import { Canvas, createPortal, useFrame, useThree } from "@react-three/fiber";
import { useAnimations, useGLTF } from "@react-three/drei";
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject, type RefObject } from "react";
import * as THREE from "three";
import { getControllerMovement } from "../input/controller";
import { clone as cloneSkeleton } from "three/examples/jsm/utils/SkeletonUtils.js";
import { ARENA_CENTER, clampToArena, clampToHockeyArena, clampToHockeyHealerHalf } from "../game/arena";
import { BOSS_ARCHETYPE_BY_ID, type BossArchetype } from "../game/bossCatalog";
import { ALTERNATE_BOSS_CONFIG, BULL_BOSS_ANIMATION_CONFIG, BULL_URL, type AlternateBossKind } from "../game/bossVisuals";
import { bossAnimationCue } from "../game/bosses/mechanicPool";
import { selectMeleeTargetIndex } from "../game/bosses/shared";
import {
  BOSS_HIT_ANIMATION_SECONDS,
  BOSS_HIT_REACTION_COOLDOWN_SECONDS,
  BOSS_MELEE_ANIMATION_SECONDS,
  bossAnimationClipName,
  bossAnimationTrigger,
  isBossAnimationOneShot,
  selectBossAnimationState,
  shouldStartBossAnimation,
  writeBossProceduralPose,
  type BossAnimationClips,
  type BossAnimationState,
  type BossProceduralPose,
} from "../game/bossAnimation";
import {
  CAMERA_FOCUS_HEIGHT,
  CAMERA_LOOK_AHEAD,
  CAMERA_ORBIT_DISTANCE,
  DEFAULT_CAMERA_PITCH,
  DEFAULT_CAMERA_YAW,
  setCameraRelativeMovement,
  updateCameraOrbit,
  type CameraOrbitState,
  type PlanarMovement,
} from "../game/cameraOrbit";
import {
  isActorAnimationOneShot,
  shouldStartActorAnimation,
  type ActorAnimationState,
} from "../game/actorAnimation";
import { PERFORMANCE_PROBE_ENABLED, recordSimulationTick, simulationTickSnapshot } from "../game/performance";
import {
  HOCKEY_ARENA_MAX_Z,
  HOCKEY_NPC_PADDLE_WIDTH,
  HOCKEY_NPC_PADDLE_Z,
  HOCKEY_PUCK_RADIUS,
  hockeyAimPreviewVisible,
  hockeyReturnDirection,
} from "../game/hockeyHealing";
import type { AiCombatantId, PartyAbilityId } from "../game/partyCombat";
import { partyAttackVfxProfile } from "../game/partyAttackVisuals";
import {
  STAFF_CAST_AFTERGLOW_SECONDS,
  STAFF_CAST_GLOW_PROFILES,
  isHealerPulseKind,
  staffCastGlowStrength,
} from "../game/staffCastGlow";
import {
  HEALER_VISUAL_PROFILES,
  type HealerVisualProfile,
} from "../game/healerVisuals";
import { isBeaconOfLightTarget } from "../game/healerMechanics";
import {
  CHARACTER_MODEL_MODE,
  type CharacterAppearanceV1,
  type CharacterModelMode,
} from "../game/characterAppearance";
import {
  weaponDefinition,
  weaponUsesBothHands,
  type CharacterWeaponGrip,
  type CharacterWeaponModelId,
} from "../game/weaponCatalog";
import { resolveCharacterEquipment } from "../game/characterEquipment";
import { HEALER_CLASS_ORDER } from "../game/healers";
import { BARRIER_RADIUS, useGameStore } from "../game/store";
import type { BossId, GamePhase, HealerClassId, MemberId, PulseKind } from "../game/types";
import { BossRoom } from "./BossRoom";
import { HealerClassAccessory } from "./HealerClassAccessory";
import { ModularCharacterBody } from "./ModularCharacterBody";
import { BossMechanicIndicators } from "./boss/BossMechanicIndicators";
import { bossBurrowPositionY, bossIsBurrowing } from "./boss/bossBurrowVisuals";
import { bossCanTrackTarget, bossDeathOpacity } from "./boss/bossDeathVisuals";
import { GameAssetProvider, LEGACY_GAME_ASSETS_FORCED, selectedGameAssetUrl, useGameGLTF } from "./GameAssetProvider";
import { characterEquipmentAssetUrl } from "./CharacterEquipmentAssets";
import {
  HOCKEY_PVP_PUCK_RADIUS,
  HOCKEY_PVP_SIDE_OFFSET_Z,
  hockeyPvpLocalToWorld,
} from "../game/hockeyHealingPvp";
import {
  BLOCKBREAKER_BRICK_DEPTH,
  BLOCKBREAKER_BRICK_WIDTH,
  BLOCKBREAKER_DANGER_Z,
  BLOCKBREAKER_MAX_BRICKS,
  BLOCKBREAKER_PUCK_RADIUS,
  blockbreakerAimPreviewVisible,
  blockbreakerColumnX,
  blockbreakerRowZ,
  type BlockbreakerBrickColor,
} from "../game/blockbreaker";
import { blockbreakerBiomeForSeed } from "../game/blockbreakerBiomes";
import {
  AETHER_MAX_ENEMY_SHOTS,
  AETHER_MAX_PLAYER_SHOTS,
  AETHER_MAX_SHIPS,
} from "../game/aetherAssault";
import {
  AETHER_STANDARD_SHIP_COLORS,
  aetherShipColorIndex,
} from "./aetherAssaultVisuals";
import { clampToBossArenaWithPortals } from "../game/rpgRoguelike/playSpace";
import {
  activePlayfieldKind,
  consumeSimulationSteps,
  FRAME_INTERVAL_JITTER_MS,
  GAMEPLAY_FRAME_INTERVAL_MS,
  isOutcomePhase,
  outcomeElapsedAfterPhaseChange,
  resetSceneClockForMode,
  sceneCanvasFrameloop,
  SIMULATION_STEP_SECONDS,
  selectSceneRenderMode,
  startSceneFrameLoop,
  summarizeFramePerformance,
  type OutcomePhase,
  type SceneRenderMode,
} from "./sceneFramePolicy";

const PARTY_MODEL_LEGACY_URLS: Record<MemberId, string> = {
  aelia: new URL("../assets/game/models/claudecraft/chars/players/druid.glb", import.meta.url).href,
  brann: new URL("../assets/game/models/claudecraft/chars/players/knight.glb", import.meta.url).href,
  nia: new URL("../assets/game/models/claudecraft/chars/players/ranger.glb", import.meta.url).href,
  orin: new URL("../assets/game/models/claudecraft/chars/players/mage.glb", import.meta.url).href,
  vale: new URL("../assets/game/models/claudecraft/chars/players/rogue.glb", import.meta.url).href,
};
const PARTY_MODEL_OPTIMIZED_URLS: Record<MemberId, string> = {
  aelia: new URL("../assets/game/models/claudecraft/chars/players/druid-uastc.glb", import.meta.url).href,
  brann: new URL("../assets/game/models/claudecraft/chars/players/knight-uastc.glb", import.meta.url).href,
  nia: new URL("../assets/game/models/claudecraft/chars/players/ranger-uastc.glb", import.meta.url).href,
  orin: new URL("../assets/game/models/claudecraft/chars/players/mage-uastc.glb", import.meta.url).href,
  vale: new URL("../assets/game/models/claudecraft/chars/players/rogue-uastc.glb", import.meta.url).href,
};
const PARTY_MODEL_URLS = Object.fromEntries(Object.keys(PARTY_MODEL_LEGACY_URLS).map((memberId) => [
  memberId,
  selectedGameAssetUrl(
    PARTY_MODEL_LEGACY_URLS[memberId as MemberId],
    PARTY_MODEL_OPTIMIZED_URLS[memberId as MemberId],
  ),
])) as Record<MemberId, string>;
const PARTY_WEAPON_LEGACY_URLS: Record<MemberId, { right: string; left?: string }> = {
  aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff.glb", import.meta.url).href },
  brann: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/shield_badge.glb", import.meta.url).href,
  },
  nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed.glb", import.meta.url).href },
  orin: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_wand.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open.glb", import.meta.url).href,
  },
  vale: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger.glb", import.meta.url).href,
  },
};
const PARTY_WEAPON_OPTIMIZED_URLS: Record<MemberId, { right: string; left?: string }> = {
  aelia: { right: new URL("../assets/game/models/claudecraft/weapons/adv_druid_staff-uastc.glb", import.meta.url).href },
  brann: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_sword_1handed-uastc.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/shield_badge-uastc.glb", import.meta.url).href,
  },
  nia: { right: new URL("../assets/game/models/claudecraft/weapons/crossbow_2handed-uastc.glb", import.meta.url).href },
  orin: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_wand-uastc.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/spellbook_open-uastc.glb", import.meta.url).href,
  },
  vale: {
    right: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
    left: new URL("../assets/game/models/claudecraft/weapons/adv_dagger-uastc.glb", import.meta.url).href,
  },
};
const PARTY_WEAPON_URLS = Object.fromEntries(Object.keys(PARTY_WEAPON_LEGACY_URLS).map((memberId) => {
  const legacy = PARTY_WEAPON_LEGACY_URLS[memberId as MemberId];
  const optimized = PARTY_WEAPON_OPTIMIZED_URLS[memberId as MemberId];
  return [memberId, {
    right: selectedGameAssetUrl(legacy.right, optimized.right),
    left: legacy.left && optimized.left ? selectedGameAssetUrl(legacy.left, optimized.left) : undefined,
  }];
})) as Record<MemberId, { right: string; left?: string }>;
const PARTY_MODEL_SCALES: Record<MemberId, number> = { aelia: 0.62, brann: 0.68, nia: 0.7, orin: 0.64, vale: 0.72 };
const PARTY_ATTACK_CLIPS: Record<MemberId, string> = {
  aelia: "2H_Melee_Attack_Chop",
  brann: "1H_Melee_Attack_Chop",
  nia: "2H_Ranged_Shoot",
  orin: "Spellcast_Shoot",
  vale: "Dualwield_Melee_Attack_Chop",
};
const CRITICAL_PARTY_MEMBER_IDS: readonly MemberId[] = ["aelia", "brann"];
const SUPPORT_PARTY_MEMBER_IDS: readonly Exclude<MemberId, "aelia" | "brann">[] = ["nia", "orin", "vale"];
type GameStoreState = ReturnType<typeof useGameStore.getState>;

interface BossFadeMaterial {
  material: THREE.Material;
  baseOpacity: number;
  baseTransparent: boolean;
  baseDepthWrite: boolean;
}

function createBossRenderModel(source: THREE.Object3D) {
  const model = cloneSkeleton(source);
  const materialClones = new Map<THREE.Material, THREE.Material>();
  model.traverse((object) => {
    if (!(object instanceof THREE.Mesh)) return;
    object.castShadow = true;
    object.receiveShadow = true;
    const cloneMaterial = (material: THREE.Material) => {
      const existing = materialClones.get(material);
      if (existing) return existing;
      const clone = material.clone();
      materialClones.set(material, clone);
      return clone;
    };
    object.material = Array.isArray(object.material)
      ? object.material.map(cloneMaterial)
      : cloneMaterial(object.material);
  });
  model.updateMatrixWorld(true);
  const modelTopY = new THREE.Box3().setFromObject(model).max.y;
  return {
    model,
    modelTopY,
    fadeMaterials: [...materialClones.values()].map((material): BossFadeMaterial => ({
      material,
      baseOpacity: material.opacity,
      baseTransparent: material.transparent,
      baseDepthWrite: material.depthWrite,
    })),
  };
}

function applyBossOpacity(materials: readonly BossFadeMaterial[], opacity: number) {
  const fading = opacity < 0.999;
  for (const entry of materials) {
    const transparent = entry.baseTransparent || fading;
    if (entry.material.transparent !== transparent) {
      entry.material.transparent = transparent;
      entry.material.needsUpdate = true;
    }
    entry.material.opacity = entry.baseOpacity * opacity;
    entry.material.depthWrite = fading ? false : entry.baseDepthWrite;
  }
}

function useBossDeathFade(
  group: RefObject<THREE.Group | null>,
  light: RefObject<THREE.PointLight | null>,
  materials: readonly BossFadeMaterial[],
  defeated: boolean,
  baseLightIntensity: number,
  bossId: BossId,
) {
  const elapsed = useRef(0);
  const lastOpacity = useRef(1);
  useFrame((_, delta) => {
    if (!defeated) {
      elapsed.current = 0;
      if (lastOpacity.current !== 1) {
        lastOpacity.current = 1;
        if (group.current) group.current.visible = true;
        if (light.current) light.current.intensity = baseLightIntensity;
        applyBossOpacity(materials, 1);
      }
      return;
    }
    elapsed.current += delta;
    const opacity = bossDeathOpacity(elapsed.current, bossId);
    if (opacity === lastOpacity.current) return;
    lastOpacity.current = opacity;
    if (group.current) group.current.visible = opacity > 0;
    if (light.current) light.current.intensity = baseLightIntensity * opacity;
    applyBossOpacity(materials, opacity);
  });
}

function encounterBossAt(state: GameStoreState, bossIndex: number, opponent = false) {
  if (opponent) return { boss: state.hockeyPvpOpponent.boss, motion: state.hockeyPvpOpponent.bossMotion };
  return bossIndex === 0
    ? { boss: state.boss, motion: state.bossMotion }
    : state.additionalBosses[bossIndex - 1];
}

function useBossAnimationPlayback({
  actions,
  clips,
  archetype,
  bossIndex,
  opponent,
  modelRoot,
}: {
  actions: Record<string, THREE.AnimationAction | null>;
  clips: BossAnimationClips;
  archetype: BossArchetype;
  bossIndex: number;
  opponent: boolean;
  modelRoot: RefObject<THREE.Group | null>;
}) {
  const activeClip = useRef<string | undefined>(undefined);
  const activeState = useRef<BossAnimationState | undefined>(undefined);
  const activeTrigger = useRef(Number.NaN);
  const previousHp = useRef<number | undefined>(undefined);
  const observedMeleeAt = useRef(-1);
  const meleeElapsed = useRef(Number.POSITIVE_INFINITY);
  const hitElapsed = useRef(Number.POSITIVE_INFINITY);
  const hitReadyAt = useRef(0);
  const hitTrigger = useRef(0);
  const observedPhaseStartedAt = useRef(Number.NaN);
  const phaseElapsed = useRef(0);
  const pose = useRef<BossProceduralPose>({
    x: 0,
    y: 0,
    z: 0,
    pitch: 0,
    yaw: 0,
    roll: 0,
    scaleX: 1,
    scaleY: 1,
    scaleZ: 1,
  });

  useFrame((_, delta) => {
    const state = useGameStore.getState();
    const current = encounterBossAt(state, bossIndex, opponent);
    if (!current) return;
    const { boss, motion } = current;

    if (previousHp.current !== undefined
      && boss.hp > 0
      && boss.hp < previousHp.current
      && state.time >= hitReadyAt.current) {
      hitElapsed.current = 0;
      hitReadyAt.current = state.time + BOSS_HIT_REACTION_COOLDOWN_SECONDS;
      hitTrigger.current += 1;
    }
    previousHp.current = boss.hp;

    if (motion.lastMeleeAt >= 0 && motion.lastMeleeAt !== observedMeleeAt.current) {
      observedMeleeAt.current = motion.lastMeleeAt;
      meleeElapsed.current = 0;
    }
    if (motion.phaseStartedAt !== observedPhaseStartedAt.current) {
      observedPhaseStartedAt.current = motion.phaseStartedAt;
      phaseElapsed.current = 0;
    }

    const mechanicCue = bossAnimationCue(motion);
    const animationState = selectBossAnimationState({
      defeated: boss.hp <= 0 || state.phase === "victory",
      activeMechanic: motion.activeMechanicId !== null,
      mechanicCue,
      meleeElapsed: meleeElapsed.current,
      hitElapsed: hitElapsed.current,
    });
    const trigger = bossAnimationTrigger(
      animationState,
      motion.phaseStartedAt,
      motion.lastMeleeAt,
      hitTrigger.current,
    );
    const clipName = bossAnimationClipName(clips, animationState);

    if (shouldStartBossAnimation(activeState.current, activeTrigger.current, animationState, trigger)) {
      const next = actions[clipName];
      if (next) {
        const clipChanged = activeClip.current !== clipName;
        if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
        const timeScale = archetype === "duelist" && motion.mode === "mantis_line_telegraph"
          ? 0.55
          : archetype === "duelist" && motion.mode === "mantis_cross_telegraph"
            ? 0.6
            : motion.mode === "charging"
              ? 1.3
              : 1;
        next.reset().setEffectiveWeight(1).setEffectiveTimeScale(timeScale);
        if (clipChanged) next.fadeIn(0.16);
        if (isBossAnimationOneShot(animationState)) {
          next.setLoop(THREE.LoopOnce, 1);
          next.clampWhenFinished = true;
        } else {
          next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
          next.clampWhenFinished = false;
        }
        next.play();
        activeClip.current = clipName;
        activeState.current = animationState;
        activeTrigger.current = trigger;
      }
    }

    const animationElapsed = animationState === "melee"
      ? meleeElapsed.current
      : animationState === "hit"
        ? hitElapsed.current
        : phaseElapsed.current;
    const phaseDuration = Number.isFinite(motion.phaseEndsAt) && motion.phaseEndsAt > motion.phaseStartedAt
      ? motion.phaseEndsAt - motion.phaseStartedAt
      : 1;
    writeBossProceduralPose(
      pose.current,
      archetype,
      motion.mode,
      animationState,
      animationElapsed,
      phaseDuration,
    );
    if (modelRoot.current) {
      const nextPose = pose.current;
      modelRoot.current.position.set(nextPose.x, nextPose.y, nextPose.z);
      modelRoot.current.rotation.set(nextPose.pitch, nextPose.yaw, nextPose.roll);
      modelRoot.current.scale.set(nextPose.scaleX, nextPose.scaleY, nextPose.scaleZ);
    }

    meleeElapsed.current = Math.min(BOSS_MELEE_ANIMATION_SECONDS, meleeElapsed.current + delta);
    hitElapsed.current = Math.min(BOSS_HIT_ANIMATION_SECONDS, hitElapsed.current + delta);
    phaseElapsed.current += delta;
  });
}

function targetBossMotion(state: GameStoreState) {
  if (state.boss.hp > 0) return state.bossMotion;
  return state.additionalBosses.find((entry) => entry.boss.hp > 0)?.motion ?? state.bossMotion;
}

function targetBossMotionByInstance(state: GameStoreState, instanceId?: string) {
  if (!instanceId || instanceId === `boss-0-${state.boss.id}`) return targetBossMotion(state);
  return state.additionalBosses.find((entry) => entry.instanceId === instanceId)?.motion ?? targetBossMotion(state);
}

const PARTY_WEAPON_MODEL_IDS: Record<MemberId, { right: CharacterWeaponModelId; left?: CharacterWeaponModelId }> = {
  aelia: { right: "cc/adv_druid_staff" },
  brann: { right: "cc/adv_sword_1handed", left: "cc/shield_badge" },
  nia: { right: "cc/crossbow_2handed" },
  orin: { right: "cc/adv_wand", left: "cc/spellbook_open" },
  vale: { right: "cc/adv_dagger", left: "cc/adv_dagger" },
};
const CROSSBOW_QUIVER_URL = characterEquipmentAssetUrl("cc/quiver");

const VARIANT_GRIPS: Record<Exclude<CharacterWeaponGrip, "crossbow" | "prop">, { lift: number; maxHeight: number }> = {
  upright: { lift: 0.04, maxHeight: 2 },
  dagger: { lift: 0.04, maxHeight: 1.4 },
  staff: { lift: 0.18, maxHeight: 2.4 },
  wand: { lift: 0.04, maxHeight: 1.2 },
  polearm: { lift: 0.12, maxHeight: 2.75 },
};

const CROSSBOW_MOUNTS: Record<Extract<CharacterWeaponModelId, `cc/${string}crossbow${string}`>, { x: number; y: number; scale: number }> = {
  "cc/crossbow_1handed": { x: 0.255, y: 0.04, scale: 0.6109 },
  "cc/crossbow_2handed": { x: 0.3381, y: 0.058, scale: 0.7204 },
  "cc/skeleton_crossbow": { x: 0.33, y: 0.064, scale: 0.7094 },
};

function resolveRigNode(root: THREE.Object3D, authoredName: string) {
  return root.getObjectByName(authoredName)
    ?? root.getObjectByName(authoredName.replace(/[[\].:/]/g, ""));
}

function flattenCrossbowScene(scene: THREE.Object3D) {
  if (scene.children.length !== 1) return scene;
  const holder = new THREE.Group();
  const child = scene.children[0];
  holder.scale.copy(child.scale);
  child.position.set(0, 0, 0);
  child.rotation.set(0, 0, 0);
  child.scale.set(1, 1, 1);
  scene.remove(child);
  holder.add(child);
  return holder;
}

function prepareHeldWeapon(
  scene: THREE.Object3D,
  modelId: CharacterWeaponModelId,
  grip: CharacterWeaponGrip,
  side: "r" | "l",
) {
  // Shields and spellbooks carry useful authored offsets, so keep their scene transform.
  if (grip === "prop") return scene;

  if (grip === "crossbow") {
    const weapon = flattenCrossbowScene(scene);
    const mount = CROSSBOW_MOUNTS[modelId as keyof typeof CROSSBOW_MOUNTS]
      ?? CROSSBOW_MOUNTS["cc/crossbow_2handed"];
    weapon.position.set(mount.x, mount.y, 0);
    weapon.quaternion.set(0, 0.7071068, 0, 0.7071067);
    weapon.scale.setScalar(mount.scale);
    return weapon;
  }

  const weapon = new THREE.Group();
  weapon.add(scene);
  const profile = VARIANT_GRIPS[grip];
  const maxHeight = grip === "upright" && weaponUsesBothHands(modelId)
    ? 2.75
    : profile.maxHeight;
  const bounds = new THREE.Box3().setFromObject(weapon);
  const height = bounds.max.y - bounds.min.y;
  const scale = height > 0.001 ? Math.min(1, maxHeight / height) : 1;
  weapon.position.set(0, profile.lift, 0);
  weapon.quaternion.set(0, side === "l" ? 0 : 1, 0, side === "l" ? 1 : 0);
  weapon.scale.multiplyScalar(scale);
  return weapon;
}

function prepareBackQuiver(scene: THREE.Object3D) {
  const quiver = new THREE.Group();
  quiver.add(scene);
  const bounds = new THREE.Box3().setFromObject(quiver);
  const height = bounds.max.y - bounds.min.y;
  quiver.scale.multiplyScalar(height > 0.001 ? 0.92 / height : 1);
  quiver.position.set(0.16, 0.08, -0.24);
  quiver.rotation.set(0.08, Math.PI, -0.16);
  return quiver;
}

interface WeaponAssetReference {
  count: number;
  scene: THREE.Object3D;
  releaseTimer: ReturnType<typeof setTimeout> | null;
}

const WEAPON_ASSET_REFERENCES = new Map<string, WeaponAssetReference>();
const PENDING_WEAPON_ASSET_RELEASE_MS = 5_000;
const MAX_PENDING_WEAPON_ASSETS = 16;
const PENDING_WEAPON_ASSETS = new Map<string, ReturnType<typeof setTimeout>>();

function releasePendingWeaponAsset(url: string) {
  const releaseTimer = PENDING_WEAPON_ASSETS.get(url);
  if (releaseTimer === undefined) return;
  clearTimeout(releaseTimer);
  PENDING_WEAPON_ASSETS.delete(url);
  // An abandoned Suspense render never reaches useEffect, but useGLTF still caches
  // its request/result. Clear that cache entry once no committed consumer owns it.
  if (!WEAPON_ASSET_REFERENCES.has(url)) useGLTF.clear(url);
}

function registerPendingWeaponAsset(url: string) {
  if (WEAPON_ASSET_REFERENCES.has(url) || PENDING_WEAPON_ASSETS.has(url)) return;
  PENDING_WEAPON_ASSETS.set(url, setTimeout(
    () => releasePendingWeaponAsset(url),
    PENDING_WEAPON_ASSET_RELEASE_MS,
  ));
  while (PENDING_WEAPON_ASSETS.size > MAX_PENDING_WEAPON_ASSETS) {
    const oldestUrl = PENDING_WEAPON_ASSETS.keys().next().value as string | undefined;
    if (!oldestUrl) break;
    releasePendingWeaponAsset(oldestUrl);
  }
}

function commitPendingWeaponAsset(url: string) {
  const releaseTimer = PENDING_WEAPON_ASSETS.get(url);
  if (releaseTimer === undefined) return;
  clearTimeout(releaseTimer);
  PENDING_WEAPON_ASSETS.delete(url);
}

function disposeWeaponAssetScene(scene: THREE.Object3D) {
  const geometries = new Set<THREE.BufferGeometry>();
  const materials = new Set<THREE.Material>();
  const textures = new Set<THREE.Texture>();
  scene.traverse((object) => {
    if (!(object instanceof THREE.Mesh)) return;
    geometries.add(object.geometry);
    for (const material of Array.isArray(object.material) ? object.material : [object.material]) {
      materials.add(material);
      for (const value of Object.values(material)) {
        if (value instanceof THREE.Texture) textures.add(value);
      }
    }
  });
  for (const geometry of geometries) geometry.dispose();
  for (const material of materials) material.dispose();
  for (const texture of textures) texture.dispose();
}

/**
 * Drei caches parsed GLBs forever by default. Weapon browsing can touch eleven large
 * embedded atlases, so release an asset after its last mounted user disappears.
 */
function useWeaponGLTF(url: string) {
  registerPendingWeaponAsset(url);
  const gltf = useGameGLTF(url);
  useEffect(() => {
    commitPendingWeaponAsset(url);
    const existing = WEAPON_ASSET_REFERENCES.get(url);
    if (existing) {
      existing.count += 1;
      if (existing.releaseTimer !== null) {
        clearTimeout(existing.releaseTimer);
        existing.releaseTimer = null;
      }
    } else {
      WEAPON_ASSET_REFERENCES.set(url, { count: 1, scene: gltf.scene, releaseTimer: null });
    }
    return () => {
      const reference = WEAPON_ASSET_REFERENCES.get(url);
      if (!reference) return;
      reference.count = Math.max(0, reference.count - 1);
      if (reference.count > 0 || reference.releaseTimer !== null) return;
      reference.releaseTimer = setTimeout(() => {
        const current = WEAPON_ASSET_REFERENCES.get(url);
        if (!current || current.count > 0) return;
        disposeWeaponAssetScene(current.scene);
        useGLTF.clear(url);
        WEAPON_ASSET_REFERENCES.delete(url);
      }, 0);
    };
  }, [gltf.scene, url]);
  return gltf;
}

interface StaffGlowMaterialBinding {
  material: THREE.Material & { emissive: THREE.Color; emissiveIntensity: number };
  baseEmissive: THREE.Color;
  baseEmissiveIntensity: number;
}

function supportsEmissiveGlow(material: THREE.Material): material is StaffGlowMaterialBinding["material"] {
  return "emissive" in material
    && material.emissive instanceof THREE.Color
    && "emissiveIntensity" in material
    && typeof material.emissiveIntensity === "number";
}

function prepareStaffGlow(scene: THREE.Object3D) {
  const materialClones = new Map<THREE.Material, THREE.Material>();
  const bindings: StaffGlowMaterialBinding[] = [];
  scene.traverse((object) => {
    if (!(object instanceof THREE.Mesh)) return;
    const cloneMaterial = (source: THREE.Material) => {
      const existing = materialClones.get(source);
      if (existing) return existing;
      const clone = source.clone();
      materialClones.set(source, clone);
      if (supportsEmissiveGlow(clone)) {
        bindings.push({
          material: clone,
          baseEmissive: clone.emissive.clone(),
          baseEmissiveIntensity: clone.emissiveIntensity,
        });
      }
      return clone;
    };
    object.material = Array.isArray(object.material)
      ? object.material.map(cloneMaterial)
      : cloneMaterial(object.material);
  });

  scene.updateMatrixWorld(true);
  const bounds = new THREE.Box3().setFromObject(scene);
  const height = bounds.max.y - bounds.min.y;
  const tipPosition: [number, number, number] = [
    (bounds.min.x + bounds.max.x) * 0.5,
    bounds.max.y - height * 0.06,
    (bounds.min.z + bounds.max.z) * 0.5,
  ];
  return { bindings, materials: [...materialClones.values()], tipPosition };
}

function createActorScene(source: THREE.Object3D, profile: HealerVisualProfile | null) {
  const scene = cloneSkeleton(source);
  if (!profile) return scene;

  // Preserve each GLB's authored material and texture palette. Class identity belongs
  // in geometry, equipment, and small accents; whole-body tinting erases surface detail.
  for (const nodeName of profile.hiddenNodes) {
    const node = resolveRigNode(scene, nodeName);
    if (node) node.visible = false;
  }
  return scene;
}

function createRigActorScene(source: THREE.Object3D) {
  const scene = cloneSkeleton(source);
  const renderNodes: THREE.Object3D[] = [];
  scene.traverse((object) => {
    if (object instanceof THREE.Mesh) renderNodes.push(object);
  });
  for (const renderNode of renderNodes) renderNode.parent?.remove(renderNode);
  return scene;
}

function disposeActorSkeletons(scene: THREE.Object3D) {
  const skeletons = new Set<THREE.Skeleton>();
  scene.traverse((object) => {
    if (object instanceof THREE.SkinnedMesh) skeletons.add(object.skeleton);
  });
  for (const skeleton of skeletons) skeleton.dispose();
}

function StaffCastGlow({
  bindings,
  position,
}: {
  bindings: readonly StaffGlowMaterialBinding[];
  position: readonly [number, number, number];
}) {
  const aura = useRef<THREE.Group>(null);
  const auraMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const light = useRef<THREE.PointLight>(null);
  const glowColor = useMemo(() => new THREE.Color(), []);
  const lastClassId = useRef<string | null>(null);
  const lastPulseId = useRef(useGameStore.getState().scenePulse.id);
  const afterglowAge = useRef<number | null>(null);
  const lastStrength = useRef(Number.NaN);

  useFrame(({ clock }, delta) => {
    const state = useGameStore.getState();
    if (state.scenePulse.id !== lastPulseId.current) {
      lastPulseId.current = state.scenePulse.id;
      if (isHealerPulseKind(state.scenePulse.kind)) afterglowAge.current = 0;
    } else if (afterglowAge.current !== null) {
      afterglowAge.current += delta;
      if (afterglowAge.current >= STAFF_CAST_AFTERGLOW_SECONDS) afterglowAge.current = null;
    }

    const activeCast = state.activeCast;
    const castingProgress = activeCast
      ? (state.time - activeCast.startedAt) / Math.max(0.001, activeCast.completesAt - activeCast.startedAt)
      : null;
    const baseStrength = staffCastGlowStrength({ castingProgress, afterglowAge: afterglowAge.current });
    const strength = Math.min(1, baseStrength * (0.94 + Math.sin(clock.elapsedTime * 11) * 0.06));
    const classChanged = lastClassId.current !== state.healerClassId;
    if (classChanged) {
      lastClassId.current = state.healerClassId;
      glowColor.set(STAFF_CAST_GLOW_PROFILES[state.healerClassId].color);
      if (auraMaterial.current) auraMaterial.current.color.copy(glowColor);
      if (light.current) light.current.color.copy(glowColor);
    }
    if (!classChanged && Math.abs(strength - lastStrength.current) < 0.001) return;
    lastStrength.current = strength;

    for (const binding of bindings) {
      binding.material.emissive.copy(binding.baseEmissive).lerp(glowColor, strength);
      binding.material.emissiveIntensity = binding.baseEmissiveIntensity + strength * 3.2;
    }
    if (aura.current) {
      aura.current.visible = strength > 0.01;
      aura.current.scale.setScalar(0.82 + strength * 0.3);
    }
    if (auraMaterial.current) auraMaterial.current.opacity = strength * 0.62;
    if (light.current) light.current.intensity = strength * 2.8;
  });

  return (
    <group ref={aura} position={position} visible={false}>
      <mesh>
        <sphereGeometry args={[0.18, 12, 8]} />
        <meshBasicMaterial
          ref={auraMaterial}
          transparent
          opacity={0}
          depthWrite={false}
          blending={THREE.AdditiveBlending}
          toneMapped={false}
        />
      </mesh>
      <pointLight ref={light} intensity={0} distance={2.8} decay={2} />
    </group>
  );
}

function PartyCharacterModel({
  memberId,
  visualMemberId = memberId,
  healerClassId,
  appearanceOverride,
  modelMode = CHARACTER_MODEL_MODE,
  animationState,
  animationTrigger,
}: {
  memberId: MemberId;
  visualMemberId?: MemberId;
  healerClassId?: HealerClassId;
  appearanceOverride?: CharacterAppearanceV1;
  modelMode?: CharacterModelMode;
  animationState: MutableRefObject<ActorAnimationState>;
  animationTrigger: MutableRefObject<number>;
}) {
  const healerVisual = memberId === "aelia" && healerClassId
    ? HEALER_VISUAL_PROFILES[healerClassId]
    : null;
  const healerAppearance = healerVisual
    ? modelMode === "modular" && appearanceOverride
      ? appearanceOverride
      : healerVisual.appearance
    : null;
  const usesModularRenderer = Boolean(healerAppearance && modelMode === "modular");
  const modularAppearance = usesModularRenderer
    ? healerAppearance
    : null;
  const bodyMemberId = healerVisual?.bodyMemberId ?? visualMemberId;
  const scaleMemberId = modularAppearance?.scaleSourceMemberId ?? bodyMemberId;
  const animationMemberId = healerVisual?.animationMemberId ?? visualMemberId;
  const bodyGltf = useGameGLTF(PARTY_MODEL_URLS[bodyMemberId]);
  const animationGltf = useGameGLTF(PARTY_MODEL_URLS[animationMemberId]);
  const actorScene = useMemo(
    () => usesModularRenderer
      ? createRigActorScene(animationGltf.scene)
      : createActorScene(bodyGltf.scene, healerVisual),
    [animationGltf.scene, bodyGltf.scene, healerVisual, usesModularRenderer],
  );
  const partyLoadout = PARTY_WEAPON_URLS[visualMemberId];
  const partyModelIds = PARTY_WEAPON_MODEL_IDS[visualMemberId];
  const resolvedHealerEquipment = healerAppearance
    ? resolveCharacterEquipment(healerAppearance)
    : null;
  const rightModelId = resolvedHealerEquipment?.mainHand.modelId ?? partyModelIds.right;
  const effectiveLeftModelId = resolvedHealerEquipment?.offHand?.modelId ?? (healerAppearance ? undefined : partyModelIds.left);
  const wearsCrossbowQuiver = Boolean(usesModularRenderer && resolvedHealerEquipment?.backPropModelId);
  const loadout = healerAppearance
    ? {
        right: characterEquipmentAssetUrl(rightModelId),
        left: effectiveLeftModelId ? characterEquipmentAssetUrl(effectiveLeftModelId) : undefined,
      }
    : partyLoadout;
  const grips = {
    right: weaponDefinition(rightModelId).grip as CharacterWeaponGrip,
    left: effectiveLeftModelId
      ? weaponDefinition(effectiveLeftModelId).grip as CharacterWeaponGrip
      : undefined,
  };
  const rightWeapon = useWeaponGLTF(loadout.right);
  const leftWeapon = useWeaponGLTF(loadout.left ?? loadout.right);
  const quiverWeapon = useWeaponGLTF(wearsCrossbowQuiver ? CROSSBOW_QUIVER_URL : loadout.right);
  const rightHandSlot = resolveRigNode(actorScene, "handslot.r");
  const leftHandSlot = resolveRigNode(actorScene, "handslot.l");
  const backSlot = resolveRigNode(actorScene, "chest") ?? resolveRigNode(actorScene, "spine");
  const accessorySlot = healerVisual ? resolveRigNode(actorScene, "head") : null;
  const renderedAppearance = useMemo(
    () => modularAppearance && resolvedHealerEquipment?.suppressSkinnedBack
      ? { ...modularAppearance, backPartId: null }
      : modularAppearance,
    [modularAppearance, resolvedHealerEquipment?.suppressSkinnedBack],
  );
  const rightWeaponScene = useMemo(
    () => prepareHeldWeapon(rightWeapon.scene.clone(true), rightModelId, grips.right, "r"),
    [grips.right, rightModelId, rightWeapon.scene],
  );
  const leftWeaponScene = useMemo(
    () => loadout.left && effectiveLeftModelId
      ? prepareHeldWeapon(leftWeapon.scene.clone(true), effectiveLeftModelId, grips.left ?? grips.right, "l")
      : null,
    [effectiveLeftModelId, grips.left, grips.right, leftWeapon.scene, loadout.left],
  );
  const quiverScene = useMemo(
    () => wearsCrossbowQuiver ? prepareBackQuiver(quiverWeapon.scene.clone(true)) : null,
    [quiverWeapon.scene, wearsCrossbowQuiver],
  );
  const staffGlow = useMemo(
    () => memberId === "aelia" && grips.right !== "crossbow" ? prepareStaffGlow(rightWeaponScene) : null,
    [grips.right, memberId, rightWeaponScene],
  );
  const { actions } = useAnimations(animationGltf.animations, actorScene);
  const activeClip = useRef<string | undefined>(undefined);
  const activeState = useRef<ActorAnimationState | undefined>(undefined);
  const activeTrigger = useRef(Number.NaN);

  useEffect(() => {
    // A newly selected weapon can suspend this subtree while its GLB loads. Force
    // the actor action to restart after it resumes instead of leaving the shared
    // healer rig in its bind pose.
    activeState.current = undefined;
    activeTrigger.current = Number.NaN;
  }, [effectiveLeftModelId, rightModelId]);

  useEffect(() => {
    actorScene.traverse((object) => {
      if (object instanceof THREE.Mesh) {
        object.castShadow = true;
        object.receiveShadow = true;
      }
    });
  }, [actorScene]);

  useEffect(() => () => disposeActorSkeletons(actorScene), [actorScene]);

  useEffect(() => {
    for (const weaponScene of [rightWeaponScene, leftWeaponScene, quiverScene]) {
      if (!weaponScene) continue;
      weaponScene.traverse((object) => {
        if (object instanceof THREE.Mesh) {
          object.castShadow = true;
          object.receiveShadow = true;
          // Animated hand sockets can cross a mesh's original root-space frustum.
          object.frustumCulled = false;
        }
      });
    }
  }, [leftWeaponScene, quiverScene, rightWeaponScene]);

  useEffect(() => () => {
    for (const material of staffGlow?.materials ?? []) material.dispose();
  }, [staffGlow]);

  useFrame(() => {
    const state = animationState.current;
    const trigger = animationTrigger.current;
    const clipName = state === "death"
      ? "Death_A"
      : state === "hit"
        ? "Hit_A"
        : state === "run"
          ? "Running_A"
          : state === "walk"
            ? "Walking_A"
            : state === "cast"
              ? "Spellcasting"
              : state === "attack"
                ? PARTY_ATTACK_CLIPS[visualMemberId]
                : "Idle";
    if (!shouldStartActorAnimation(activeState.current, activeTrigger.current, state, trigger)) return;
    const next = actions[clipName];
    if (!next) return;
    const clipChanged = activeClip.current !== clipName;
    if (clipChanged && activeClip.current) actions[activeClip.current]?.fadeOut(0.16);
    next.reset().setEffectiveWeight(1).setEffectiveTimeScale(state === "run" ? 1.1 : 1);
    if (clipChanged) next.fadeIn(0.16);
    if (isActorAnimationOneShot(state)) {
      next.setLoop(THREE.LoopOnce, 1);
      next.clampWhenFinished = true;
    } else {
      next.setLoop(THREE.LoopRepeat, Number.POSITIVE_INFINITY);
      next.clampWhenFinished = false;
    }
    next.play();
    activeClip.current = clipName;
    activeState.current = state;
    activeTrigger.current = trigger;
  });

  return (
    <>
      {renderedAppearance && (
        <ModularCharacterBody
          actorScene={actorScene}
          appearance={renderedAppearance}
          modelUrls={PARTY_MODEL_URLS}
        />
      )}
      <primitive object={actorScene} scale={PARTY_MODEL_SCALES[scaleMemberId]} />
      {rightHandSlot && createPortal(<primitive object={rightWeaponScene} />, rightHandSlot)}
      {rightHandSlot && staffGlow && createPortal(
        <StaffCastGlow bindings={staffGlow.bindings} position={staffGlow.tipPosition} />,
        rightHandSlot,
      )}
      {leftWeaponScene && leftHandSlot && createPortal(<primitive object={leftWeaponScene} />, leftHandSlot)}
      {quiverScene && backSlot && createPortal(<primitive object={quiverScene} />, backSlot)}
      {accessorySlot && healerVisual && createPortal(
        <HealerClassAccessory profile={healerVisual} />,
        accessorySlot,
      )}
    </>
  );
}

const MIN_RENDER_DPR = 1;
const MAX_RENDER_DPR = 1.25;

/**
 * Requests demand frames only while gameplay or a finite outcome animation is
 * active. Keeping one R3F clock domain avoids demand/manual RAF handoff races.
 */
function SceneFrameScheduler({
  dpr,
  mode,
  outcomePhase,
  onDprChange,
  onOutcomeComplete,
}: {
  dpr: number;
  mode: SceneRenderMode;
  outcomePhase: OutcomePhase | null;
  onDprChange: (next: number) => void;
  onOutcomeComplete: () => void;
}) {
  const { clock, get, invalidate } = useThree();
  const simulationAccumulator = useRef(0);
  const outcomeElapsedSeconds = useRef(0);
  const previousOutcomePhase = useRef<OutcomePhase | null>(null);
  const slowFrameMs = useRef(0);
  const stableFrameMs = useRef(0);
  const dprRef = useRef(dpr);
  dprRef.current = dpr;

  useLayoutEffect(() => {
    if (mode === "suspended") get().internal.frames = 0;
    resetSceneClockForMode(clock, mode);
    outcomeElapsedSeconds.current = outcomeElapsedAfterPhaseChange(
      previousOutcomePhase.current,
      outcomePhase,
      outcomeElapsedSeconds.current,
    );
    previousOutcomePhase.current = outcomePhase;
    if (mode !== "active") {
      simulationAccumulator.current = 0;
      slowFrameMs.current = 0;
      stableFrameMs.current = 0;
    }

    if (mode === "static") {
      invalidate();
      return;
    }
    if (mode === "suspended") return;

    return startSceneFrameLoop({
      mode,
      initialOutcomeElapsedSeconds: outcomeElapsedSeconds.current,
      requestFrame: requestAnimationFrame,
      cancelFrame: cancelAnimationFrame,
      onFrame: (sample) => {
        if (mode === "active") {
          const stepResult = consumeSimulationSteps(simulationAccumulator.current, sample.elapsedSeconds);
          simulationAccumulator.current = stepResult.remainderSeconds;
          for (let step = 0; step < stepResult.steps; step += 1) {
            const startedAt = PERFORMANCE_PROBE_ENABLED ? performance.now() : 0;
            useGameStore.getState().tick(SIMULATION_STEP_SECONDS);
            if (PERFORMANCE_PROBE_ENABLED) recordSimulationTick(performance.now() - startedAt);
          }

          if (sample.elapsedMs > 20) {
            slowFrameMs.current += sample.elapsedMs;
            stableFrameMs.current = 0;
            if (slowFrameMs.current >= 2_000 && dprRef.current > MIN_RENDER_DPR) {
              onDprChange(Math.max(MIN_RENDER_DPR, dprRef.current - 0.125));
              slowFrameMs.current = 0;
            }
          } else if (sample.elapsedMs > 0) {
            slowFrameMs.current = 0;
            stableFrameMs.current += sample.elapsedMs;
            if (stableFrameMs.current >= 10_000 && dprRef.current < MAX_RENDER_DPR) {
              onDprChange(Math.min(MAX_RENDER_DPR, dprRef.current + 0.125));
              stableFrameMs.current = 0;
            }
          }
        } else {
          outcomeElapsedSeconds.current = sample.outcomeElapsedSeconds;
        }

        invalidate();
      },
      onOutcomeComplete,
    });
  }, [clock, get, invalidate, mode, onDprChange, onOutcomeComplete, outcomePhase]);

  return null;
}

function BeaconOfLightMarker() {
  const marker = useRef<THREE.Group>(null);
  const glow = useRef<THREE.MeshBasicMaterial>(null);

  useFrame(({ clock }) => {
    if (!marker.current) return;
    const pulse = Math.sin(clock.elapsedTime * 4.4);
    marker.current.position.y = 2.62 + pulse * 0.06;
    marker.current.rotation.y = clock.elapsedTime * 0.7;
    if (glow.current) glow.current.opacity = 0.78 + pulse * 0.12;
  });

  return (
    <group ref={marker} position={[0, 2.62, 0]}>
      <pointLight color="#ffe989" intensity={3.2} distance={3.2} decay={2} />
      <mesh>
        <octahedronGeometry args={[0.18, 0]} />
        <meshBasicMaterial
          ref={glow}
          color="#fff4af"
          transparent
          opacity={0.9}
          blending={THREE.AdditiveBlending}
          depthWrite={false}
          toneMapped={false}
        />
      </mesh>
      <mesh rotation={[Math.PI / 2, 0, 0]}>
        <torusGeometry args={[0.29, 0.025, 8, 24]} />
        <meshBasicMaterial
          color="#ffd45e"
          transparent
          opacity={0.8}
          blending={THREE.AdditiveBlending}
          depthWrite={false}
          toneMapped={false}
        />
      </mesh>
      <mesh position={[0, -0.38, 0]}>
        <coneGeometry args={[0.23, 0.72, 12, 1, true]} />
        <meshBasicMaterial
          color="#ffe68a"
          side={THREE.DoubleSide}
          transparent
          opacity={0.16}
          blending={THREE.AdditiveBlending}
          depthWrite={false}
          toneMapped={false}
        />
      </mesh>
    </group>
  );
}

function Character({ memberId, selected = false }: { memberId: Exclude<MemberId, "aelia">; selected?: boolean }) {
  const group = useRef<THREE.Group>(null);
  const animationState = useRef<ActorAnimationState>("idle");
  const animationTrigger = useRef(0);
  const beaconed = useGameStore((state) => isBeaconOfLightTarget(memberId, state.healerMechanic, state.time));
  const visualArchetype = useGameStore((state) => state.party.find((member) => member.id === memberId)?.runProfile?.visualArchetype);
  const visualMemberId: MemberId = visualArchetype === "knight"
    ? "brann"
    : visualArchetype === "ranger"
      ? "nia"
      : visualArchetype === "mage"
        ? "orin"
        : visualArchetype === "rogue"
          ? "vale"
          : memberId;
  useEffect(() => {
    const start = useGameStore.getState().partyPositions[memberId];
    group.current?.position.set(start[0], 0.025, start[1]);
  }, [memberId]);

  useFrame((_, delta) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const target = state.partyPositions[memberId];
    const dx = target[0] - group.current.position.x;
    const dz = target[1] - group.current.position.z;
    const moving = Math.hypot(dx, dz) > 0.015;
    const movementBlend = 1 - Math.pow(0.002, delta);
    group.current.position.x = THREE.MathUtils.lerp(group.current.position.x, target[0], movementBlend);
    group.current.position.z = THREE.MathUtils.lerp(group.current.position.z, target[1], movementBlend);

    const member = state.party.find((entry) => entry.id === memberId)!;
    const knocked = member.knockedUntil > state.time;
    const visualAction = state.partyCombat.combatants[memberId].visualAction;
    const targetMotion = targetBossMotionByInstance(state, visualAction?.targetInstanceId);
    const attacking = state.phase === "combat"
      && visualAction !== null
      && visualAction.endsAt > state.time;
    animationTrigger.current = member.hp <= 0
      ? 0
      : knocked
        ? member.knockedUntil
        : attacking
          ? visualAction.startedAt
          : 0;
    animationState.current = member.hp <= 0
      ? "death"
      : knocked
        ? "hit"
        : attacking
          ? "attack"
          : moving
            ? (member.runProfile?.combatKitId === "melee" || !member.runProfile && memberId === "vale") ? "run" : "walk"
            : "idle";
    if (!knocked && member.hp > 0 && (state.phase === "combat" || moving)) {
      const faceBoss = state.phase === "combat";
      const facingX = faceBoss ? targetMotion.position[0] - group.current.position.x : dx;
      const facingZ = faceBoss ? targetMotion.position[1] - group.current.position.z : dz;
      const targetAngle = Math.atan2(facingX, facingZ);
      const angleDelta = Math.atan2(
        Math.sin(targetAngle - group.current.rotation.y),
        Math.cos(targetAngle - group.current.rotation.y),
      );
      group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
    }
    group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);
  });

  return (
    <group ref={group} rotation={[0, Math.PI, 0]}>
      <PartyCharacterModel memberId={memberId} visualMemberId={visualMemberId} animationState={animationState} animationTrigger={animationTrigger} />
      {beaconed && <BeaconOfLightMarker />}
      {selected && (
        <mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
          <ringGeometry args={[0.55, 0.66, 32]} />
          <meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
        </mesh>
      )}
    </group>
  );
}

function PlayerCharacter({ appearance }: { appearance?: CharacterAppearanceV1 }) {
  const group = useRef<THREE.Group>(null);
  const animationState = useRef<ActorAnimationState>("idle");
  const animationTrigger = useRef(0);
  const keys = useRef(new Set<string>());
  const scenePulse = useGameStore((state) => state.scenePulse);
  const healerClassId = useGameStore((state) => state.healerClassId);
  const selected = useGameStore((state) => state.selectedMemberId === "aelia");
  const beaconed = useGameStore((state) => isBeaconOfLightTarget("aelia", state.healerMechanic, state.time));
  const rpgEncounterKey = useGameStore((state) => {
    const phase = state.rpgRun?.phase;
    if (state.runMode !== "rpg-roguelike") return null;
    const bossIndex = state.rpgRun?.bossIndex ?? 0;
    if (phase === "challenge-active") return bossIndex * 2 + 1;
    if (phase === "boss-combat") return bossIndex * 2 + 2;
    return null;
  });
  const setPlayerPosition = useGameStore((state) => state.setPlayerPosition);
  const setHockeyAimDirection = useGameStore((state) => state.setHockeyAimDirection);
  const { camera } = useThree();
  const broadcastTimer = useRef(0);
  const castingUntil = useRef(0);
  const instantCastTrigger = useRef(0);
  const desiredCameraPosition = useMemo(() => new THREE.Vector3(), []);
  const cameraOrbit = useRef<CameraOrbitState>({ yaw: DEFAULT_CAMERA_YAW, pitch: DEFAULT_CAMERA_PITCH });
  const cameraRelativeMovement = useRef<PlanarMovement>({ x: 0, z: 0 });
  const hockeyAimMovement = useRef<PlanarMovement>({ x: 0, z: -1 });

  useLayoutEffect(() => {
    const start = useGameStore.getState().partyPositions.aelia;
    group.current?.position.set(start[0], 0.025, start[1]);
    const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE;
    const sinYaw = Math.sin(cameraOrbit.current.yaw);
    const cosYaw = Math.cos(cameraOrbit.current.yaw);
    desiredCameraPosition.set(
      start[0] + sinYaw * horizontalDistance,
      CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE,
      start[1] + cosYaw * horizontalDistance,
    );
    camera.position.copy(desiredCameraPosition);
    camera.lookAt(
      start[0] - sinYaw * CAMERA_LOOK_AHEAD,
      CAMERA_FOCUS_HEIGHT,
      start[1] - cosYaw * CAMERA_LOOK_AHEAD,
    );
    keys.current.clear();
    broadcastTimer.current = 0;
  }, [camera, desiredCameraPosition, rpgEncounterKey]);

  useEffect(() => {
    if (["periodic-heal", "protective", "cleanse", "group-heal", "field"].includes(scenePulse.kind)) {
      castingUntil.current = performance.now() + 700;
      instantCastTrigger.current = scenePulse.id;
    }
  }, [scenePulse]);

  useEffect(() => {
    const down = (event: KeyboardEvent) => {
      const key = event.key.toLowerCase();
      keys.current.add(key);
      if (event.repeat || !group.current) return;
      const state = useGameStore.getState();
      if (state.phase !== "combat" || state.paused || state.activeCast || state.party[0].hp <= 0 || state.party[0].knockedUntil > state.time) return;
      const nudgeX = Number(key === "d") - Number(key === "a");
      const nudgeZ = Number(key === "s") - Number(key === "w");
      if (!nudgeX && !nudgeZ) return;
      setCameraRelativeMovement(cameraRelativeMovement.current, nudgeX, nudgeZ, cameraOrbit.current.yaw);
      const aetherAssaultMode = state.activityMode === "aether-assault";
      const hockeyMode = state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker";
      const requestedPosition: [number, number] = [
        group.current.position.x + cameraRelativeMovement.current.x * 0.18,
        group.current.position.z + cameraRelativeMovement.current.z * 0.18,
      ];
      const next = state.runMode === "rpg-roguelike" && state.activityMode === "boss"
        ? clampToBossArenaWithPortals(requestedPosition, { north: state.rpgRun?.phase === "boss-cleared", south: false })
        : aetherAssaultMode
          ? clampToHockeyArena(requestedPosition, 0.65)
          : hockeyMode
            ? clampToHockeyHealerHalf(requestedPosition, 0.65)
            : clampToArena(requestedPosition);
      group.current.position.x = next[0];
      group.current.position.z = next[1];
      setPlayerPosition([group.current.position.x, group.current.position.z]);
    };
    const up = (event: KeyboardEvent) => keys.current.delete(event.key.toLowerCase());
    window.addEventListener("keydown", down);
    window.addEventListener("keyup", up);
    return () => {
      window.removeEventListener("keydown", down);
      window.removeEventListener("keyup", up);
    };
  }, [setPlayerPosition]);

  useFrame((_, delta) => {
    if (!group.current) return;
    let inputX = 0;
    let inputZ = 0;
    const state = useGameStore.getState();
    const knocked = state.party[0].knockedUntil > state.time;
    const player = state.party[0];
    const controller = getControllerMovement();
    const rawInputX = Number(keys.current.has("d")) - Number(keys.current.has("a")) + controller.moveX;
    const rawInputZ = Number(keys.current.has("s")) - Number(keys.current.has("w")) + controller.moveY;
    if ((state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker") && state.phase === "combat" && !state.paused) {
      setCameraRelativeMovement(hockeyAimMovement.current, rawInputX, rawInputZ, cameraOrbit.current.yaw);
      setHockeyAimDirection([hockeyAimMovement.current.x, hockeyAimMovement.current.z]);
    }
    if (state.phase === "combat" && !state.paused && !state.activeCast && player.hp > 0 && !knocked) {
      inputX = rawInputX;
      inputZ = rawInputZ;
    }
    if (state.activityMode === "aether-assault") {
      cameraOrbit.current.yaw = DEFAULT_CAMERA_YAW;
      cameraOrbit.current.pitch = DEFAULT_CAMERA_PITCH;
    } else if (state.phase === "combat" && !state.paused) {
      updateCameraOrbit(cameraOrbit.current, controller.lookX, controller.lookY, delta);
    }
    setCameraRelativeMovement(cameraRelativeMovement.current, inputX, inputZ, cameraOrbit.current.yaw);
    inputX = cameraRelativeMovement.current.x;
    inputZ = cameraRelativeMovement.current.z;
    const length = Math.hypot(inputX, inputZ);
    if (length > 0.05) {
      const speed = 4.6 * state.gearModifiers.aelia.moveSpeed * delta / Math.max(1, length);
      const requestedPosition: [number, number] = [group.current.position.x + inputX * speed, group.current.position.z + inputZ * speed];
      const next = state.runMode === "rpg-roguelike" && state.activityMode === "boss"
        ? clampToBossArenaWithPortals(requestedPosition, { north: state.rpgRun?.phase === "boss-cleared", south: false })
        : state.activityMode === "aether-assault"
          ? clampToHockeyArena(requestedPosition, 0.65)
          : state.activityMode === "hockey-healing" || state.activityMode === "hockey-healing-pvp" || state.activityMode === "blockbreaker"
            ? clampToHockeyHealerHalf(requestedPosition, 0.65)
            : clampToArena(requestedPosition);
      group.current.position.x = next[0];
      group.current.position.z = next[1];
      group.current.rotation.y = Math.atan2(inputX, inputZ);
    } else if (state.phase === "combat" && player.hp > 0 && !knocked) {
      const boss = targetBossMotion(state).position;
      const targetAngle = Math.atan2(boss[0] - group.current.position.x, boss[1] - group.current.position.z);
      const angleDelta = Math.atan2(
        Math.sin(targetAngle - group.current.rotation.y),
        Math.cos(targetAngle - group.current.rotation.y),
      );
      group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
    }
    const instantCasting = performance.now() < castingUntil.current;
    animationTrigger.current = player.hp <= 0
      ? 0
      : knocked
        ? player.knockedUntil
        : state.activeCast
          ? state.activeCast.startedAt
          : instantCasting
            ? instantCastTrigger.current
            : 0;
    animationState.current = player.hp <= 0
      ? "death"
      : knocked
        ? "hit"
        : state.activeCast
          ? "cast"
          : length > 0.05
            ? "run"
            : instantCasting
            ? "cast"
            : "idle";
    group.current.position.y = THREE.MathUtils.lerp(group.current.position.y, 0.025, 0.16);

    const horizontalDistance = Math.cos(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE;
    const sinYaw = Math.sin(cameraOrbit.current.yaw);
    const cosYaw = Math.cos(cameraOrbit.current.yaw);
    const pvpOffsetZ = state.activityMode === "hockey-healing-pvp" ? HOCKEY_PVP_SIDE_OFFSET_Z : 0;
    desiredCameraPosition.set(
      group.current.position.x + sinYaw * horizontalDistance,
      CAMERA_FOCUS_HEIGHT + Math.sin(cameraOrbit.current.pitch) * CAMERA_ORBIT_DISTANCE,
      group.current.position.z + pvpOffsetZ + cosYaw * horizontalDistance,
    );
    camera.position.lerp(desiredCameraPosition, 1 - Math.pow(0.002, delta));
    camera.lookAt(
      group.current.position.x - sinYaw * CAMERA_LOOK_AHEAD,
      CAMERA_FOCUS_HEIGHT,
      group.current.position.z + pvpOffsetZ - cosYaw * CAMERA_LOOK_AHEAD,
    );

    broadcastTimer.current += delta;
    const activeRunMode = useGameStore.getState().runMode;
    const positionSyncInterval = activeRunMode === "hockey-healing"
      || activeRunMode === "hockey-healing-pvp"
      || activeRunMode === "blockbreaker"
      || activeRunMode === "aether-assault"
      ? 0.08
      : 0.15;
    if (broadcastTimer.current > positionSyncInterval) {
      setPlayerPosition([group.current.position.x, group.current.position.z]);
      broadcastTimer.current = 0;
    }
  });

  return (
    <group ref={group} rotation={[0, Math.PI, 0]}>
      <PartyCharacterModel
        memberId="aelia"
        healerClassId={healerClassId}
        appearanceOverride={appearance}
        animationState={animationState}
        animationTrigger={animationTrigger}
      />
      {beaconed && <BeaconOfLightMarker />}
      {selected && (
        <mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
          <ringGeometry args={[0.55, 0.66, 32]} />
          <meshBasicMaterial color="#f6d477" transparent opacity={0.95} />
        </mesh>
      )}
    </group>
  );
}

function HockeyHealingPlayfield() {
  const puck = useRef<THREE.Group>(null);
  const puckGlow = useRef<THREE.MeshBasicMaterial>(null);
  const npcPaddle = useRef<THREE.Group>(null);
  const npcPaddleMaterial = useRef<THREE.MeshStandardMaterial>(null);
  const arrow = useRef<THREE.Group>(null);
  const arrowMaterial = useRef<THREE.MeshBasicMaterial>(null);

  useFrame(({ clock }, delta) => {
    const state = useGameStore.getState();
    const hockeyMode = state.activityMode === "hockey-healing";
    if (puck.current) {
      puck.current.visible = hockeyMode;
      if (hockeyMode) {
        puck.current.position.set(state.hockey.puckPosition[0], 0.48, state.hockey.puckPosition[1]);
        puck.current.rotation.y += 0.07;
        const pulse = 1 + Math.sin(clock.elapsedTime * 7) * 0.08;
        puck.current.scale.setScalar(pulse);
        if (puckGlow.current) puckGlow.current.opacity = 0.6 + Math.sin(clock.elapsedTime * 7) * 0.18;
      }
    }
    if (npcPaddle.current) {
      npcPaddle.current.visible = hockeyMode;
      if (hockeyMode) {
        npcPaddle.current.position.x = THREE.MathUtils.damp(npcPaddle.current.position.x, state.hockey.paddleX, 18, delta);
        const hitPulse = Math.max(0, 1 - (state.time - state.hockey.paddleHitAt) / 0.22);
        npcPaddle.current.scale.set(1 + hitPulse * 0.04, 1 + hitPulse * 0.16, 1);
        if (npcPaddleMaterial.current) npcPaddleMaterial.current.emissiveIntensity = 1.8 + hitPulse * 3.4;
      }
    }
    if (!arrow.current) return;
    const arrowVisible = hockeyMode
      && state.phase === "combat"
      && hockeyAimPreviewVisible(state.hockey, state.partyPositions.aelia);
    arrow.current.visible = arrowVisible;
    if (!arrowVisible) return;
    const direction = hockeyReturnDirection(state.hockey.aimDirection);
    arrow.current.position.set(state.hockey.puckPosition[0], 0.09, state.hockey.puckPosition[1]);
    arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]);
    if (arrowMaterial.current) arrowMaterial.current.opacity = 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2;
  });

  return (
    <>
      <group ref={puck} visible={false}>
        <mesh castShadow>
          <sphereGeometry args={[HOCKEY_PUCK_RADIUS, 16, 12]} />
          <meshStandardMaterial color="#eafcff" emissive="#42d9ff" emissiveIntensity={2.4} roughness={0.18} metalness={0.28} />
        </mesh>
        <mesh scale={1.75}>
          <sphereGeometry args={[HOCKEY_PUCK_RADIUS, 12, 8]} />
          <meshBasicMaterial ref={puckGlow} color="#67e8ff" transparent opacity={0.68} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
        </mesh>
        <pointLight color="#5ce4ff" intensity={2.2} distance={4.2} />
      </group>
      <group ref={npcPaddle} visible={false} position={[0, 0.68, HOCKEY_NPC_PADDLE_Z]}>
        <mesh castShadow receiveShadow>
          <boxGeometry args={[HOCKEY_NPC_PADDLE_WIDTH, 1.12, 0.56]} />
          <meshStandardMaterial ref={npcPaddleMaterial} color="#ff8a5b" emissive="#ff4f38" emissiveIntensity={1.8} roughness={0.24} metalness={0.5} />
        </mesh>
        <mesh position={[0, 0, 0.3]}>
          <boxGeometry args={[HOCKEY_NPC_PADDLE_WIDTH - 0.26, 0.74, 0.04]} />
          <meshBasicMaterial color="#ffe0a8" transparent opacity={0.34} depthWrite={false} toneMapped={false} />
        </mesh>
        <pointLight color="#ff7659" intensity={2.4} distance={5.5} position={[0, 0.2, 0.45]} />
      </group>
      <group ref={arrow} visible={false}>
        <mesh position={[0, 0, -1.05]}>
          <boxGeometry args={[0.16, 0.045, 1.75]} />
          <meshBasicMaterial ref={arrowMaterial} color="#ffe478" transparent opacity={0.8} depthWrite={false} toneMapped={false} />
        </mesh>
        <mesh position={[0, 0, -2.05]} rotation={[-Math.PI / 2, 0, 0]}>
          <coneGeometry args={[0.38, 0.72, 12]} />
          <meshBasicMaterial color="#fff0a3" transparent opacity={0.88} depthWrite={false} toneMapped={false} />
        </mesh>
      </group>
    </>
  );
}

const BLOCKBREAKER_COLOR_KEYS: readonly BlockbreakerBrickColor[] = ["cyan", "amber", "magenta", "lime"];

function BlockbreakerPlayfield() {
  const biome = useGameStore((state) => blockbreakerBiomeForSeed(state.blockbreaker.seed));
  const root = useRef<THREE.Group>(null);
  const bricks = useRef<Record<BlockbreakerBrickColor, THREE.InstancedMesh | null>>({
    cyan: null,
    amber: null,
    magenta: null,
    lime: null,
  });
  const marksA = useRef<THREE.InstancedMesh>(null);
  const marksB = useRef<THREE.InstancedMesh>(null);
  const puck = useRef<THREE.Group>(null);
  const puckGlow = useRef<THREE.MeshBasicMaterial>(null);
  const arrow = useRef<THREE.Group>(null);
  const arrowMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const breakFx = useRef<THREE.Group>(null);
  const breakFxMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const transform = useMemo(() => new THREE.Object3D(), []);
  const layout = useRef({ active: false, seed: 0, rows: -1, broken: -1 });

  useFrame(({ clock }, delta) => {
    const state = useGameStore.getState();
    const active = state.activityMode === "blockbreaker";
    if (root.current) root.current.visible = active;
    if (!active) {
      layout.current.active = false;
      return;
    }

    const blockbreaker = state.blockbreaker;
    const reducedMotion = document.documentElement.classList.contains("force-reduced-motion");
    if (puck.current) {
      puck.current.visible = blockbreaker.status === "live";
      puck.current.position.set(blockbreaker.puckPosition[0], 0.48, blockbreaker.puckPosition[1]);
      if (!reducedMotion) puck.current.rotation.y += delta * 5.5;
      puck.current.scale.setScalar(reducedMotion ? 1 : 1 + Math.sin(clock.elapsedTime * 7) * 0.08);
      if (puckGlow.current) puckGlow.current.opacity = reducedMotion ? 0.58 : 0.58 + Math.sin(clock.elapsedTime * 7) * 0.18;
    }

    const needsLayout = !layout.current.active
      || layout.current.seed !== blockbreaker.seed
      || layout.current.rows !== blockbreaker.rowsSpawned
      || layout.current.broken !== blockbreaker.bricksBroken;
    const brickMeshesReady = bricks.current.cyan
      && bricks.current.amber
      && bricks.current.magenta
      && bricks.current.lime;
    if (needsLayout && brickMeshesReady && marksA.current && marksB.current) {
      const count = Math.min(BLOCKBREAKER_MAX_BRICKS, blockbreaker.bricks.length);
      const colorCounts: Record<BlockbreakerBrickColor, number> = { cyan: 0, amber: 0, magenta: 0, lime: 0 };
      marksA.current.count = count;
      marksB.current.count = count;
      for (let index = 0; index < count; index += 1) {
        const brick = blockbreaker.bricks[index];
        const x = blockbreakerColumnX(brick.column);
        const z = blockbreakerRowZ(brick.row);
        const colorIndex = colorCounts[brick.color];
        transform.position.set(x, 0.72, z);
        transform.rotation.set(0, 0, 0);
        transform.scale.set(BLOCKBREAKER_BRICK_WIDTH, 1.24, BLOCKBREAKER_BRICK_DEPTH);
        transform.updateMatrix();
        bricks.current[brick.color]?.setMatrixAt(colorIndex, transform.matrix);
        colorCounts[brick.color] = colorIndex + 1;

        transform.position.set(x, 0.72, z + BLOCKBREAKER_BRICK_DEPTH * 0.535);
        transform.rotation.set(0, 0, brick.color === "amber" ? Math.PI / 4 : 0);
        if (brick.color === "cyan") transform.scale.set(0.14, 0.38, 0.035);
        else if (brick.color === "amber") transform.scale.set(0.27, 0.27, 0.035);
        else if (brick.color === "magenta") transform.scale.set(0.35, 0.09, 0.035);
        else transform.scale.set(0.34, 0.075, 0.035);
        if (brick.color === "lime") transform.position.y += 0.16;
        transform.updateMatrix();
        marksA.current.setMatrixAt(index, transform.matrix);

        transform.position.set(x, 0.72, z + BLOCKBREAKER_BRICK_DEPTH * 0.54);
        transform.rotation.set(0, 0, 0);
        if (brick.color === "magenta") transform.scale.set(0.09, 0.35, 0.035);
        else if (brick.color === "lime") {
          transform.position.y -= 0.16;
          transform.scale.set(0.34, 0.075, 0.035);
        } else transform.scale.setScalar(0.0001);
        transform.updateMatrix();
        marksB.current.setMatrixAt(index, transform.matrix);
      }
      for (const color of BLOCKBREAKER_COLOR_KEYS) {
        const mesh = bricks.current[color];
        if (!mesh) continue;
        mesh.count = colorCounts[color];
        mesh.instanceMatrix.needsUpdate = true;
        mesh.computeBoundingSphere();
      }
      marksA.current.instanceMatrix.needsUpdate = true;
      marksB.current.instanceMatrix.needsUpdate = true;
      marksA.current.computeBoundingSphere();
      marksB.current.computeBoundingSphere();
      layout.current = {
        active: true,
        seed: blockbreaker.seed,
        rows: blockbreaker.rowsSpawned,
        broken: blockbreaker.bricksBroken,
      };
    }

    if (arrow.current) {
      const visible = state.phase === "combat" && blockbreakerAimPreviewVisible(blockbreaker, state.partyPositions.aelia);
      arrow.current.visible = visible;
      if (visible) {
        const direction = hockeyReturnDirection(blockbreaker.aimDirection);
        arrow.current.position.set(blockbreaker.puckPosition[0], 0.09, blockbreaker.puckPosition[1]);
        arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]);
        if (arrowMaterial.current) arrowMaterial.current.opacity = reducedMotion ? 0.72 : 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2;
      }
    }

    if (breakFx.current) {
      const age = state.time - blockbreaker.lastBreakAt;
      const visible = age >= 0 && age < 0.42;
      breakFx.current.visible = visible;
      if (visible) {
        const progress = age / 0.42;
        breakFx.current.position.set(blockbreaker.puckPosition[0], 0.2, blockbreaker.puckPosition[1]);
        breakFx.current.scale.setScalar(reducedMotion ? 1.4 : 0.6 + progress * 4.2);
        if (breakFxMaterial.current) breakFxMaterial.current.opacity = reducedMotion ? 0.5 : (1 - progress) * 0.9;
      }
    }
  });

  return (
    <group ref={root} visible={false}>
      {BLOCKBREAKER_COLOR_KEYS.map((color) => (
        <instancedMesh
          key={color}
          ref={(mesh) => { bricks.current[color] = mesh; }}
          args={[undefined, undefined, BLOCKBREAKER_MAX_BRICKS]}
          castShadow
          receiveShadow
          frustumCulled={false}
        >
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial
            color={biome.bricks[color]}
            emissive={biome.bricks[color]}
            emissiveIntensity={0.55}
            roughness={0.44}
            metalness={0.03}
            toneMapped={false}
          />
        </instancedMesh>
      ))}
      <instancedMesh ref={marksA} args={[undefined, undefined, BLOCKBREAKER_MAX_BRICKS]} frustumCulled={false}>
        <boxGeometry args={[1, 1, 1]} />
        <meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
      </instancedMesh>
      <instancedMesh ref={marksB} args={[undefined, undefined, BLOCKBREAKER_MAX_BRICKS]} frustumCulled={false}>
        <boxGeometry args={[1, 1, 1]} />
        <meshBasicMaterial color={biome.midline} transparent opacity={0.86} toneMapped={false} />
      </instancedMesh>
      <mesh position={[0, 0.025, BLOCKBREAKER_DANGER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
        <planeGeometry args={[19.5, 0.18]} />
        <meshBasicMaterial color="#ff496f" transparent opacity={0.9} toneMapped={false} />
      </mesh>
      <mesh position={[0, 0.03, BLOCKBREAKER_DANGER_Z]} rotation={[-Math.PI / 2, 0, 0]}>
        <planeGeometry args={[19.5, 0.8]} />
        <meshBasicMaterial color="#ff315e" transparent opacity={0.12} depthWrite={false} toneMapped={false} />
      </mesh>
      <group ref={puck}>
        <mesh castShadow>
          <sphereGeometry args={[BLOCKBREAKER_PUCK_RADIUS, 16, 12]} />
          <meshStandardMaterial color="#f4fbff" emissive={biome.fillLightA} emissiveIntensity={2.5} roughness={0.16} metalness={0.3} />
        </mesh>
        <mesh scale={1.75}>
          <sphereGeometry args={[BLOCKBREAKER_PUCK_RADIUS, 12, 8]} />
          <meshBasicMaterial ref={puckGlow} color={biome.boundary} transparent opacity={0.65} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
        </mesh>
        <pointLight color={biome.fillLightA} intensity={2.2} distance={4.2} />
      </group>
      <group ref={arrow} visible={false}>
        <mesh position={[0, 0, -1.05]}>
          <boxGeometry args={[0.16, 0.045, 1.75]} />
          <meshBasicMaterial ref={arrowMaterial} color={biome.railB} transparent opacity={0.8} depthWrite={false} toneMapped={false} />
        </mesh>
        <mesh position={[0, 0, -2.05]} rotation={[-Math.PI / 2, 0, 0]}>
          <coneGeometry args={[0.38, 0.72, 12]} />
          <meshBasicMaterial color={biome.midline} transparent opacity={0.88} depthWrite={false} toneMapped={false} />
        </mesh>
      </group>
      <group ref={breakFx} visible={false} rotation={[-Math.PI / 2, 0, 0]}>
        <mesh>
          <ringGeometry args={[0.35, 0.5, 28]} />
          <meshBasicMaterial ref={breakFxMaterial} color={biome.railB} transparent opacity={0.9} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
        </mesh>
      </group>
    </group>
  );
}

function AetherAssaultPlayfield() {
  const root = useRef<THREE.Group>(null);
  const hulls = useRef<THREE.InstancedMesh>(null);
  const wings = useRef<THREE.InstancedMesh>(null);
  const trails = useRef<THREE.InstancedMesh>(null);
  const warnings = useRef<THREE.InstancedMesh>(null);
  const playerShots = useRef<THREE.InstancedMesh>(null);
  const enemyShots = useRef<THREE.InstancedMesh>(null);
  const hullMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const wingMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const hitRing = useRef<THREE.Mesh>(null);
  const hitMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const transform = useMemo(() => new THREE.Object3D(), []);
  const standardColors = useMemo(() => AETHER_STANDARD_SHIP_COLORS.map((color) => new THREE.Color(color)), []);

  useFrame(({ clock }) => {
    const state = useGameStore.getState();
    const active = state.activityMode === "aether-assault";
    if (root.current) root.current.visible = active;
    if (!active || !hulls.current || !wings.current || !trails.current || !warnings.current || !playerShots.current || !enemyShots.current) return;

    const reducedMotion = document.documentElement.classList.contains("force-reduced-motion");
    const aether = state.aetherAssault;
    const shipCount = Math.min(AETHER_MAX_SHIPS, aether.ships.length);
    const formationColor = standardColors[aetherShipColorIndex(aether.seed, aether.wave, 0)];
    hullMaterial.current?.color.copy(formationColor);
    wingMaterial.current?.color.copy(formationColor);
    trailMaterial.current?.color.copy(formationColor);
    let trailCount = 0;
    let warningCount = 0;
    hulls.current.count = shipCount;
    wings.current.count = shipCount;
    for (let index = 0; index < shipCount; index += 1) {
      const ship = aether.ships[index];
      const armored = ship.kind === "armored";
      const hover = reducedMotion ? 0 : Math.sin(clock.elapsedTime * 3.2 + index * 0.7) * 0.1;
      const diveTilt = ship.phase === "diving" ? Math.sin(clock.elapsedTime * 5 + index) * 0.38 : 0;
      transform.position.set(ship.position[0], 2.25 + hover, ship.position[1]);
      transform.rotation.set(diveTilt, Math.PI, diveTilt * 0.45);
      transform.scale.setScalar(armored ? 0.82 : 0.66);
      transform.updateMatrix();
      hulls.current.setMatrixAt(index, transform.matrix);

      transform.position.set(ship.position[0], 2.12 + hover, ship.position[1] + 0.08);
      transform.rotation.set(diveTilt, Math.PI, diveTilt * 0.45);
      transform.scale.set(armored ? 1.38 : 1.12, armored ? 0.12 : 0.09, armored ? 0.76 : 0.62);
      transform.updateMatrix();
      wings.current.setMatrixAt(index, transform.matrix);

      if (ship.phase === "entering" || ship.phase === "diving" || ship.phase === "returning") {
        transform.position.set(ship.position[0], 2.18 + hover, ship.position[1] + 0.85);
        transform.rotation.set(Math.PI / 2, 0, 0);
        transform.scale.set(0.11, 0.11, ship.phase === "diving" ? 1.5 : 0.92);
        transform.updateMatrix();
        trails.current.setMatrixAt(trailCount, transform.matrix);
        trailCount += 1;
      }
      if (ship.phase === "diving") {
        transform.position.set(ship.targetPosition[0], 0.045, HOCKEY_ARENA_MAX_Z - 0.9);
        transform.rotation.set(-Math.PI / 2, 0, 0);
        transform.scale.setScalar(reducedMotion ? 1 : 0.82 + Math.sin(clock.elapsedTime * 8) * 0.14);
        transform.updateMatrix();
        warnings.current.setMatrixAt(warningCount, transform.matrix);
        warningCount += 1;
      }
    }
    hulls.current.instanceMatrix.needsUpdate = true;
    wings.current.instanceMatrix.needsUpdate = true;
    trails.current.count = trailCount;
    warnings.current.count = warningCount;
    trails.current.instanceMatrix.needsUpdate = true;
    warnings.current.instanceMatrix.needsUpdate = true;

    const playerShotCount = Math.min(AETHER_MAX_PLAYER_SHOTS, aether.playerShots.length);
    playerShots.current.count = playerShotCount;
    for (let index = 0; index < playerShotCount; index += 1) {
      const shot = aether.playerShots[index];
      transform.position.set(shot.position[0], 1.15, shot.position[1]);
      transform.rotation.set(Math.PI / 2, 0, 0);
      transform.scale.set(0.14, 0.14, 0.58);
      transform.updateMatrix();
      playerShots.current.setMatrixAt(index, transform.matrix);
    }
    playerShots.current.instanceMatrix.needsUpdate = true;

    const enemyShotCount = Math.min(AETHER_MAX_ENEMY_SHOTS, aether.enemyShots.length);
    enemyShots.current.count = enemyShotCount;
    for (let index = 0; index < enemyShotCount; index += 1) {
      const shot = aether.enemyShots[index];
      transform.position.set(shot.position[0], 0.82, shot.position[1]);
      transform.rotation.set(0, 0, 0);
      transform.scale.setScalar(0.24);
      transform.updateMatrix();
      enemyShots.current.setMatrixAt(index, transform.matrix);
    }
    enemyShots.current.instanceMatrix.needsUpdate = true;

    if (hitRing.current) {
      const age = state.time - aether.lastPlayerHitAt;
      const visible = age >= 0 && age < 0.6;
      hitRing.current.visible = visible;
      if (visible) {
        const progress = age / 0.6;
        const player = state.partyPositions.aelia;
        hitRing.current.position.set(player[0], 0.08, player[1]);
        hitRing.current.scale.setScalar(0.7 + progress * 2.8);
        if (hitMaterial.current) hitMaterial.current.opacity = reducedMotion ? 0.68 : (1 - progress) * 0.9;
      }
    }
  });

  return (
    <group ref={root} visible={false}>
      <instancedMesh ref={hulls} args={[undefined, undefined, AETHER_MAX_SHIPS]} castShadow frustumCulled={false}>
        <octahedronGeometry args={[1, 0]} />
        <meshBasicMaterial ref={hullMaterial} color={AETHER_STANDARD_SHIP_COLORS[0]} toneMapped={false} />
      </instancedMesh>
      <instancedMesh ref={wings} args={[undefined, undefined, AETHER_MAX_SHIPS]} castShadow frustumCulled={false}>
        <boxGeometry args={[1, 1, 1]} />
        <meshBasicMaterial ref={wingMaterial} color={AETHER_STANDARD_SHIP_COLORS[0]} toneMapped={false} />
      </instancedMesh>
      <instancedMesh ref={trails} args={[undefined, undefined, AETHER_MAX_SHIPS]} frustumCulled={false}>
        <coneGeometry args={[1, 1, 8]} />
        <meshBasicMaterial ref={trailMaterial} color={AETHER_STANDARD_SHIP_COLORS[0]} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </instancedMesh>
      <instancedMesh ref={warnings} args={[undefined, undefined, AETHER_MAX_SHIPS]} frustumCulled={false}>
        <ringGeometry args={[0.75, 1, 28]} />
        <meshBasicMaterial color="#ffb85c" transparent opacity={0.78} depthWrite={false} toneMapped={false} />
      </instancedMesh>
      <instancedMesh ref={playerShots} args={[undefined, undefined, AETHER_MAX_PLAYER_SHOTS]} frustumCulled={false}>
        <capsuleGeometry args={[1, 2, 3, 6]} />
        <meshBasicMaterial color="#d9ffff" transparent opacity={0.92} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </instancedMesh>
      <instancedMesh ref={enemyShots} args={[undefined, undefined, AETHER_MAX_ENEMY_SHOTS]} frustumCulled={false}>
        <octahedronGeometry args={[1, 0]} />
        <meshBasicMaterial color="#ff1f14" depthWrite={false} toneMapped={false} />
      </instancedMesh>
      <mesh ref={hitRing} visible={false} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[0.42, 0.62, 32]} />
        <meshBasicMaterial ref={hitMaterial} color="#ff4778" transparent opacity={0.9} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
    </group>
  );
}

function Party({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) {
  const selected = useGameStore((state) => state.selectedMemberId);
  const [loadSupportModels, setLoadSupportModels] = useState(false);

  useEffect(() => {
    const timer = window.setTimeout(() => setLoadSupportModels(true), 750);
    return () => window.clearTimeout(timer);
  }, []);

  return (
    <>
      <Suspense fallback={<PartyFallback memberIds={CRITICAL_PARTY_MEMBER_IDS} />}>
        <PlayerCharacter appearance={playerAppearance} />
        <Character memberId="brann" selected={selected === "brann"} />
      </Suspense>
      {loadSupportModels ? (
        <Suspense fallback={<PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}>
          {SUPPORT_PARTY_MEMBER_IDS.map((memberId) => (
            <Character key={memberId} memberId={memberId} selected={selected === memberId} />
          ))}
        </Suspense>
      ) : <PartyFallback memberIds={SUPPORT_PARTY_MEMBER_IDS} />}
    </>
  );
}

function PartyFallback({ memberIds }: { memberIds: readonly MemberId[] }) {
  const positions = useGameStore((state) => state.partyPositions);
  return (
    <>
      {memberIds.map((memberId) => (
        <mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
          <capsuleGeometry args={[0.3, 0.75, 4, 8]} />
          <meshStandardMaterial color="#79998c" roughness={0.8} />
        </mesh>
      ))}
    </>
  );
}

function OpponentCharacter({ memberId }: { memberId: MemberId }) {
  const group = useRef<THREE.Group>(null);
  const animationState = useRef<ActorAnimationState>("idle");
  const animationTrigger = useRef(0);

  useEffect(() => {
    const start = useGameStore.getState().hockeyPvpOpponent.partyPositions[memberId];
    group.current?.position.set(start[0], 0.025, start[1]);
  }, [memberId]);

  useFrame((_, delta) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const opponent = state.hockeyPvpOpponent;
    const target = opponent.partyPositions[memberId];
    const dx = target[0] - group.current.position.x;
    const dz = target[1] - group.current.position.z;
    const moving = Math.hypot(dx, dz) > 0.015;
    const blend = 1 - Math.pow(0.002, delta);
    group.current.position.x = THREE.MathUtils.lerp(group.current.position.x, target[0], blend);
    group.current.position.z = THREE.MathUtils.lerp(group.current.position.z, target[1], blend);

    const member = opponent.party.find((entry) => entry.id === memberId);
    const actor = memberId === "aelia" ? null : opponent.partyCombat.combatants[memberId];
    const knocked = Boolean(member && member.knockedUntil > state.time);
    const attacking = Boolean(actor?.visualAction && actor.visualAction.endsAt > state.time);
    animationTrigger.current = !member || member.hp <= 0
      ? 0
      : knocked
        ? member.knockedUntil
        : attacking
          ? actor?.visualAction?.startedAt ?? 0
          : 0;
    animationState.current = !member || member.hp <= 0
      ? "death"
      : knocked
        ? "hit"
        : attacking
          ? "attack"
          : moving
            ? "walk"
            : "idle";

    if (member && member.hp > 0 && !knocked) {
      const boss = opponent.bossMotion.position;
      const facingX = boss[0] - group.current.position.x;
      const facingZ = boss[1] - group.current.position.z;
      const targetAngle = Math.atan2(facingX, facingZ);
      const angleDelta = Math.atan2(
        Math.sin(targetAngle - group.current.rotation.y),
        Math.cos(targetAngle - group.current.rotation.y),
      );
      group.current.rotation.y += angleDelta * (1 - Math.pow(0.0008, delta));
    }
  });

  return (
    <group ref={group} rotation={[0, Math.PI, 0]}>
      <PartyCharacterModel memberId={memberId} animationState={animationState} animationTrigger={animationTrigger} />
      <mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[0.5, 0.57, 24]} />
        <meshBasicMaterial color="#ff685c" transparent opacity={0.72} />
      </mesh>
    </group>
  );
}

function OpponentPartyFallback() {
  const positions = useGameStore((state) => state.hockeyPvpOpponent.partyPositions);
  return (
    <>
      {(Object.keys(positions) as MemberId[]).map((memberId) => (
        <mesh key={memberId} castShadow position={[positions[memberId][0], 0.8, positions[memberId][1]]}>
          <capsuleGeometry args={[0.3, 0.75, 4, 8]} />
          <meshStandardMaterial color="#a45d57" roughness={0.8} />
        </mesh>
      ))}
    </>
  );
}

function OpponentParty() {
  return (
    <Suspense fallback={<OpponentPartyFallback />}>
      {(["aelia", "brann", "nia", "orin", "vale"] as MemberId[]).map((memberId) => (
        <OpponentCharacter key={memberId} memberId={memberId} />
      ))}
    </Suspense>
  );
}

function BossFallback({ bossIndex, opponent = false }: { bossIndex: number; opponent?: boolean }) {
  const boss = useGameStore((state) => opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss);
  const motion = useGameStore((state) => opponent ? state.hockeyPvpOpponent.bossMotion : bossIndex === 0 ? state.bossMotion : state.additionalBosses[bossIndex - 1]?.motion);
  const group = useRef<THREE.Group>(null);
  const material = useRef<THREE.MeshStandardMaterial>(null);
  const deathElapsed = useRef(0);
  useFrame((_, delta) => {
    const defeated = (boss?.hp ?? 1) <= 0;
    deathElapsed.current = defeated ? deathElapsed.current + delta : 0;
    const opacity = bossDeathOpacity(deathElapsed.current, boss?.id);
    if (group.current) group.current.visible = opacity > 0;
    if (material.current) {
      const transparent = opacity < 0.999;
      if (material.current.transparent !== transparent) {
        material.current.transparent = transparent;
        material.current.needsUpdate = true;
      }
      material.current.opacity = opacity;
      material.current.depthWrite = opacity >= 0.999;
    }
  });
  if (!boss || !motion) return null;
  const position = motion.position;
  const bossId = boss.id;
  return (
    <group ref={group} position={[position[0], 1.1, position[1]]}>
      <mesh castShadow>
        <dodecahedronGeometry args={[1.1, 0]} />
        <meshStandardMaterial ref={material} color={BOSS_ARCHETYPE_BY_ID[bossId] === "web-caster" ? "#56306f" : BOSS_ARCHETYPE_BY_ID[bossId] === "sky-sweeper" ? "#9d4c24" : BOSS_ARCHETYPE_BY_ID[bossId] === "burrower" ? "#b78b32" : BOSS_ARCHETYPE_BY_ID[bossId] === "duelist" || BOSS_ARCHETYPE_BY_ID[bossId] === "ricochet" ? "#a42d18" : "#4d3937"} emissive="#3a100c" emissiveIntensity={0.5} />
      </mesh>
    </group>
  );
}

function BullBoss({ bossIndex, opponent = false }: { bossIndex: number; opponent?: boolean }) {
  const phase = useGameStore((state) => state.phase);
  const bossHp = useGameStore((state) => (opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
  const defeated = bossHp <= 0;
  const group = useRef<THREE.Group>(null);
  const modelRoot = useRef<THREE.Group>(null);
  const light = useRef<THREE.PointLight>(null);
  const gltf = useGLTF(BULL_URL, false, true);
  const { model: bullScene, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
  const { actions } = useAnimations(gltf.animations, bullScene);
  const targetPosition = useMemo(() => new THREE.Vector3(), []);

  useBossAnimationPlayback({
    actions,
    clips: BULL_BOSS_ANIMATION_CONFIG,
    archetype: "bull",
    bossIndex,
    opponent,
    modelRoot,
  });

  useEffect(() => {
    return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
  }, [fadeMaterials]);

  useBossDeathFade(group, light, fadeMaterials, defeated, 2.8, "bulldrome");

  useFrame((_, delta) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const current = encounterBossAt(state, bossIndex, opponent);
    if (!current) return;
    const motion = current.motion;
    targetPosition.set(motion.position[0], 0.03, motion.position[1]);
    group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
    if (!bossCanTrackTarget(current.boss.hp)) return;

    if (motion.mode === "stacking") {
      group.current.rotation.y += (Math.PI * 2 / 5) * delta;
      return;
    }

    const partyPositions = opponent ? state.hockeyPvpOpponent.partyPositions : state.partyPositions;
    const party = opponent ? state.hockeyPvpOpponent.party : state.party;
    const targetIndex = selectMeleeTargetIndex(party);
    const targetId = targetIndex >= 0 ? party[targetIndex].id : "brann";
    let facingX = partyPositions[targetId][0] - motion.position[0];
    let facingZ = partyPositions[targetId][1] - motion.position[1];
    if (motion.mode === "telegraph" || motion.mode === "charging" || motion.mode === "pouncing") {
      facingX = motion.chargeEnd[0] - motion.chargeStart[0];
      facingZ = motion.chargeEnd[1] - motion.chargeStart[1];
    }
    if (Math.hypot(facingX, facingZ) > 0.01) {
      const targetAngle = Math.atan2(facingX, facingZ);
      const difference = Math.atan2(Math.sin(targetAngle - group.current.rotation.y), Math.cos(targetAngle - group.current.rotation.y));
      group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
    }
  });
  if (phase === "briefing") return null;
  return (
    <group ref={group}>
      <group ref={modelRoot}>
        <primitive object={bullScene} scale={0.81} />
      </group>
      <pointLight ref={light} color="#ff9b5c" intensity={2.8} distance={7} position={[0, 2.3, 0.8]} />
    </group>
  );
}

function AlternateBoss({ kind, bossIndex, opponent = false }: { kind: AlternateBossKind; bossIndex: number; opponent?: boolean }) {
  const config = ALTERNATE_BOSS_CONFIG[kind];
  const archetype = BOSS_ARCHETYPE_BY_ID[kind];
  const phase = useGameStore((state) => state.phase);
  const bossHp = useGameStore((state) => (opponent ? state.hockeyPvpOpponent.boss : bossIndex === 0 ? state.boss : state.additionalBosses[bossIndex - 1]?.boss)?.hp ?? 0);
  const defeated = bossHp <= 0;
  const group = useRef<THREE.Group>(null);
  const modelRoot = useRef<THREE.Group>(null);
  const light = useRef<THREE.PointLight>(null);
  const assetUrl = selectedGameAssetUrl(config.url, config.optimizedUrl ?? config.url);
  const gltf = useGameGLTF(assetUrl);
  const { model, modelTopY, fadeMaterials } = useMemo(() => createBossRenderModel(gltf.scene), [gltf.scene]);
  const { actions } = useAnimations(gltf.animations, model);
  const targetPosition = useMemo(() => new THREE.Vector3(), []);
  const burrowPositionY = bossBurrowPositionY(modelTopY, config.scale);

  useBossAnimationPlayback({
    actions,
    clips: config,
    archetype,
    bossIndex,
    opponent,
    modelRoot,
  });

  useEffect(() => {
    return () => { for (const entry of fadeMaterials) entry.material.dispose(); };
  }, [fadeMaterials]);

  useBossDeathFade(group, light, fadeMaterials, defeated, 2.5, kind);

  useFrame((_, delta) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const current = encounterBossAt(state, bossIndex, opponent);
    if (!current) return;
    const motion = current.motion;
    const airborne = archetype === "sky-sweeper" && motion.mode === "skyfall";
    const burrowing = archetype === "burrower" && bossIsBurrowing(motion.activeMechanicId, motion.mode);
    const floatingHeight = config.floating ? 0.2 : 0.03;
    targetPosition.set(motion.position[0], airborne ? 3.2 : burrowing ? burrowPositionY : floatingHeight, motion.position[1]);
    group.current.position.lerp(targetPosition, 1 - Math.pow(0.00001, delta));
    if (!bossCanTrackTarget(current.boss.hp)) return;

    const partyPositions = opponent ? state.hockeyPvpOpponent.partyPositions : state.partyPositions;
    const party = opponent ? state.hockeyPvpOpponent.party : state.party;
    const meleeTargetIndex = selectMeleeTargetIndex(party);
    const meleeTargetId = meleeTargetIndex >= 0 ? party[meleeTargetIndex].id : "brann";
    let targetAngle = Math.atan2(
      partyPositions[meleeTargetId][0] - motion.position[0],
      partyPositions[meleeTargetId][1] - motion.position[1],
    );
    if (archetype === "sky-sweeper" && (motion.mode === "breath_telegraph" || motion.mode === "breath_sweeping")) {
      targetAngle = motion.breathAngle;
    } else if (
      motion.mode === "mantis_line_telegraph"
      || motion.mode === "mantis_cross_telegraph"
    ) {
      const target = partyPositions[motion.chargeTargetId];
      targetAngle = Math.atan2(target[0] - motion.position[0], target[1] - motion.position[1]);
    } else if (motion.mode === "telegraph" || motion.mode === "charging") {
      targetAngle = Math.atan2(motion.chargeEnd[0] - motion.position[0], motion.chargeEnd[1] - motion.position[1]);
    }
    const difference = Math.atan2(
      Math.sin(targetAngle - group.current.rotation.y),
      Math.cos(targetAngle - group.current.rotation.y),
    );
    group.current.rotation.y += difference * (1 - Math.pow(0.001, delta));
  });

  if (phase === "briefing") return null;
  return (
    <group ref={group}>
      <group ref={modelRoot}>
        <primitive object={model} scale={config.scale} rotation={[0, config.rotationOffset, 0]} />
      </group>
      <pointLight ref={light} color={config.light} intensity={2.5} distance={7} position={[0, 2.2, 0.5]} />
    </group>
  );
}

function BarrierField() {
  const group = useRef<THREE.Group>(null);
  const fill = useRef<THREE.MeshBasicMaterial>(null);
  const innerRing = useRef<THREE.MeshBasicMaterial>(null);

  useFrame(({ clock }) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const active = state.phase === "combat" && state.barrier.expiresAt > state.time;
    group.current.visible = active;
    if (!active) return;
    group.current.position.set(state.barrier.center[0], 0.058, state.barrier.center[1]);
    group.current.rotation.y = clock.elapsedTime * 0.08;
    const fieldColor = state.barrier.kind === "spirit-link" ? "#9d8cf2" : "#e7bf46";
    if (fill.current) fill.current.color.set(fieldColor);
    if (innerRing.current) innerRing.current.color.set(fieldColor);
    if (fill.current) fill.current.opacity = 0.16 + (Math.sin(clock.elapsedTime * 2.6) + 1) * 0.035;
    if (innerRing.current) innerRing.current.opacity = 0.38 + (Math.sin(clock.elapsedTime * 3.2) + 1) * 0.12;
  });

  return (
    <group ref={group} visible={false}>
      <mesh rotation={[-Math.PI / 2, 0, 0]}>
        <circleGeometry args={[BARRIER_RADIUS, 64]} />
        <meshBasicMaterial ref={fill} color="#e7bf46" transparent opacity={0.2} depthWrite={false} />
      </mesh>
      <mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[BARRIER_RADIUS - 0.08, BARRIER_RADIUS + 0.05, 64]} />
        <meshBasicMaterial color="#ffd968" transparent opacity={0.88} depthWrite={false} />
      </mesh>
      <mesh position={[0, 0.016, 0]} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[BARRIER_RADIUS * 0.607, BARRIER_RADIUS * 0.633, 48]} />
        <meshBasicMaterial ref={innerRing} color="#ffe89a" transparent opacity={0.55} depthWrite={false} />
      </mesh>
      {Array.from({ length: 8 }, (_, index) => {
        const angle = (index / 8) * Math.PI * 2;
        return (
          <mesh
            key={index}
            position={[Math.sin(angle) * BARRIER_RADIUS * 0.783, 0.02, Math.cos(angle) * BARRIER_RADIUS * 0.783]}
            rotation={[-Math.PI / 2, 0, angle]}
          >
            <ringGeometry args={[0.09, 0.16, 6]} />
            <meshBasicMaterial color="#ffe89a" transparent opacity={0.62} depthWrite={false} />
          </mesh>
        );
      })}
    </group>
  );
}

function TankAuraField() {
  const group = useRef<THREE.Group>(null);
  const material = useRef<THREE.MeshBasicMaterial>(null);
  useFrame(({ clock }) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const sourceId = state.partyCombat.tankAura.sourceId;
    const source = state.party.find((member) => member.id === sourceId);
    const active = state.phase === "combat" && state.partyCombat.tankAura.expiresAt > state.time && Boolean(source && source.hp > 0);
    group.current.visible = active;
    if (!active) return;
    const tankPosition = state.partyPositions[sourceId];
    group.current.position.set(tankPosition[0], 0.06, tankPosition[1]);
    group.current.rotation.y = clock.elapsedTime * -0.22;
    if (material.current) material.current.opacity = 0.13 + (Math.sin(clock.elapsedTime * 5) + 1) * 0.05;
  });
  return (
    <group ref={group} visible={false}>
      <mesh rotation={[-Math.PI / 2, 0, 0]}>
        <circleGeometry args={[3, 48]} />
        <meshBasicMaterial ref={material} color="#67bfff" transparent opacity={0.18} depthWrite={false} />
      </mesh>
      <mesh position={[0, 0.012, 0]} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[2.88, 3.04, 48]} />
        <meshBasicMaterial color="#8fd5ff" transparent opacity={0.92} depthWrite={false} />
      </mesh>
      {Array.from({ length: 6 }, (_, index) => {
        const angle = (index / 6) * Math.PI * 2;
        return <mesh key={index} position={[Math.sin(angle) * 2.35, 0.025, Math.cos(angle) * 2.35]} rotation={[-Math.PI / 2, 0, angle]}>
          <ringGeometry args={[0.08, 0.15, 4]} />
          <meshBasicMaterial color="#d2efff" transparent opacity={0.8} depthWrite={false} />
        </mesh>;
      })}
    </group>
  );
}

function RangedProjectile({ memberId }: { memberId: AiCombatantId }) {
  const projectile = useRef<THREE.Group>(null);
  const impact = useRef<THREE.Group>(null);
  const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const accentMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const trailMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const trail = useRef<THREE.Mesh>(null);
  const impactMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const impactCoreMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const lastAbilityId = useRef<PartyAbilityId | null>(null);
  const start = useMemo(() => new THREE.Vector3(), []);
  const end = useMemo(() => new THREE.Vector3(), []);
  const current = useMemo(() => new THREE.Vector3(), []);
  const direction = useMemo(() => new THREE.Vector3(), []);
  const up = useMemo(() => new THREE.Vector3(0, 1, 0), []);
  const visualArchetype = useGameStore((state) => state.party.find((member) => member.id === memberId)?.runProfile?.visualArchetype);
  const arrowStyle = visualArchetype === "ranger" || !visualArchetype && memberId === "nia";

  useFrame(({ clock }) => {
    if (!projectile.current || !impact.current) return;
    const state = useGameStore.getState();
    const action = state.partyCombat.combatants[memberId].visualAction;
    const member = state.party.find((entry) => entry.id === memberId)!;
    const profile = action ? partyAttackVfxProfile(action.abilityId) : null;
    const rapid = action?.abilityId === "rapid_fire";
    const active = action !== null
      && profile?.style === "projectile"
      && state.phase === "combat"
      && member.hp > 0
      && action.abilityId !== "overcharge"
      && state.time >= action.startedAt
      && (rapid ? state.time <= action.endsAt : state.time <= action.impactAt);
    projectile.current.visible = active;
    impact.current.visible = false;
    if (!action || !profile || profile.style !== "projectile" || state.phase !== "combat" || member.hp <= 0 || action.abilityId === "overcharge") return;

    if (lastAbilityId.current !== action.abilityId) {
      lastAbilityId.current = action.abilityId;
      coreMaterial.current?.color.set(profile.primary);
      accentMaterial.current?.color.set(profile.accent);
      trailMaterial.current?.color.set(profile.primary);
      impactMaterial.current?.color.set(profile.accent);
      impactCoreMaterial.current?.color.set(profile.primary);
    }

    const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
    const projectileDuration = Math.max(0.12, action.impactAt - action.startedAt);
    const progress = rapid
      ? ((state.time - action.startedAt) % 0.4) / 0.4
      : Math.min(1, (state.time - action.startedAt) / projectileDuration);
    const source = state.partyPositions[memberId];
    const target = targetMotion.position;
    start.set(source[0], 1.18, source[1]);
    end.set(target[0], 1.12, target[1]);
    if (active) {
      current.copy(start).lerp(end, progress);
      current.y += Math.sin(progress * Math.PI) * (arrowStyle ? 0.34 : 0.95);
      projectile.current.position.copy(current);
      direction.subVectors(end, start).normalize();
      projectile.current.quaternion.setFromUnitVectors(up, direction);
      const pulseScale = arrowStyle ? 1 : 1 + Math.sin(clock.elapsedTime * 14) * 0.12;
      projectile.current.scale.setScalar(profile.scale * pulseScale);
      trail.current?.scale.set(1, profile.trail, 1);
      if (trailMaterial.current) trailMaterial.current.opacity = 0.34 + Math.sin(clock.elapsedTime * 10) * 0.08;
    }

    const impactProgress = rapid
      ? progress > 0.72 ? (progress - 0.72) / 0.28 : -1
      : (state.time - action.impactAt) / 0.3;
    const impactVisible = impactProgress >= 0 && impactProgress <= 1 && state.time <= action.endsAt + 0.3;
    impact.current.visible = impactVisible;
    if (impactVisible) {
      impact.current.position.copy(end);
      impact.current.scale.setScalar(profile.scale * (0.45 + impactProgress * 2.15));
      if (impactMaterial.current) impactMaterial.current.opacity = (1 - impactProgress) * 0.9;
      if (impactCoreMaterial.current) impactCoreMaterial.current.opacity = (1 - impactProgress) * 0.72;
    }
  });

  return (
    <>
      <group ref={projectile} visible={false}>
        {arrowStyle ? (
          <>
            <mesh>
              <cylinderGeometry args={[0.026, 0.026, 0.82, 6]} />
              <meshBasicMaterial ref={coreMaterial} color="#77d596" />
            </mesh>
            <mesh position={[0, 0.5, 0]}>
              <coneGeometry args={[0.085, 0.2, 6]} />
              <meshBasicMaterial ref={accentMaterial} color="#e5ffb8" />
            </mesh>
            <mesh position={[0, -0.4, 0]}>
              <coneGeometry args={[0.1, 0.18, 4]} />
              <meshBasicMaterial color="#d7b477" />
            </mesh>
            <mesh ref={trail} position={[0, -0.62, 0]}>
              <coneGeometry args={[0.12, 0.72, 6, 1, true]} />
              <meshBasicMaterial ref={trailMaterial} color="#77d596" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
            </mesh>
          </>
        ) : (
          <>
            <mesh>
              <sphereGeometry args={[0.19, 12, 10]} />
              <meshBasicMaterial ref={coreMaterial} color="#a87cff" toneMapped={false} />
            </mesh>
            <mesh rotation={[Math.PI / 2, 0, 0]}>
              <torusGeometry args={[0.25, 0.025, 6, 20]} />
              <meshBasicMaterial ref={accentMaterial} color="#ead9ff" transparent opacity={0.8} depthWrite={false} />
            </mesh>
            <mesh ref={trail} position={[0, -0.48, 0]}>
              <coneGeometry args={[0.18, 0.95, 8, 1, true]} />
              <meshBasicMaterial ref={trailMaterial} color="#a87cff" transparent opacity={0.34} depthWrite={false} blending={THREE.AdditiveBlending} />
            </mesh>
          </>
        )}
      </group>
      <group ref={impact} visible={false}>
        <mesh rotation={[-Math.PI / 2, 0, 0]}>
          <ringGeometry args={[0.28, 0.45, 24]} />
          <meshBasicMaterial ref={impactMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
        </mesh>
        <mesh>
          <octahedronGeometry args={[0.24, 0]} />
          <meshBasicMaterial ref={impactCoreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
        </mesh>
      </group>
    </>
  );
}

function RangedProjectiles() {
  return (
    <>
      <RangedProjectile memberId="brann" />
      <RangedProjectile memberId="nia" />
      <RangedProjectile memberId="orin" />
      <RangedProjectile memberId="vale" />
    </>
  );
}

function CloseAttackVfx({ memberId }: { memberId: AiCombatantId }) {
  const group = useRef<THREE.Group>(null);
  const firstArc = useRef<THREE.Mesh>(null);
  const secondArc = useRef<THREE.Mesh>(null);
  const groundRing = useRef<THREE.Mesh>(null);
  const core = useRef<THREE.Mesh>(null);
  const primaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const secondaryMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const ringMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const lastAbilityId = useRef<PartyAbilityId | null>(null);

  useFrame(({ clock }) => {
    if (!group.current || !firstArc.current || !secondArc.current || !groundRing.current || !core.current) return;
    const state = useGameStore.getState();
    const actor = state.partyCombat.combatants[memberId];
    const action = actor.visualAction;
    const member = state.party.find((entry) => entry.id === memberId)!;
    const visible = action !== null
      && state.phase === "combat"
      && member.hp > 0
      && state.time >= action.startedAt
      && state.time <= action.endsAt + 0.18
      && partyAttackVfxProfile(action.abilityId).style !== "projectile";
    group.current.visible = visible;
    if (!visible || !action) return;

    const profile = partyAttackVfxProfile(action.abilityId);
    if (lastAbilityId.current !== action.abilityId) {
      lastAbilityId.current = action.abilityId;
      primaryMaterial.current?.color.set(profile.primary);
      secondaryMaterial.current?.color.set(profile.accent);
      ringMaterial.current?.color.set(profile.primary);
      coreMaterial.current?.color.set(profile.accent);
    }

    const duration = Math.max(0.2, action.endsAt - action.startedAt);
    const progress = THREE.MathUtils.clamp((state.time - action.startedAt) / duration, 0, 1);
    const impactProgress = THREE.MathUtils.clamp((state.time - action.impactAt + 0.08) / 0.3, 0, 1);
    const source = state.partyPositions[memberId];
    const targetMotion = targetBossMotionByInstance(state, action.targetInstanceId);
    const target = targetMotion.position;
    const sourceStyle = profile.style === "buff" || profile.style === "spin";
    const effectHeight = sourceStyle ? 0.22 : profile.style === "slam" ? 0.18 : 1.05;
    group.current.position.set(sourceStyle ? source[0] : target[0], effectHeight, sourceStyle ? source[1] : target[1]);
    group.current.rotation.y = sourceStyle
      ? clock.elapsedTime * 0.8
      : Math.atan2(target[0] - source[0], target[1] - source[1]);

    const slashStyle = profile.style === "slash" || profile.style === "double-slash";
    firstArc.current.visible = slashStyle;
    secondArc.current.visible = profile.style === "double-slash";
    groundRing.current.visible = profile.style === "slam" || profile.style === "spin" || profile.style === "buff";
    core.current.visible = profile.style === "slam" || profile.style === "buff";
    const actionScale = profile.scale * (0.65 + Math.sin(progress * Math.PI) * 0.75);
    firstArc.current.scale.setScalar(actionScale);
    firstArc.current.rotation.z = -Math.PI * (0.82 - progress * 0.34);
    secondArc.current.scale.setScalar(actionScale * 0.92);
    secondArc.current.rotation.z = -Math.PI * (0.15 + progress * 0.36);
    const burstScale = profile.scale * (0.5 + impactProgress * 2.2);
    groundRing.current.scale.setScalar(burstScale);
    groundRing.current.rotation.z = clock.elapsedTime * (memberId === "vale" ? -1.6 : 0.85);
    core.current.scale.setScalar(profile.scale * (0.6 + Math.sin(progress * Math.PI) * 1.1));
    core.current.rotation.set(clock.elapsedTime, clock.elapsedTime * 1.4, 0);
    if (primaryMaterial.current) primaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.9;
    if (secondaryMaterial.current) secondaryMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.84;
    if (ringMaterial.current) ringMaterial.current.opacity = (1 - impactProgress * 0.7) * 0.76;
    if (coreMaterial.current) coreMaterial.current.opacity = Math.sin(progress * Math.PI) * 0.72;
  });

  return (
    <group ref={group} visible={false}>
      <mesh ref={firstArc} rotation={[0, 0, -Math.PI * 0.72]}>
        <torusGeometry args={[0.72, 0.055, 6, 28, Math.PI * 1.28]} />
        <meshBasicMaterial ref={primaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh ref={secondArc} position={[0.16, 0.02, 0.05]} rotation={[0, 0, -Math.PI * 0.2]}>
        <torusGeometry args={[0.64, 0.045, 6, 26, Math.PI * 1.18]} />
        <meshBasicMaterial ref={secondaryMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh ref={groundRing} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[0.38, 0.54, 28]} />
        <meshBasicMaterial ref={ringMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh ref={core} position={[0, 0.3, 0]}>
        <octahedronGeometry args={[0.26, 0]} />
        <meshBasicMaterial ref={coreMaterial} color="#ffffff" transparent opacity={0} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
    </group>
  );
}

function PartyPowerAuraVfx({ memberId }: { memberId: AiCombatantId }) {
  const group = useRef<THREE.Group>(null);
  const material = useRef<THREE.MeshBasicMaterial>(null);
  useFrame(({ clock }) => {
    if (!group.current) return;
    const state = useGameStore.getState();
    const actor = state.partyCombat.combatants[memberId];
    const member = state.party.find((entry) => entry.id === memberId)!;
    const overcharged = actor.overchargeStacks > 0;
    const active = state.phase === "combat" && member.hp > 0 && (overcharged || actor.bladeFlurryUntil > state.time);
    group.current.visible = active;
    if (!active) return;
    const position = state.partyPositions[memberId];
    group.current.position.set(position[0], 0.16, position[1]);
    group.current.rotation.y = clock.elapsedTime * (overcharged ? 1.4 : -1.8);
    const pulse = 0.92 + Math.sin(clock.elapsedTime * 5.5) * 0.12;
    group.current.scale.setScalar(pulse);
    if (material.current) material.current.opacity = 0.38 + Math.sin(clock.elapsedTime * 4.2) * 0.1;
  });
  const color = memberId === "orin" ? "#bc72ff" : "#9a86ff";
  return (
    <group ref={group} visible={false}>
      <mesh rotation={[-Math.PI / 2, 0, 0]}>
        <torusGeometry args={[0.7, 0.035, 6, 28]} />
        <meshBasicMaterial ref={material} color={color} transparent opacity={0.4} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh position={[0, 0.72, 0]} rotation={[Math.PI / 2, 0, 0]}>
        <torusGeometry args={[0.46, 0.025, 6, 24]} />
        <meshBasicMaterial color={color} transparent opacity={0.5} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
    </group>
  );
}

function PartyCombatVfx() {
  return (
    <>
      <RangedProjectiles />
      <CloseAttackVfx memberId="brann" />
      <CloseAttackVfx memberId="nia" />
      <CloseAttackVfx memberId="orin" />
      <CloseAttackVfx memberId="vale" />
      <PartyPowerAuraVfx memberId="brann" />
      <PartyPowerAuraVfx memberId="nia" />
      <PartyPowerAuraVfx memberId="orin" />
      <PartyPowerAuraVfx memberId="vale" />
    </>
  );
}

function BossActor() {
  const phase = useGameStore((state) => state.phase);
  const primaryBossId = useGameStore((state) => state.boss.id);
  const additionalBossIds = useGameStore((state) => state.additionalBosses.map((entry) => entry.boss.id).join("|"));
  if (phase === "briefing") return null;
  const bossIds = additionalBossIds ? [primaryBossId, ...additionalBossIds.split("|")] : [primaryBossId];
  return (
    <>{bossIds.map((bossId, bossIndex) => (
      <Suspense key={`${bossId}-${bossIndex}`} fallback={<BossFallback bossIndex={bossIndex} />}>
        {bossId === "bulldrome" ? <BullBoss bossIndex={bossIndex} /> : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={bossIndex} />}
      </Suspense>
    ))}</>
  );
}

function OpponentBossActor() {
  const phase = useGameStore((state) => state.phase);
  const bossId = useGameStore((state) => state.hockeyPvpOpponent.boss.id);
  if (phase === "briefing") return null;
  return (
    <Suspense key={`opponent-${bossId}`} fallback={<BossFallback bossIndex={0} opponent />}>
      {bossId === "bulldrome"
        ? <BullBoss bossIndex={0} opponent />
        : <AlternateBoss kind={bossId as AlternateBossKind} bossIndex={0} opponent />}
    </Suspense>
  );
}

function HockeyHealingPvpPlayfield() {
  const puck = useRef<THREE.Group>(null);
  const puckGlow = useRef<THREE.MeshBasicMaterial>(null);
  const arrow = useRef<THREE.Group>(null);
  const arrowMaterial = useRef<THREE.MeshBasicMaterial>(null);

  useFrame(({ clock }) => {
    const state = useGameStore.getState();
    const active = state.activityMode === "hockey-healing-pvp";
    if (puck.current) {
      puck.current.visible = active;
      if (active) {
        puck.current.position.set(state.hockeyPvp.puckPosition[0], 0.48, state.hockeyPvp.puckPosition[1]);
        puck.current.rotation.y += 0.07;
        puck.current.scale.setScalar(1 + Math.sin(clock.elapsedTime * 7) * 0.08);
        if (puckGlow.current) puckGlow.current.opacity = 0.6 + Math.sin(clock.elapsedTime * 7) * 0.18;
      }
    }
    if (!arrow.current) return;
    const localPlayer = hockeyPvpLocalToWorld(state.partyPositions.aelia);
    const distance = Math.hypot(
      state.hockeyPvp.puckPosition[0] - localPlayer[0],
      state.hockeyPvp.puckPosition[1] - localPlayer[1],
    );
    const visible = active
      && state.phase === "combat"
      && state.hockeyPvp.puckVelocity[1] > 0
      && distance < 8;
    arrow.current.visible = visible;
    if (!visible) return;
    const direction = hockeyReturnDirection(state.hockeyPvp.aimDirection);
    arrow.current.position.set(state.hockeyPvp.puckPosition[0], 0.09, state.hockeyPvp.puckPosition[1]);
    arrow.current.rotation.y = Math.atan2(-direction[0], -direction[1]);
    if (arrowMaterial.current) arrowMaterial.current.opacity = 0.62 + Math.sin(clock.elapsedTime * 8) * 0.2;
  });

  return (
    <>
      <group ref={puck} visible={false}>
        <mesh castShadow>
          <sphereGeometry args={[HOCKEY_PVP_PUCK_RADIUS, 16, 12]} />
          <meshStandardMaterial color="#f4fbff" emissive="#45cfff" emissiveIntensity={2.4} roughness={0.18} metalness={0.28} />
        </mesh>
        <mesh scale={1.75}>
          <sphereGeometry args={[HOCKEY_PVP_PUCK_RADIUS, 12, 8]} />
          <meshBasicMaterial ref={puckGlow} color="#67e8ff" transparent opacity={0.68} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
        </mesh>
        <pointLight color="#5ce4ff" intensity={2.2} distance={4.2} />
      </group>
      <group ref={arrow} visible={false}>
        <mesh position={[0, 0, -1.05]}>
          <boxGeometry args={[0.16, 0.045, 1.75]} />
          <meshBasicMaterial ref={arrowMaterial} color="#ffe478" transparent opacity={0.8} depthWrite={false} toneMapped={false} />
        </mesh>
        <mesh position={[0, 0, -2.05]} rotation={[-Math.PI / 2, 0, 0]}>
          <coneGeometry args={[0.38, 0.72, 12]} />
          <meshBasicMaterial color="#fff0a3" transparent opacity={0.88} depthWrite={false} toneMapped={false} />
        </mesh>
      </group>
    </>
  );
}

function EncounterActors({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) {
  const pvp = useGameStore((state) => state.activityMode === "hockey-healing-pvp");
  const localOffset = pvp ? HOCKEY_PVP_SIDE_OFFSET_Z : 0;
  return (
    <>
      <group position={[0, 0, localOffset]}>
        <BossMechanicIndicators />
        <BarrierField />
        <TankAuraField />
        <Party playerAppearance={playerAppearance} />
        <BossActor />
        <PartyCombatVfx />
        <CombatFx />
      </group>
      {pvp && (
        <group position={[0, 0, -HOCKEY_PVP_SIDE_OFFSET_Z]} rotation={[0, Math.PI, 0]}>
          <OpponentParty />
          <OpponentBossActor />
        </group>
      )}
    </>
  );
}

type PerformanceMemory = Performance & {
  memory?: { usedJSHeapSize: number; totalJSHeapSize: number; jsHeapSizeLimit: number };
};

function PerformanceProbe({ mode }: { mode: SceneRenderMode }) {
  const { gl } = useThree();
  const frameSamples = useRef<number[]>([]);
  const observedFrames = useRef(0);
  const longTaskCount = useRef(0);
  const longTaskDuration = useRef(0);
  const lastPublishAt = useRef(0);
  const skipNextFrameSample = useRef(true);

  const publishSnapshot = useCallback((currentMode: SceneRenderMode, samples: readonly number[]) => {
    const memory = performance as PerformanceMemory;
    const resources = performance.getEntriesByType("resource") as PerformanceResourceTiming[];
    let transferredBytes = 0;
    let decodedBytes = 0;
    for (const resource of resources) {
      transferredBytes += resource.transferSize;
      decodedBytes += resource.decodedBodySize;
    }
    document.documentElement.dataset.gamePerf = JSON.stringify({
      frame: summarizeFramePerformance(currentMode, samples),
      renderer: {
        observedFrames: observedFrames.current,
        calls: gl.info.render.calls,
        triangles: gl.info.render.triangles,
        geometries: gl.info.memory.geometries,
        textures: gl.info.memory.textures,
        pixelRatio: gl.getPixelRatio(),
        renderWidth: gl.domElement.width,
        renderHeight: gl.domElement.height,
      },
      simulation: simulationTickSnapshot(),
      memory: memory.memory ? {
        usedJSHeapSize: memory.memory.usedJSHeapSize,
        totalJSHeapSize: memory.memory.totalJSHeapSize,
        jsHeapSizeLimit: memory.memory.jsHeapSizeLimit,
      } : null,
      resources: { transferredBytes, decodedBytes, count: resources.length },
      longTasks: { count: longTaskCount.current, durationMs: longTaskDuration.current },
    });
  }, [gl]);

  useEffect(() => {
    if (!PERFORMANCE_PROBE_ENABLED || typeof PerformanceObserver === "undefined") return;
    let observer: PerformanceObserver | undefined;
    try {
      observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          longTaskCount.current += 1;
          longTaskDuration.current += entry.duration;
        }
      });
      observer.observe({ type: "longtask", buffered: true });
    } catch {
      // Long Tasks API is optional on Android WebView implementations.
    }
    return () => {
      observer?.disconnect();
      delete document.documentElement.dataset.gamePerf;
    };
  }, []);

  useEffect(() => {
    frameSamples.current = [];
    lastPublishAt.current = 0;
    skipNextFrameSample.current = true;
    publishSnapshot(mode, []);
  }, [mode, publishSnapshot]);

  useFrame(({ clock }, delta) => {
    if (!PERFORMANCE_PROBE_ENABLED) return;
    observedFrames.current += 1;
    if (mode === "static" || mode === "suspended") {
      publishSnapshot(mode, []);
      return;
    }
    if (skipNextFrameSample.current) {
      skipNextFrameSample.current = false;
      lastPublishAt.current = clock.elapsedTime;
      return;
    }
    const samples = frameSamples.current;
    if (samples.length === 300) samples.shift();
    if (delta > 0) samples.push(delta * 1000);
    if (clock.elapsedTime - lastPublishAt.current < 1 || samples.length < 30) return;
    lastPublishAt.current = clock.elapsedTime;
    publishSnapshot(mode, samples);
  });
  return null;
}

const FX_BURST_PARTICLE_COUNT = 8;

function scenePulseColor(kind: PulseKind) {
  if (kind === "protective") return "#62bdff";
  if (kind === "cleanse") return "#c39bff";
  if (kind === "periodic-heal") return "#72e0a1";
  if (kind === "field") return "#d8c16e";
  if (kind === "breath") return "#66dcff";
  if (kind === "venom") return "#8fdb4f";
  if (kind === "tether") return "#d482ff";
  if (kind === "skyfall") return "#ffd36b";
  if (kind === "boss" || kind === "debuff" || kind === "charge" || kind === "pounce" || kind === "slash") return "#ff643c";
  return "#ffe087";
}

function FxBurst({ kind, targetId }: { kind: PulseKind; targetId?: MemberId }) {
  const group = useRef<THREE.Group>(null);
  const ring = useRef<THREE.Mesh>(null);
  const material = useRef<THREE.MeshBasicMaterial>(null);
  const particles = useRef<THREE.InstancedMesh>(null);
  const particleMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const core = useRef<THREE.Mesh>(null);
  const coreMaterial = useRef<THREE.MeshBasicMaterial>(null);
  const transform = useMemo(() => new THREE.Object3D(), []);
  const age = useRef(0);
  const isBossFx = kind === "boss";
  const state = useGameStore.getState();
  const worldPosition = isBossFx ? targetBossMotion(state).position : targetId ? state.partyPositions[targetId] : [0, 0];
  const position: [number, number, number] = [worldPosition[0], 0.15, worldPosition[1]];
  const color = scenePulseColor(kind);
  useFrame(({ clock }, delta) => {
    age.current += delta;
    if (!group.current || !ring.current || !material.current || !particles.current || !core.current) return;
    const progress = Math.min(1, age.current / 0.7);
    ring.current.scale.setScalar(0.5 + progress * 3.6);
    material.current.opacity = (1 - progress) * 0.85;
    core.current.scale.setScalar(0.45 + Math.sin(progress * Math.PI) * 1.8);
    core.current.rotation.set(clock.elapsedTime * 1.5, clock.elapsedTime * 2, 0);
    if (coreMaterial.current) coreMaterial.current.opacity = (1 - progress) * 0.72;
    for (let index = 0; index < FX_BURST_PARTICLE_COUNT; index += 1) {
      const angle = index / FX_BURST_PARTICLE_COUNT * Math.PI * 2 + clock.elapsedTime * 0.6;
      const radial = progress * (kind === "boss" ? 3.2 : 1.65);
      const scale = (1 - progress) * (kind === "boss" ? 1.4 : 0.9);
      transform.position.set(Math.sin(angle) * radial, 0.18 + Math.sin(progress * Math.PI) * (0.7 + (index % 2) * 0.45), Math.cos(angle) * radial);
      transform.rotation.set(angle, progress * Math.PI * 2 + index, clock.elapsedTime);
      transform.scale.setScalar(scale);
      transform.updateMatrix();
      particles.current.setMatrixAt(index, transform.matrix);
    }
    particles.current.instanceMatrix.needsUpdate = true;
    if (particleMaterial.current) particleMaterial.current.opacity = (1 - progress) * 0.82;
  });
  return (
    <group ref={group} position={position}>
      <mesh ref={ring} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[0.38, 0.5, 32]} />
        <meshBasicMaterial ref={material} color={color} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <mesh ref={core} position={[0, 0.42, 0]}>
        <octahedronGeometry args={[0.24, 0]} />
        <meshBasicMaterial ref={coreMaterial} color={color} transparent opacity={0.72} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </mesh>
      <instancedMesh ref={particles} args={[undefined, undefined, FX_BURST_PARTICLE_COUNT]} frustumCulled={false}>
        <tetrahedronGeometry args={[0.18, 0]} />
        <meshBasicMaterial ref={particleMaterial} color={color} transparent opacity={0.82} depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} />
      </instancedMesh>
    </group>
  );
}

function CombatFx() {
  const pulse = useGameStore((state) => state.scenePulse);
  if (!pulse.id) return null;
  return <FxBurst key={pulse.id} kind={pulse.kind} targetId={pulse.targetId} />;
}

function ActiveModePlayfield() {
  const activityMode = useGameStore((state) => state.activityMode);
  const playfield = activePlayfieldKind(activityMode);
  if (playfield === "hockey-healing") return <HockeyHealingPlayfield />;
  if (playfield === "blockbreaker") return <BlockbreakerPlayfield />;
  if (playfield === "aether-assault") return <AetherAssaultPlayfield />;
  if (playfield === "hockey-healing-pvp") return <HockeyHealingPvpPlayfield />;
  return null;
}

function useDocumentVisible() {
  const [visible, setVisible] = useState(() => typeof document === "undefined" || document.visibilityState !== "hidden");

  useEffect(() => {
    const updateVisibility = () => setVisible(document.visibilityState !== "hidden");
    const suspend = () => setVisible(false);
    document.addEventListener("visibilitychange", updateVisibility);
    window.addEventListener("pagehide", suspend);
    window.addEventListener("pageshow", updateVisibility);
    return () => {
      document.removeEventListener("visibilitychange", updateVisibility);
      window.removeEventListener("pagehide", suspend);
      window.removeEventListener("pageshow", updateVisibility);
    };
  }, []);

  return visible;
}

export function GameScene({ playerAppearance }: { playerAppearance?: CharacterAppearanceV1 }) {
  const phase = useGameStore((state) => state.phase);
  const paused = useGameStore((state) => state.paused);
  const visible = useDocumentVisible();
  const outcomePhaseActive = isOutcomePhase(phase);
  const [completedOutcomePhase, setCompletedOutcomePhase] = useState<GamePhase | null>(null);
  const outcomeTailComplete = completedOutcomePhase === phase;
  const [dpr, setDpr] = useState(() => Math.min(MAX_RENDER_DPR, Math.max(MIN_RENDER_DPR, window.devicePixelRatio || 1)));
  const setRenderDpr = useCallback((next: number) => {
    setDpr((current) => Math.abs(current - next) < 0.001 ? current : next);
  }, []);
  const completeOutcomeTail = useCallback(() => setCompletedOutcomePhase(phase), [phase]);
  const renderMode = selectSceneRenderMode({ phase, paused, visible, outcomeTailComplete });

  useEffect(() => {
    if (!outcomePhaseActive && completedOutcomePhase !== null) setCompletedOutcomePhase(null);
  }, [completedOutcomePhase, outcomePhaseActive]);

  return (
    <Canvas
      frameloop={sceneCanvasFrameloop(renderMode)}
      shadows="basic"
      dpr={dpr}
      camera={{ position: [0, 5.2, 12], fov: 48, near: 0.1, far: 70 }}
      gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
    >
      <GameAssetProvider>
        <SceneFrameScheduler
          dpr={dpr}
          mode={renderMode}
          outcomePhase={outcomePhaseActive ? phase : null}
          onDprChange={setRenderDpr}
          onOutcomeComplete={completeOutcomeTail}
        />
        <BossRoom />
        <ActiveModePlayfield />
        <EncounterActors playerAppearance={playerAppearance} />
        {PERFORMANCE_PROBE_ENABLED && <PerformanceProbe mode={renderMode} />}
      </GameAssetProvider>
    </Canvas>
  );
}

const MIXED_GALLERY_APPEARANCE: CharacterAppearanceV1 = {
  version: 1,
  rigId: "medium",
  scaleSourceMemberId: "brann",
  headPartId: "rogue-head",
  upperBodyPartId: "knight-upper",
  lowerBodyPartId: "ranger-lower",
  headwearPartId: "mage-hat",
  backPartId: "druid-backpack",
  mainHand: { modelId: "cc/adv_wand", grip: "wand" },
  offHand: { modelId: "cc/spellbook_open", grip: "prop" },
};

function galleryAppearanceOverride(classId: HealerClassId, mix: string | null) {
  if (!mix || CHARACTER_MODEL_MODE !== "modular") return undefined;
  const base = HEALER_VISUAL_PROFILES[classId].appearance;
  if (mix === "head") return { ...base, headPartId: "rogue-head" } satisfies CharacterAppearanceV1;
  if (mix === "upper") return { ...base, upperBodyPartId: "knight-upper" } satisfies CharacterAppearanceV1;
  if (mix === "lower") return { ...base, lowerBodyPartId: "ranger-lower" } satisfies CharacterAppearanceV1;
  if (mix === "headwear") return { ...base, headwearPartId: "knight-helmet" } satisfies CharacterAppearanceV1;
  if (mix === "back") return { ...base, backPartId: "druid-backpack" } satisfies CharacterAppearanceV1;
  if (mix === "weapons") return {
    ...base,
    mainHand: { modelId: "cc/adv_wand", grip: "wand" },
    offHand: { modelId: "cc/spellbook_open", grip: "prop" },
  } satisfies CharacterAppearanceV1;
  return mix === "all" || mix === "1" ? MIXED_GALLERY_APPEARANCE : undefined;
}

export type HealerPreviewAnimation = Extract<ActorAnimationState, "idle" | "walk" | "cast">;

function PreviewHealerActor({
  appearanceOverride,
  animation = "idle",
  classId,
  modelMode = CHARACTER_MODEL_MODE,
  positionX,
}: {
  appearanceOverride?: CharacterAppearanceV1;
  animation?: HealerPreviewAnimation;
  classId: HealerClassId;
  modelMode?: CharacterModelMode;
  positionX: number;
}) {
  const animationState = useRef<ActorAnimationState>(animation);
  const animationTrigger = useRef(0);
  const profile = HEALER_VISUAL_PROFILES[classId];

  useEffect(() => {
    if (animationState.current === animation) return;
    animationState.current = animation;
    animationTrigger.current += 1;
  }, [animation]);

  return (
    <group position={[positionX, 0.025, 0]}>
      <PartyCharacterModel
        memberId="aelia"
        healerClassId={classId}
        appearanceOverride={appearanceOverride}
        modelMode={modelMode}
        animationState={animationState}
        animationTrigger={animationTrigger}
      />
      <mesh position={[0, 0.018, 0]} rotation={[-Math.PI / 2, 0, 0]}>
        <ringGeometry args={[0.48, 0.56, 24]} />
        <meshBasicMaterial color={profile.accentColor} transparent opacity={0.82} />
      </mesh>
    </group>
  );
}

function GalleryCamera() {
  const { camera } = useThree();
  useEffect(() => {
    camera.lookAt(0, 1.15, 0);
    camera.updateProjectionMatrix();
  }, [camera]);
  return null;
}

function PreviewFrameScheduler() {
  const { advance } = useThree();
  const frameId = useRef<number | null>(null);
  const lastRenderedAt = useRef<number | null>(null);

  useEffect(() => {
    const schedule = (now: number) => {
      const previous = lastRenderedAt.current;
      if (previous === null || now - previous + FRAME_INTERVAL_JITTER_MS >= GAMEPLAY_FRAME_INTERVAL_MS) {
        lastRenderedAt.current = now;
        advance(now / 1000, true);
      }
      frameId.current = window.requestAnimationFrame(schedule);
    };
    frameId.current = window.requestAnimationFrame(schedule);
    return () => {
      if (frameId.current !== null) window.cancelAnimationFrame(frameId.current);
      frameId.current = null;
      lastRenderedAt.current = null;
    };
  }, [advance]);

  return null;
}

export function HealerAppearancePreview({
  animation = "idle",
  appearance,
  classId,
  modelMode = CHARACTER_MODEL_MODE,
}: {
  animation?: HealerPreviewAnimation;
  appearance: CharacterAppearanceV1;
  classId: HealerClassId;
  modelMode?: CharacterModelMode;
}) {
  return (
    <Canvas
      frameloop="never"
      shadows="basic"
      dpr={1}
      camera={{ position: [0, 2.2, 4.2], fov: 43, near: 0.1, far: 20 }}
      gl={{ alpha: false, antialias: false, powerPreference: "high-performance" }}
    >
      <GameAssetProvider>
        <PreviewFrameScheduler />
        <GalleryCamera />
        <color attach="background" args={["#07100e"]} />
        <ambientLight intensity={0.7} />
        <directionalLight position={[4, 7, 5]} intensity={1.8} castShadow />
        <directionalLight position={[-5, 3, 2]} color="#72b8ff" intensity={0.55} />
        <mesh position={[0, -0.02, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
          <planeGeometry args={[7, 6]} />
          <meshStandardMaterial color="#10201b" roughness={0.96} />
        </mesh>
        <Suspense fallback={null}>
          <PreviewHealerActor
            appearanceOverride={appearance}
            animation={animation}
            classId={classId}
            modelMode={modelMode}
            positionX={0}
          />
        </Suspense>
      </GameAssetProvider>
    </Canvas>
  );
}

/** Development-only deterministic view used for rig, socket, and silhouette QA. */
export function HealerModelGallery() {
  const query = new URLSearchParams(window.location.search);
  const requestedClass = query.get("class");
  const requestedMix = query.get("mix");
  const galleryClasses = HEALER_CLASS_ORDER.filter((classId) => !requestedClass || classId === requestedClass);
  const visibleClasses = galleryClasses.length > 0 ? galleryClasses : HEALER_CLASS_ORDER;
  const solo = visibleClasses.length === 1;
  return (
    <main style={{ width: "100vw", height: "100vh", background: "#07100e", position: "relative", overflow: "hidden" }}>
      <Canvas
        shadows="basic"
        dpr={1}
        camera={{ position: [0, solo ? 2.2 : 2.8, solo ? 4.2 : 9.5], fov: 43, near: 0.1, far: 40 }}
        gl={{ alpha: false, antialias: true, powerPreference: "high-performance" }}
      >
        <GameAssetProvider>
          <GalleryCamera />
          <color attach="background" args={["#07100e"]} />
          <ambientLight intensity={0.7} />
          <directionalLight position={[4, 7, 5]} intensity={1.8} castShadow />
          <directionalLight position={[-5, 3, 2]} color="#72b8ff" intensity={0.55} />
          <mesh position={[0, -0.02, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
            <planeGeometry args={[14, 8]} />
            <meshStandardMaterial color="#10201b" roughness={0.96} />
          </mesh>
          <Suspense fallback={null}>
            {visibleClasses.map((classId, index) => (
              <PreviewHealerActor
                key={classId}
                classId={classId}
                appearanceOverride={index === 0 ? galleryAppearanceOverride(classId, requestedMix) : undefined}
                positionX={(index - (visibleClasses.length - 1) * 0.5) * 1.65}
              />
            ))}
          </Suspense>
        </GameAssetProvider>
      </Canvas>
      <section style={{ position: "absolute", inset: "auto 5% 5%", display: "grid", gridTemplateColumns: `repeat(${visibleClasses.length}, 1fr)`, color: "#e9f8ef", font: "600 12px/1.2 system-ui", letterSpacing: "0.08em", textAlign: "center", textTransform: "uppercase", pointerEvents: "none" }}>
        {visibleClasses.map((classId) => <span key={classId}>{classId}</span>)}
      </section>
    </main>
  );
}

if (LEGACY_GAME_ASSETS_FORCED) {
  for (const memberId of CRITICAL_PARTY_MEMBER_IDS) {
    useGLTF.preload(PARTY_MODEL_URLS[memberId], false, true);
    const loadout = PARTY_WEAPON_URLS[memberId];
    useGLTF.preload(loadout.right, false, true);
    if (loadout.left) useGLTF.preload(loadout.left, false, true);
  }
}
