984 lines
37 KiB
TypeScript
984 lines
37 KiB
TypeScript
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)
|
|
}
|
|
}
|