import * as THREE from 'three' import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js' import { MeshoptDecoder } from 'three/examples/jsm/libs/meshopt_decoder.module.js' import { clone as cloneSkeleton } from 'three/examples/jsm/utils/SkeletonUtils.js' import type { PartyRole } from './actionCombatCore' import { getArenaAbility, getArenaClassKit, type ArenaAbilityId, type ArenaClassId } from './actionClassKits' export type CombatRenderUnit = { classId?: ArenaClassId id: string role: PartyRole | 'player' | 'boss' teamId?: string } type CombatModelRuntime = { actions: Map clips: Map currentAction: string mixer: THREE.AnimationMixer } type CombatAnimationRequest = string | string[] const combatModelLoader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder) const combatModelCache = new Map>() const combatWeaponCache = new Map>() let kayKitMediumAnimationRequest: Promise | null = null const KAYKIT_COMBAT_ANIMATION_URLS = [ '/action-assets/models/downloaded/kaykit-character-animations/rig-medium/Rig_Medium_General.glb', '/action-assets/models/downloaded/kaykit-character-animations/rig-medium/Rig_Medium_MovementBasic.glb', '/action-assets/models/downloaded/kaykit-character-animations/rig-medium/Rig_Medium_CombatMelee.glb', '/action-assets/models/downloaded/kaykit-character-animations/rig-medium/Rig_Medium_CombatRanged.glb', ] const IDLE_ANIMATIONS = ['Idle_A', 'Idle_B', 'Skeletons_Idle', 'Idle', 'Idle_Combat'] const RUN_ANIMATIONS = ['Running_A', 'Running_B', 'Walking_A', 'Running_Strafe_Left', 'Walking_Backwards'] const SPELL_CAST_ANIMATIONS = ['Ranged_Magic_Raise', 'Spellcast_Raise', 'Spellcasting', 'Ranged_Magic_Spellcasting'] const SPELL_SHOOT_ANIMATIONS = ['Ranged_Magic_Shoot', 'Spellcast_Shoot', 'Ranged_Magic_Spellcasting', 'Ranged_Magic_Raise', 'Spellcasting'] const RANGED_ATTACK_ANIMATIONS = ['Ranged_Bow_Release', 'Ranged_2H_Shoot', 'Ranged_1H_Shoot', 'Ranged_Magic_Shoot', '2H_Ranged_Shoot'] const MELEE_ATTACK_ANIMATIONS = ['Melee_1H_Attack_Chop', 'Melee_1H_Attack_Slice_Diagonal', 'Melee_1H_Attack_Stab', '1H_Melee_Attack_Chop'] const MELEE_HEAVY_ATTACK_ANIMATIONS = ['Melee_2H_Attack_Chop', 'Melee_2H_Attack_Slice', 'Melee_2H_Attack_Stab', '2H_Melee_Attack_Chop'] const OGA_DRAGON_MODEL_URL = '/action-assets/models/opengameart/dragon-oga/dragon-oga-animated.glb' const BULLDROME_IDLE_ANIMATIONS = ['Idle1', 'Idle_AnimalArmature', 'Idle_MonsterArmature', 'Idle'] const BULLDROME_WALK_ANIMATIONS = ['Gallop_AnimalArmature', 'Walk_AnimalArmature', 'Walk_MonsterArmature'] const BULLDROME_ATTACK_ANIMATIONS = ['Attack2 (tusks)', 'Attack_Headbutt_AnimalArmature', 'Bite_Front_MonsterArmature'] const BULLDROME_DEATH_ANIMATIONS = ['Death_AnimalArmature', 'Death_MonsterArmature', 'Dying'] const COMBAT_ANIMATION_NAMES = new Set([ ...IDLE_ANIMATIONS, ...RUN_ANIMATIONS, ...SPELL_CAST_ANIMATIONS, ...SPELL_SHOOT_ANIMATIONS, ...RANGED_ATTACK_ANIMATIONS, ...MELEE_ATTACK_ANIMATIONS, ...MELEE_HEAVY_ATTACK_ANIMATIONS, ...BULLDROME_IDLE_ANIMATIONS, ...BULLDROME_WALK_ANIMATIONS, ...BULLDROME_ATTACK_ANIMATIONS, ...BULLDROME_DEATH_ANIMATIONS, 'Block', 'Dragon_Attack', 'Dragon_Death', 'Dragon_Idle', 'Dragon_Walk', 'Dualwield_Melee_Attack_Chop', 'Flying_Idle', 'Idle1', 'Melee_Block', 'Melee_Blocking', 'Melee_Dualwield_Attack_Chop', 'Melee_Dualwield_Attack_Slice', 'Spellcast_Raise', 'Spellcast_Shoot', ]) const OGA_DRAGON_FOOT_BONES = [ 'Armature_leg_front_foot_L', 'Armature_leg_front_foot_R', 'Armature_leg_back_foot_L', 'Armature_leg_back_foot_R', ] export function getEnemyModelYawOffset(kind: string) { if (kind === 'bullfango' || kind === 'yian-kut-ku' || kind === 'bird') return Math.PI return 0 } export function addCombatUnitMesh(group: THREE.Group, unit: CombatRenderUnit) { group.userData.role = unit.role const fallback = new THREE.Group() fallback.name = 'proceduralFallback' group.add(fallback) if (unit.role === 'player') { addHunterMesh(fallback) addClaudeCraftCombatModel(group, { ...unit, classId: unit.classId ?? 'priest' }) return } if (unit.role === 'boss') return if (unit.role === 'healer') { addHunterMesh(fallback) addClaudeCraftCombatModel(group, { ...unit, classId: unit.classId ?? 'priest' }) return } addPartyMesh(fallback, unit.role, unit.id) addClaudeCraftCombatModel(group, { ...unit, classId: unit.classId ?? (unit.role === 'tank' ? 'knight' : unit.role === 'melee' ? 'rogue' : unit.id.includes('ranged-2') ? 'ranger' : 'mage'), }) } function addClaudeCraftCombatModel(group: THREE.Group, unit: Exclude & { classId: ArenaClassId }) { if (unit.role === 'boss') return const kit = getArenaClassKit(unit.classId) const loadToken = Symbol(kit.model) group.userData.combatModelToken = loadToken group.userData.classId = unit.classId Promise.all([loadCombatModel(kit.model), loadKayKitMediumAnimationClips()]) .then(([gltf, kayKitClips]) => { if (group.userData.combatModelToken !== loadToken) return const model = cloneSkeleton(gltf.scene) model.name = 'claudeCraftCombatModel' model.scale.setScalar(unit.classId === 'priest' ? 0.5 : 0.56) model.traverse((child) => { if (!(child instanceof THREE.Mesh)) return child.castShadow = true child.receiveShadow = true }) const mixer = new THREE.AnimationMixer(model) const clips = createCombatClipMap([...kayKitClips, ...gltf.animations]) const runtime: CombatModelRuntime = { actions: new Map(), clips, currentAction: '', mixer, } group.userData.combatModelRuntime = runtime group.add(model) addClaudeCraftWeapons(model, unit.classId) setCombatModelAction(runtime, IDLE_ANIMATIONS, true) const fallback = group.getObjectByName('proceduralFallback') if (fallback) fallback.visible = false }) .catch(() => { const fallback = group.getObjectByName('proceduralFallback') if (fallback) fallback.visible = true }) } function loadCombatModel(modelUrl: string) { let request = combatModelCache.get(modelUrl) if (!request) { request = combatModelLoader.loadAsync(modelUrl) combatModelCache.set(modelUrl, request) } return request } function loadKayKitMediumAnimationClips() { if (!kayKitMediumAnimationRequest) { kayKitMediumAnimationRequest = Promise.all(KAYKIT_COMBAT_ANIMATION_URLS.map((url) => combatModelLoader.loadAsync(url))) .then((gltfs) => { const clips: THREE.AnimationClip[] = [] const seen = new Set() for (const gltf of gltfs) { for (const clip of gltf.animations) { if (!COMBAT_ANIMATION_NAMES.has(clip.name) || seen.has(clip.name)) continue seen.add(clip.name) clips.push(clip.clone()) } } return clips }) } return kayKitMediumAnimationRequest } function createCombatClipMap(clips: THREE.AnimationClip[]) { const map = new Map() for (const clip of clips) { if (!COMBAT_ANIMATION_NAMES.has(clip.name) || map.has(clip.name)) continue map.set(clip.name, clip) } return map } function loadCombatWeapon(weaponUrl: string) { let request = combatWeaponCache.get(weaponUrl) if (!request) { request = combatModelLoader.loadAsync(weaponUrl) combatWeaponCache.set(weaponUrl, request) } return request } function addClaudeCraftWeapons(model: THREE.Object3D, classId: ArenaClassId) { for (const weapon of getArenaClassKit(classId).weapons) { attachClaudeCraftWeapon(model, weapon.hand, weapon.model) } } function attachClaudeCraftWeapon(model: THREE.Object3D, handSlotName: string, weaponUrl: string) { const handSlot = findAttachmentSlot(model, handSlotName) if (!handSlot) return loadCombatWeapon(weaponUrl) .then((gltf) => { const weaponModel = cloneSkeleton(gltf.scene) weaponModel.name = 'claudeCraftWeapon' weaponModel.traverse((child) => { if (!(child instanceof THREE.Mesh)) return child.castShadow = true child.receiveShadow = true }) handSlot.add(weaponModel) }) .catch(() => null) } function findAttachmentSlot(model: THREE.Object3D, handSlotName: string) { const exact = model.getObjectByName(handSlotName) if (exact) return exact const normalizedName = normalizeSlotName(handSlotName) let match: THREE.Object3D | null = null model.traverse((child) => { if (!match && normalizeSlotName(child.name) === normalizedName) match = child }) return match } function normalizeSlotName(name: string) { return name.toLowerCase().replace(/[^a-z0-9]/g, '') } function setCombatModelAction(runtime: CombatModelRuntime, requestedAction: CombatAnimationRequest, instant = false) { const requestedActions = Array.isArray(requestedAction) ? requestedAction : [requestedAction] const nextAction = requestedActions.map((name) => getCombatAction(runtime, name)).find(Boolean) ?? IDLE_ANIMATIONS.map((name) => getCombatAction(runtime, name)).find(Boolean) ?? getCombatAction(runtime, runtime.clips.keys().next().value) if (!nextAction || runtime.currentAction === nextAction.getClip().name) return const previousAction = runtime.actions.get(runtime.currentAction) const isDeathAction = nextAction.getClip().name === 'Dragon_Death' || BULLDROME_DEATH_ANIMATIONS.includes(nextAction.getClip().name) nextAction.enabled = true nextAction.clampWhenFinished = isDeathAction nextAction.setLoop(isDeathAction ? THREE.LoopOnce : THREE.LoopRepeat, Infinity) nextAction.reset() nextAction.play() if (previousAction && !instant) { previousAction.crossFadeTo(nextAction, 0.16, false) } else { nextAction.weight = 1 } runtime.currentAction = nextAction.getClip().name } function getCombatAction(runtime: CombatModelRuntime, name: string | undefined) { if (!name) return null const existing = runtime.actions.get(name) if (existing) return existing const clip = runtime.clips.get(name) if (!clip) return null const action = runtime.mixer.clipAction(clip) runtime.actions.set(name, action) return action } export function updateCombatUnitModel( mesh: THREE.Object3D, deltaSeconds: number, options?: { abilityId?: ArenaAbilityId | null, action?: 'idle' | 'attack' | 'run' | 'spell', role?: PartyRole | 'boss' | 'player' }, ) { const runtime = mesh.userData.combatModelRuntime as CombatModelRuntime | undefined if (!runtime) return const role = options?.role ?? (mesh.userData.role as PartyRole | 'boss' | 'player' | undefined) if (role === 'boss' && mesh.userData.kind === 'bulldrome') { const action = options?.action === 'attack' || options?.action === 'spell' ? BULLDROME_ATTACK_ANIMATIONS : options?.action === 'run' ? BULLDROME_WALK_ANIMATIONS : BULLDROME_IDLE_ANIMATIONS setCombatModelAction(runtime, action) runtime.mixer.update(deltaSeconds) return } const action = options?.abilityId ? getAnimationForAbility(options.abilityId) : options?.action === 'spell' ? SPELL_CAST_ANIMATIONS : options?.action === 'attack' ? role === 'ranged' ? RANGED_ATTACK_ANIMATIONS : role === 'healer' ? SPELL_SHOOT_ANIMATIONS : MELEE_ATTACK_ANIMATIONS : options?.action === 'run' ? RUN_ANIMATIONS : IDLE_ANIMATIONS setCombatModelAction(runtime, action) runtime.mixer.update(deltaSeconds) } export function updateBossModelAnimation( mesh: THREE.Object3D, deltaSeconds: number, action: 'idle' | 'walk' | 'attack' | 'death' = 'idle', ) { const runtime = mesh.userData.combatModelRuntime as CombatModelRuntime | undefined if (!runtime) return const animation: CombatAnimationRequest = mesh.userData.kind === 'bulldrome' ? action === 'attack' ? BULLDROME_ATTACK_ANIMATIONS : action === 'walk' ? BULLDROME_WALK_ANIMATIONS : action === 'death' ? BULLDROME_DEATH_ANIMATIONS : BULLDROME_IDLE_ANIMATIONS : action === 'attack' ? 'Dragon_Attack' : action === 'walk' ? 'Dragon_Walk' : action === 'death' ? 'Dragon_Death' : 'Dragon_Idle' setCombatModelAction(runtime, animation) runtime.mixer.update(deltaSeconds) if (mesh.userData.kind === 'cyber-dragon') lockDragonFeetToGround(mesh) } function getAnimationForAbility(abilityId: ArenaAbilityId) { const ability = getArenaAbility(abilityId) if (ability.animation === 'block') return ['Melee_Block', 'Melee_Blocking', 'Block', ...MELEE_ATTACK_ANIMATIONS] if (ability.animation === 'dual') return ['Melee_Dualwield_Attack_Chop', 'Melee_Dualwield_Attack_Slice', 'Dualwield_Melee_Attack_Chop', ...MELEE_ATTACK_ANIMATIONS] if (ability.animation === 'melee') return abilityId === 'thunder_clap' ? MELEE_HEAVY_ATTACK_ANIMATIONS : [...MELEE_ATTACK_ANIMATIONS, ...MELEE_HEAVY_ATTACK_ANIMATIONS] if (ability.animation === 'ranged') return RANGED_ATTACK_ANIMATIONS return ability.castTime > 0 ? SPELL_CAST_ANIMATIONS : SPELL_SHOOT_ANIMATIONS } export function addBoarMesh(group: THREE.Group, options?: { color?: number, kind?: 'bulldrome' | 'bullfango' }) { const isBulldrome = options?.kind !== 'bullfango' const hide = new THREE.MeshStandardMaterial({ color: options?.color ?? (isBulldrome ? 0x5f4b3e : 0x8a7049), roughness: 0.9 }) const dark = new THREE.MeshStandardMaterial({ color: 0x2f211b, roughness: 0.85 }) const hornMaterial = new THREE.MeshStandardMaterial({ color: 0xf4eed8, roughness: 0.5 }) const bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(isBulldrome ? 0.78 : 0.52, 18, 12), hide) bodyMesh.scale.set(isBulldrome ? 1.35 : 1.12, isBulldrome ? 0.68 : 0.58, isBulldrome ? 1.65 : 1.28) bodyMesh.position.y = isBulldrome ? 0.62 : 0.48 bodyMesh.castShadow = true group.add(bodyMesh) const chest = new THREE.Mesh(new THREE.SphereGeometry(isBulldrome ? 0.52 : 0.34, 14, 10), dark) chest.scale.set(1.05, 0.9, 0.9) chest.position.set(0, isBulldrome ? 0.76 : 0.55, isBulldrome ? -0.95 : -0.66) chest.castShadow = true group.add(chest) const head = new THREE.Mesh(new THREE.SphereGeometry(isBulldrome ? 0.42 : 0.28, 14, 10), hide) head.scale.set(1.05, 0.82, 0.86) head.position.set(0, isBulldrome ? 0.86 : 0.62, isBulldrome ? -1.35 : -0.9) head.castShadow = true group.add(head) const snout = new THREE.Mesh(new THREE.SphereGeometry(isBulldrome ? 0.22 : 0.16, 10, 8), dark) snout.scale.set(1.2, 0.62, 0.85) snout.position.set(0, isBulldrome ? 0.78 : 0.55, isBulldrome ? -1.68 : -1.12) snout.castShadow = true group.add(snout) for (const x of [-0.28, 0.28]) { const horn = new THREE.Mesh(new THREE.ConeGeometry(isBulldrome ? 0.1 : 0.065, isBulldrome ? 0.58 : 0.34, 8), hornMaterial) horn.position.set(x * (isBulldrome ? 1.5 : 1), isBulldrome ? 1.02 : 0.73, isBulldrome ? -1.5 : -1.0) horn.rotation.set(-Math.PI / 2, 0, x < 0 ? -0.45 : 0.45) horn.castShadow = true group.add(horn) } for (const x of [-0.42, 0.42]) { for (const z of [isBulldrome ? -0.72 : -0.48, isBulldrome ? 0.64 : 0.42]) { const leg = new THREE.Mesh(new THREE.CylinderGeometry(isBulldrome ? 0.1 : 0.075, isBulldrome ? 0.12 : 0.09, isBulldrome ? 0.58 : 0.42, 8), dark) leg.position.set(x, isBulldrome ? 0.24 : 0.18, z) leg.castShadow = true group.add(leg) } } const tail = new THREE.Mesh(new THREE.ConeGeometry(isBulldrome ? 0.06 : 0.04, isBulldrome ? 0.5 : 0.3, 8), dark) tail.position.set(0, isBulldrome ? 0.74 : 0.52, isBulldrome ? 1.35 : 0.9) tail.rotation.x = Math.PI / 2 tail.castShadow = true group.add(tail) } export type ClaudeCraftBossModelKind = | 'deacon-varric' | 'vael-the-mistcaller' | 'ysolei' | 'korgath-the-bound' | 'grand-necromancer-velkhar' | 'korzul-the-gravewyrm' | 'bulldrome' | 'bullfango' | 'cyber-dragon' export function addClaudeCraftBossModel(group: THREE.Group, kind: ClaudeCraftBossModelKind) { const modelUrl = getClaudeCraftBossModelUrl(kind) const loadToken = Symbol(modelUrl) group.userData.bossModelToken = loadToken loadCombatModel(modelUrl) .then((gltf) => { if (group.userData.bossModelToken !== loadToken) return const model = cloneSkeleton(gltf.scene) model.name = 'claudeCraftBossModel' model.scale.setScalar(getClaudeCraftBossModelScale(kind)) if (kind === 'cyber-dragon') normalizeCyberDragonModel(model) if (kind === 'bulldrome') normalizeBulldromeModel(model) model.traverse((child) => { if (!(child instanceof THREE.Mesh)) return child.castShadow = true child.frustumCulled = false child.receiveShadow = true }) const mixer = new THREE.AnimationMixer(model) const runtime: CombatModelRuntime = { actions: new Map(), clips: createCombatClipMap(gltf.animations), currentAction: '', mixer, } group.userData.combatModelRuntime = runtime group.add(model) setCombatModelAction(runtime, getClaudeCraftBossIdleAction(kind), true) const fallback = group.getObjectByName('proceduralFallback') if (fallback) fallback.visible = false }) .catch(() => { const fallback = group.getObjectByName('proceduralFallback') if (fallback) fallback.visible = true }) } export function cancelCombatRenderLoads(root: THREE.Object3D) { root.traverse((object) => { object.userData.combatModelToken = null object.userData.bossModelToken = null }) } export function clearCombatRenderCaches() { const pending = [...combatModelCache.values(), ...combatWeaponCache.values()] combatModelCache.clear() combatWeaponCache.clear() kayKitMediumAnimationRequest = null for (const request of pending) request.then(disposeCachedGltf).catch(() => null) } function disposeCachedGltf(gltf: GLTF) { gltf.scene.traverse((child) => { if (!(child instanceof THREE.Mesh)) return child.geometry.dispose() const materials = Array.isArray(child.material) ? child.material : [child.material] for (const material of materials) disposeMaterial(material) }) } function disposeMaterial(material: THREE.Material) { for (const value of Object.values(material)) { if (value instanceof THREE.Texture) value.dispose() } material.dispose() } function getClaudeCraftBossModelUrl(kind: ClaudeCraftBossModelKind) { if (kind === 'cyber-dragon') return OGA_DRAGON_MODEL_URL if (kind === 'ysolei' || kind === 'korzul-the-gravewyrm') { return '/action-assets/models/claudecraft/creatures/dragonevolved.glb' } if (kind === 'bullfango') return '/action-assets/models/claudecraft/creatures/bull.glb' if (kind === 'bulldrome') return '/action-assets/models/claudecraft/creatures/wild_boar.glb' if (kind === 'korgath-the-bound') return '/action-assets/models/claudecraft/creatures/giant.glb' if (kind === 'vael-the-mistcaller') return '/action-assets/models/claudecraft/chars/players/mage_classic.glb' return '/action-assets/models/claudecraft/chars/enemies/necromancer.glb' } function getClaudeCraftBossModelScale(kind: ClaudeCraftBossModelKind) { if (kind === 'cyber-dragon') return 4.5 if (kind === 'bulldrome') return 0.35 if (kind === 'korzul-the-gravewyrm') return 1.08 if (kind === 'ysolei') return 0.92 if (kind === 'korgath-the-bound') return 1.1 if (kind === 'deacon-varric' || kind === 'grand-necromancer-velkhar') return 1.05 return 0.72 } function getClaudeCraftBossIdleAction(kind: ClaudeCraftBossModelKind) { if (kind === 'cyber-dragon') return 'Dragon_Idle' if (kind === 'ysolei' || kind === 'korzul-the-gravewyrm') return 'Flying_Idle' if (kind === 'bulldrome') return 'Idle1' return 'Idle' } function normalizeCyberDragonModel(model: THREE.Object3D) { liftModelBottomToGround(model) model.position.y -= 0.03 } function normalizeBulldromeModel(model: THREE.Object3D) { liftModelBottomToGround(model) } function liftModelBottomToGround(model: THREE.Object3D) { const box = new THREE.Box3().setFromObject(model) if (!Number.isFinite(box.min.y)) return model.position.y -= box.min.y } function lockDragonFeetToGround(mesh: THREE.Object3D) { const model = mesh.getObjectByName('claudeCraftBossModel') if (!model) return const footPosition = new THREE.Vector3() let lowestFootY = Infinity for (const boneName of OGA_DRAGON_FOOT_BONES) { const bone = model.getObjectByName(boneName) if (!bone) continue bone.getWorldPosition(footPosition) lowestFootY = Math.min(lowestFootY, footPosition.y) } if (Number.isFinite(lowestFootY)) { model.position.y += 0.03 - lowestFootY return } liftModelBottomToGround(model) } export function addBirdMesh(group: THREE.Group, kind: 'bird' | 'yian-kut-ku' = 'bird') { const isYian = kind === 'yian-kut-ku' const feather = new THREE.MeshStandardMaterial({ color: isYian ? 0xd94d2f : 0x7b6ac0, roughness: 0.72 }) const gold = new THREE.MeshStandardMaterial({ color: 0xffc857, roughness: 0.55, emissive: isYian ? 0x7a2200 : 0x000000, emissiveIntensity: isYian ? 0.25 : 0 }) const dark = new THREE.MeshStandardMaterial({ color: 0x301015, roughness: 0.82 }) const torso = new THREE.Mesh(new THREE.SphereGeometry(isYian ? 0.48 : 0.28, 16, 10), feather) torso.scale.set(0.9, isYian ? 1.25 : 1.05, isYian ? 1.05 : 1.2) torso.position.y = isYian ? 0.82 : 0.55 torso.castShadow = true group.add(torso) const head = new THREE.Mesh(new THREE.SphereGeometry(isYian ? 0.24 : 0.16, 12, 8), feather) head.position.set(0, isYian ? 1.35 : 0.88, isYian ? -0.34 : -0.22) head.castShadow = true group.add(head) const beak = new THREE.Mesh(new THREE.ConeGeometry(isYian ? 0.09 : 0.06, isYian ? 0.32 : 0.2, 8), gold) beak.position.set(0, isYian ? 1.34 : 0.87, isYian ? -0.58 : -0.38) beak.rotation.x = -Math.PI / 2 beak.castShadow = true group.add(beak) for (const x of [-1, 1]) { const wing = new THREE.Mesh(new THREE.ConeGeometry(isYian ? 0.34 : 0.2, isYian ? 1.35 : 0.78, 4), feather) wing.position.set(x * (isYian ? 0.68 : 0.38), isYian ? 0.95 : 0.66, 0) wing.rotation.set(0.4, 0, x < 0 ? 1.05 : -1.05) wing.scale.set(1, 0.38, 1.45) wing.castShadow = true group.add(wing) } for (let i = -1; i <= 1; i += 1) { const tail = new THREE.Mesh(new THREE.ConeGeometry(isYian ? 0.12 : 0.07, isYian ? 0.9 : 0.48, 4), i === 0 ? gold : feather) tail.position.set(i * (isYian ? 0.18 : 0.1), isYian ? 0.62 : 0.42, isYian ? 0.72 : 0.42) tail.rotation.set(Math.PI / 2.8, 0, i * 0.28) tail.castShadow = true group.add(tail) } for (const x of [-0.13, 0.13]) { const leg = new THREE.Mesh(new THREE.CylinderGeometry(isYian ? 0.035 : 0.025, isYian ? 0.045 : 0.03, isYian ? 0.32 : 0.22, 6), dark) leg.position.set(x, isYian ? 0.26 : 0.18, -0.04) leg.castShadow = true group.add(leg) } } export function addCyberDragonMesh(group: THREE.Group) { const shell = new THREE.MeshStandardMaterial({ color: 0x31566f, roughness: 0.42, metalness: 0.55 }) const glow = new THREE.MeshStandardMaterial({ color: 0x6feeff, roughness: 0.25, metalness: 0.3, emissive: 0x1b7f99, emissiveIntensity: 0.55 }) const darkMetal = new THREE.MeshStandardMaterial({ color: 0x152432, roughness: 0.48, metalness: 0.72 }) const bodyMesh = new THREE.Mesh(new THREE.SphereGeometry(0.62, 16, 10), shell) bodyMesh.scale.set(1.35, 0.62, 1.55) bodyMesh.position.y = 0.72 bodyMesh.castShadow = true group.add(bodyMesh) const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.18, 0.24, 0.72, 8), darkMetal) neck.position.set(0, 0.88, -0.74) neck.rotation.x = -0.75 neck.castShadow = true group.add(neck) const head = new THREE.Mesh(new THREE.SphereGeometry(0.34, 12, 8), shell) head.scale.set(1.05, 0.78, 1.2) head.position.set(0, 1.12, -1.18) head.castShadow = true group.add(head) const snout = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.42, 4), glow) snout.position.set(0, 1.1, -1.53) snout.rotation.x = -Math.PI / 2 snout.castShadow = true group.add(snout) for (const x of [-0.16, 0.16]) { const horn = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.32, 6), glow) horn.position.set(x, 1.36, -1.2) horn.rotation.x = -0.55 horn.castShadow = true group.add(horn) } for (const x of [-1, 1]) { const wing = new THREE.Mesh(new THREE.ConeGeometry(0.36, 1.35, 4), glow) wing.position.set(x * 0.78, 1.0, 0.02) wing.rotation.set(0.25, 0, x < 0 ? 1.08 : -1.08) wing.scale.set(1, 0.32, 1.5) wing.castShadow = true group.add(wing) } const tail = new THREE.Mesh(new THREE.ConeGeometry(0.16, 1.2, 8), darkMetal) tail.position.set(0, 0.68, 1.26) tail.rotation.x = Math.PI / 2 tail.castShadow = true group.add(tail) for (const x of [-0.42, 0.42]) { for (const z of [-0.38, 0.52]) { const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.1, 0.46, 8), darkMetal) leg.position.set(x, 0.28, z) leg.castShadow = true group.add(leg) } } } export function animateCombatWeapon( mesh: THREE.Object3D, role: PartyRole, options: { elapsed: number, inMelee: boolean }, ) { if (role !== 'tank' && role !== 'melee') return const swing = options.inMelee ? 0.5 - Math.cos(options.elapsed * (role === 'tank' ? 12 : 10.5)) * 0.5 : 0 if (role === 'tank') { const sword = mesh.getObjectByName('tankSword') if (sword) { sword.rotation.x = 0.18 - swing * 0.35 sword.rotation.y = 0.12 + swing * 0.18 sword.rotation.z = -0.55 + swing * 0.72 } return } const sword = mesh.getObjectByName('twoHandSword') if (sword) { sword.rotation.x = 0.26 - swing * 0.42 sword.rotation.y = 0.08 + swing * 0.28 sword.rotation.z = -0.82 + swing * 1.35 } } export function syncRangedCombatProjectiles( scene: THREE.Scene, effects: Map, options: { elapsed: number teamKey: string toWorld: (x: number, y: number) => { x: number, z: number } target: { x: number, y: number } | null staffCaster?: { x: number, y: number } | null archer?: { x: number, y: number } | null }, ) { const active = new Set() if (!options.target) { pruneCombatEffects(effects, active) return } if (options.staffCaster) { const id = `${options.teamKey}-staff-spell` active.add(id) const effect = getOrCreateStaffSpell(scene, effects, id) const t = (options.elapsed * 1.45) % 1 const start = options.toWorld(options.staffCaster.x, options.staffCaster.y) const end = options.toWorld(options.target.x, options.target.y) effect.position.set( THREE.MathUtils.lerp(start.x, end.x, t), 0.82 + Math.sin(t * Math.PI) * 0.58, THREE.MathUtils.lerp(start.z, end.z, t), ) effect.scale.setScalar(0.78 + Math.sin(options.elapsed * 11) * 0.18) effect.visible = true } if (options.archer) { const id = `${options.teamKey}-arrow-shot` active.add(id) const effect = getOrCreateArrowShot(scene, effects, id) const t = (options.elapsed * 2.2) % 1 const start = options.toWorld(options.archer.x, options.archer.y) const end = options.toWorld(options.target.x, options.target.y) effect.position.set( THREE.MathUtils.lerp(start.x, end.x, t), 0.62 + Math.sin(t * Math.PI) * 0.22, THREE.MathUtils.lerp(start.z, end.z, t), ) effect.rotation.y = Math.atan2(end.x - start.x, end.z - start.z) effect.visible = true } pruneCombatEffects(effects, active) } function addHunterMesh(group: THREE.Group) { const armor = new THREE.MeshStandardMaterial({ color: 0x2f8fd1, roughness: 0.58, metalness: 0.16 }) const robe = new THREE.MeshStandardMaterial({ color: 0xf4eed8, roughness: 0.78 }) const skin = new THREE.MeshStandardMaterial({ color: 0xf4c9a0, roughness: 0.72 }) const metal = new THREE.MeshStandardMaterial({ color: 0xd7d9de, roughness: 0.32, metalness: 0.8 }) const glow = new THREE.MeshStandardMaterial({ color: 0x7dff9d, roughness: 0.25, emissive: 0x1e8f45, emissiveIntensity: 0.55 }) group.add(body(armor, 0.34, 0.82)) const skirt = new THREE.Mesh(new THREE.ConeGeometry(0.42, 0.46, 10), robe) skirt.position.y = 0.25 skirt.castShadow = true group.add(skirt) const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 12, 8), skin) head.position.y = 1.18 head.castShadow = true group.add(head) const staff = new THREE.Group() staff.name = 'weapon' const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 1.48, 8), metal) shaft.rotation.x = 0.22 shaft.castShadow = true staff.add(shaft) const headRing = new THREE.Mesh(new THREE.TorusGeometry(0.18, 0.018, 8, 20), metal) headRing.position.y = 0.84 headRing.rotation.x = Math.PI / 2 headRing.castShadow = true staff.add(headRing) const crystal = new THREE.Mesh(new THREE.OctahedronGeometry(0.14), glow) crystal.position.y = 0.84 crystal.castShadow = true staff.add(crystal) const butt = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), metal) butt.position.y = -0.76 butt.castShadow = true staff.add(butt) staff.position.set(0.42, 0.76, -0.22) staff.rotation.z = -0.22 group.add(staff) } function addPartyMesh(group: THREE.Group, role: PartyRole, id: string) { const color = role === 'tank' ? 0xe5b95f : role === 'melee' ? 0xdc5162 : 0x8e68c4 const armor = new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: role === 'tank' ? 0.36 : 0.18 }) const skin = new THREE.MeshStandardMaterial({ color: 0xf4c9a0, roughness: 0.72 }) const leather = new THREE.MeshStandardMaterial({ color: 0x65412a, roughness: 0.78 }) const metal = new THREE.MeshStandardMaterial({ color: 0xd2d5db, roughness: 0.36, metalness: 0.72 }) const wood = new THREE.MeshStandardMaterial({ color: 0x8a5a2f, roughness: 0.82 }) const magic = new THREE.MeshStandardMaterial({ color: 0x89f7ff, roughness: 0.26, emissive: 0x147d92, emissiveIntensity: 0.5 }) group.add(body(armor, role === 'tank' ? 0.34 : 0.29, 0.78)) const head = new THREE.Mesh(new THREE.SphereGeometry(0.18, 12, 8), skin) head.position.y = 1.05 head.castShadow = true group.add(head) const leftArm = limb(armor, 0.08, 0.48) leftArm.position.set(-0.28, 0.72, -0.03) leftArm.rotation.z = -0.24 group.add(leftArm) const rightArm = limb(armor, 0.08, 0.48) rightArm.position.set(0.28, 0.72, -0.03) rightArm.rotation.z = 0.24 group.add(rightArm) const leftLeg = limb(leather, 0.08, 0.48) leftLeg.position.set(-0.13, 0.22, 0) group.add(leftLeg) const rightLeg = limb(leather, 0.08, 0.48) rightLeg.position.set(0.13, 0.22, 0) group.add(rightLeg) if (role === 'tank') { addTankWeapons(group, metal, wood) return } if (role === 'melee') { addMeleeWeapon(group, metal, wood) return } if (id.includes('ranged-1')) { addCasterStaff(group, metal, wood, magic) return } addBow(group, metal, wood) } function addTankWeapons(group: THREE.Group, metal: THREE.Material, wood: THREE.Material) { const shield = new THREE.Group() const shieldFace = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.24, 0.08, 6), metal) shieldFace.rotation.set(Math.PI / 2, 0, 0) shieldFace.castShadow = true shield.add(shieldFace) const boss = new THREE.Mesh(new THREE.SphereGeometry(0.08, 8, 6), wood) boss.position.z = -0.05 boss.castShadow = true shield.add(boss) const stripe = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.46, 0.025), wood) stripe.position.z = -0.08 shield.add(stripe) shield.position.set(-0.43, 0.72, -0.26) shield.rotation.z = 0.25 group.add(shield) const sword = new THREE.Group() sword.name = 'tankSword' const blade = new THREE.Mesh(new THREE.BoxGeometry(0.08, 0.82, 0.055), metal) blade.position.y = 0.24 blade.castShadow = true sword.add(blade) const tip = new THREE.Mesh(new THREE.ConeGeometry(0.075, 0.2, 4), metal) tip.position.y = 0.75 tip.castShadow = true sword.add(tip) const guard = new THREE.Mesh(new THREE.BoxGeometry(0.36, 0.07, 0.06), metal) guard.position.y = -0.2 sword.add(guard) const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.04, 0.04, 0.34, 8), wood) grip.position.y = -0.42 sword.add(grip) const pommel = new THREE.Mesh(new THREE.SphereGeometry(0.07, 8, 6), metal) pommel.position.y = -0.63 sword.add(pommel) sword.position.set(0.42, 0.62, -0.38) sword.rotation.set(0.18, 0.12, -0.55) group.add(sword) } function addMeleeWeapon(group: THREE.Group, metal: THREE.Material, wood: THREE.Material) { const sword = new THREE.Group() sword.name = 'twoHandSword' const blade = new THREE.Mesh(new THREE.BoxGeometry(0.18, 1.24, 0.075), metal) blade.position.y = 0.28 blade.castShadow = true sword.add(blade) const fuller = new THREE.Mesh(new THREE.BoxGeometry(0.045, 0.88, 0.082), wood) fuller.position.y = 0.24 sword.add(fuller) const tip = new THREE.Mesh(new THREE.ConeGeometry(0.13, 0.28, 4), metal) tip.position.y = 1.03 sword.add(tip) const guard = new THREE.Mesh(new THREE.BoxGeometry(0.62, 0.1, 0.1), metal) guard.position.y = -0.4 sword.add(guard) const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.055, 0.055, 0.56, 8), wood) grip.position.y = -0.74 sword.add(grip) const pommel = new THREE.Mesh(new THREE.SphereGeometry(0.1, 8, 6), metal) pommel.position.y = -1.07 sword.add(pommel) sword.position.set(0.42, 0.66, -0.24) sword.rotation.set(0.26, 0.08, -0.82) group.add(sword) } function addCasterStaff(group: THREE.Group, metal: THREE.Material, wood: THREE.Material, magic: THREE.Material) { const staff = new THREE.Group() const shaft = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 1.35, 8), wood) shaft.castShadow = true staff.add(shaft) const crescent = new THREE.Mesh(new THREE.TorusGeometry(0.18, 0.018, 8, 24, Math.PI * 1.3), magic) crescent.position.y = 0.76 crescent.rotation.x = Math.PI / 2 staff.add(crescent) const orb = new THREE.Mesh(new THREE.SphereGeometry(0.11, 12, 8), magic) orb.position.y = 0.76 staff.add(orb) const cap = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), metal) cap.position.y = -0.7 staff.add(cap) staff.position.set(0.38, 0.74, -0.18) staff.rotation.set(0.18, 0, -0.24) group.add(staff) } function addBow(group: THREE.Group, metal: THREE.Material, wood: THREE.Material) { const bow = new THREE.Group() for (const y of [-0.2, 0.2]) { const limbMesh = new THREE.Mesh(new THREE.BoxGeometry(0.06, 0.42, 0.045), wood) limbMesh.position.y = y limbMesh.position.x = y < 0 ? -0.08 : 0.08 limbMesh.rotation.z = y < 0 ? -0.35 : 0.35 bow.add(limbMesh) } const grip = new THREE.Mesh(new THREE.CylinderGeometry(0.045, 0.045, 0.24, 8), wood) grip.rotation.z = Math.PI / 2 bow.add(grip) const string = new THREE.Mesh(new THREE.BoxGeometry(0.025, 0.78, 0.025), metal) string.position.x = 0.24 bow.add(string) const arrow = new THREE.Group() const arrowShaft = new THREE.Mesh(new THREE.BoxGeometry(0.035, 0.035, 0.72), metal) arrow.add(arrowShaft) const arrowTip = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.12, 6), metal) arrowTip.position.z = -0.42 arrowTip.rotation.x = -Math.PI / 2 arrow.add(arrowTip) arrow.position.set(0.08, 0, -0.05) bow.add(arrow) bow.position.set(0.4, 0.75, -0.32) bow.rotation.y = 0.18 group.add(bow) } function body(material: THREE.Material, radius: number, height: number) { const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius * 0.86, height, 12), material) mesh.position.y = height / 2 mesh.castShadow = true return mesh } function limb(material: THREE.Material, radius: number, height: number) { const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius * 0.82, height, 8), material) mesh.castShadow = true return mesh } function getOrCreateStaffSpell(scene: THREE.Scene, effects: Map, id: string) { let group = effects.get(id) if (!group) { group = new THREE.Group() const material = new THREE.MeshBasicMaterial({ color: 0x89f7ff, transparent: true, opacity: 0.86 }) group.add(new THREE.Mesh(new THREE.SphereGeometry(0.11, 12, 8), material)) const ring = new THREE.Mesh(new THREE.TorusGeometry(0.19, 0.013, 6, 20), material) ring.rotation.x = Math.PI / 2 group.add(ring) scene.add(group) effects.set(id, group) } return group } function getOrCreateArrowShot(scene: THREE.Scene, effects: Map, id: string) { let group = effects.get(id) if (!group) { group = new THREE.Group() const wood = new THREE.MeshStandardMaterial({ color: 0x8a5a2f, roughness: 0.82 }) const metal = new THREE.MeshStandardMaterial({ color: 0xd2d5db, roughness: 0.36, metalness: 0.72 }) const shaft = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.04, 0.58), wood) group.add(shaft) const tip = new THREE.Mesh(new THREE.ConeGeometry(0.055, 0.14, 6), metal) tip.position.z = -0.36 tip.rotation.x = -Math.PI / 2 group.add(tip) scene.add(group) effects.set(id, group) } return group } function pruneCombatEffects(map: Map, active: Set) { for (const [id, object] of map) { if (active.has(id)) continue object.removeFromParent() map.delete(id) } }