I Want To Heal 2 build v1.0.5 code

This commit is contained in:
Warren H
2026-06-28 13:29:39 -04:00
parent 8abb44ea02
commit 257c34fc8d
53 changed files with 18187 additions and 1199 deletions
+983
View File
@@ -0,0 +1,983 @@
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<string, THREE.AnimationAction>
clips: Map<string, THREE.AnimationClip>
currentAction: string
mixer: THREE.AnimationMixer
}
type CombatAnimationRequest = string | string[]
const combatModelLoader = new GLTFLoader().setMeshoptDecoder(MeshoptDecoder)
const combatModelCache = new Map<string, Promise<GLTF>>()
const combatWeaponCache = new Map<string, Promise<GLTF>>()
let kayKitMediumAnimationRequest: Promise<THREE.AnimationClip[]> | 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<CombatRenderUnit, { role: 'boss' }> & { 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<string, THREE.AnimationAction>(),
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<string>()
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<string, THREE.AnimationClip>()
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<string, THREE.AnimationAction>(),
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<string, THREE.Group>,
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<string>()
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<string, THREE.Group>, 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<string, THREE.Group>, 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<string, THREE.Group>, active: Set<string>) {
for (const [id, object] of map) {
if (active.has(id)) continue
object.removeFromParent()
map.delete(id)
}
}