Skip to content

Commit df76611

Browse files
committed
deploy: 14b4389
0 parents  commit df76611

3,011 files changed

Lines changed: 1821092 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.nojekyll

Whitespace-only changes.

AnimationClipCache.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import * as THREE from 'three'
2+
import { get, put, remove } from './IndexedDBStore.js'
3+
4+
const DB_NAME = 'spawnpoint-anim-cache'
5+
const DB_VERSION = 3
6+
const STORE = 'clips'
7+
8+
const TRACK_TYPES = [
9+
['QuaternionKeyframeTrack', THREE.QuaternionKeyframeTrack],
10+
['VectorKeyframeTrack', THREE.VectorKeyframeTrack],
11+
['NumberKeyframeTrack', THREE.NumberKeyframeTrack],
12+
['BooleanKeyframeTrack', THREE.BooleanKeyframeTrack],
13+
['StringKeyframeTrack', THREE.StringKeyframeTrack],
14+
['ColorKeyframeTrack', THREE.ColorKeyframeTrack],
15+
]
16+
17+
function getTrackTypeName(track) {
18+
for (const [name, cls] of TRACK_TYPES) {
19+
if (track instanceof cls) return name
20+
}
21+
return null
22+
}
23+
24+
function serializeClip(clip) {
25+
const tracks = []
26+
for (const track of clip.tracks) {
27+
const type = getTrackTypeName(track)
28+
if (!type) continue
29+
tracks.push({ name: track.name, type, times: track.times.buffer.slice(track.times.byteOffset, track.times.byteOffset + track.times.byteLength), values: track.values.buffer.slice(track.values.byteOffset, track.values.byteOffset + track.values.byteLength), interpolation: track.getInterpolation?.() ?? 2301 })
30+
}
31+
return { name: clip.name, duration: clip.duration, tracks }
32+
}
33+
34+
function deserializeClip(data) {
35+
const typeMap = Object.fromEntries(TRACK_TYPES)
36+
const tracks = data.tracks.map(t => {
37+
const TrackClass = typeMap[t.type]
38+
if (!TrackClass) throw new Error(`Unknown track type: ${t.type}`)
39+
const times = t.times instanceof ArrayBuffer ? new Float32Array(t.times) : new Float32Array(t.times)
40+
const values = t.values instanceof ArrayBuffer ? new Float32Array(t.values) : new Float32Array(t.values)
41+
const track = new TrackClass(t.name, times, values)
42+
if (t.interpolation !== undefined && track.setInterpolation) track.setInterpolation(t.interpolation)
43+
return track
44+
})
45+
return new THREE.AnimationClip(data.name, data.duration, tracks)
46+
}
47+
48+
export async function getCachedClips(cacheKey) {
49+
const cached = await get(DB_NAME, DB_VERSION, STORE, cacheKey)
50+
if (cached) {
51+
try {
52+
return new Map(cached.clips.map(c => [c.name.replace(/^VRM\|/, '').replace(/@\d+$/, ''), deserializeClip(c)]))
53+
} catch (e) {
54+
console.warn('[anim-cache] deserialize failed:', e.message)
55+
await remove(DB_NAME, DB_VERSION, STORE, cacheKey)
56+
return null
57+
}
58+
}
59+
return null
60+
}
61+
62+
export async function cacheClips(cacheKey, clipsMap) {
63+
if (!clipsMap) return
64+
const clips = Array.from(clipsMap.values()).map(serializeClip)
65+
try {
66+
await put(DB_NAME, DB_VERSION, STORE, cacheKey, { clips, timestamp: Date.now() })
67+
} catch (e) {
68+
console.warn('[anim-cache] cache failed:', e.message)
69+
}
70+
}

AnimationLibrary.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import * as THREE from 'three'
2+
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
3+
import { getCachedClips, cacheClips } from './AnimationClipCache.js'
4+
5+
const q1 = new THREE.Quaternion()
6+
const restInv = new THREE.Quaternion()
7+
const parentRest = new THREE.Quaternion()
8+
9+
function normalizeClips(gltf, vrmVersion, vrmHumanoid) {
10+
const scene = gltf.scene
11+
scene.updateMatrixWorld(true)
12+
const clips = new Map()
13+
for (const clip of gltf.animations) {
14+
const name = clip.name.replace(/^VRM\|/, '').replace(/@\d+$/, '')
15+
const tracks = []
16+
for (const track of clip.tracks) {
17+
const [boneName, property] = track.name.split('.')
18+
if (property === 'scale') continue
19+
if (property === 'position') {
20+
if (boneName !== 'root' && boneName !== 'hips') continue
21+
if (vrmVersion === '0') {
22+
const newTrack = track.clone()
23+
for (let i = 0; i < newTrack.values.length; i += 3) {
24+
newTrack.values[i] = -newTrack.values[i]
25+
newTrack.values[i + 2] = -newTrack.values[i + 2]
26+
}
27+
tracks.push(newTrack)
28+
} else {
29+
tracks.push(track)
30+
}
31+
continue
32+
}
33+
let bone = scene.getObjectByName(boneName)
34+
if (!bone && vrmHumanoid) bone = vrmHumanoid.getNormalizedBoneNode(boneName)
35+
if (!bone || !bone.parent) { tracks.push(track); continue }
36+
if (property === 'quaternion') {
37+
bone.getWorldQuaternion(restInv).invert()
38+
bone.parent.getWorldQuaternion(parentRest)
39+
const newTrack = track.clone()
40+
for (let i = 0; i < newTrack.values.length; i += 4) {
41+
q1.fromArray(newTrack.values, i)
42+
q1.premultiply(parentRest).multiply(restInv)
43+
if (vrmVersion === '0') { q1.x = -q1.x; q1.z = -q1.z }
44+
q1.toArray(newTrack.values, i)
45+
}
46+
tracks.push(newTrack)
47+
} else {
48+
tracks.push(track)
49+
}
50+
}
51+
clips.set(name, new THREE.AnimationClip(clip.name, clip.duration, tracks))
52+
}
53+
return clips
54+
}
55+
56+
let _gltfPromise = null
57+
let _normalizedCache = null
58+
59+
export function preloadAnimationLibrary(loader) {
60+
if (_gltfPromise) return _gltfPromise
61+
const l = loader || new GLTFLoader()
62+
_gltfPromise = l.loadAsync('/spoint/anim-lib.glb')
63+
return _gltfPromise
64+
}
65+
66+
export async function loadAnimationLibrary(vrmVersion, vrmHumanoid) {
67+
if (_normalizedCache) return _normalizedCache
68+
const cacheKey = `anim-lib-v${vrmVersion || '1'}`
69+
const cached = await getCachedClips(cacheKey)
70+
if (cached) {
71+
console.log(`[anim] Loaded ${cached.size} clips from cache`)
72+
_normalizedCache = { normalizedClips: cached, rawClips: cached }
73+
return _normalizedCache
74+
}
75+
const gltf = await preloadAnimationLibrary()
76+
if (_normalizedCache) return _normalizedCache
77+
const normalizedClips = normalizeClips(gltf, vrmVersion || '1', vrmHumanoid)
78+
_gltfPromise = null
79+
console.log(`[anim] Loaded animation library (${normalizedClips.size} clips):`, [...normalizedClips.keys()])
80+
_normalizedCache = { normalizedClips, rawClips: normalizedClips }
81+
await cacheClips(cacheKey, normalizedClips)
82+
return _normalizedCache
83+
}

AnimationStateMachine.js

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import * as THREE from 'three'
2+
3+
const _isNode = typeof process !== 'undefined' && process.versions?.node
4+
const { createMachine, createActor } = await import(_isNode ? 'xstate' : '/spoint/node_modules/xstate/dist/xstate.esm.js')
5+
6+
export const FADE_TIME = 0.15
7+
8+
export const STATES = {
9+
IdleLoop: { loop: true },
10+
WalkLoop: { loop: true },
11+
JogFwdLoop: { loop: true },
12+
SprintLoop: { loop: true },
13+
JumpStart: { loop: false, next: 'JumpLoop' },
14+
JumpLoop: { loop: true },
15+
JumpLand: { loop: false, next: 'IdleLoop', duration: 0.4 },
16+
CrouchIdleLoop: { loop: true },
17+
CrouchFwdLoop: { loop: true },
18+
Death: { loop: false, clamp: true },
19+
PistolShoot: { loop: false, next: null, duration: 0.3, upperBody: true },
20+
Aim: { loop: true, additive: true },
21+
PistolReload: { loop: false, next: 'IdleLoop', duration: 2.0, upperBody: true }
22+
}
23+
24+
export const LOWER_BODY_BONES = new Set([
25+
'root', 'hips', 'pelvis',
26+
'leftUpperLeg', 'leftLowerLeg', 'leftFoot', 'leftToes',
27+
'rightUpperLeg', 'rightLowerLeg', 'rightFoot', 'rightToes',
28+
'LeftUpperLeg', 'LeftLowerLeg', 'LeftFoot', 'LeftToes',
29+
'RightUpperLeg', 'RightLowerLeg', 'RightFoot', 'RightToes',
30+
'LeftUpLeg', 'LeftLeg', 'LeftFoot', 'LeftToeBase',
31+
'RightUpLeg', 'RightLeg', 'RightFoot', 'RightToeBase',
32+
'leftUpLeg', 'leftLeg', 'leftFoot', 'leftToeBase',
33+
'rightUpLeg', 'rightLeg', 'rightFoot', 'rightToeBase',
34+
'lUpLeg', 'lLeg', 'lFoot', 'lToe',
35+
'rUpLeg', 'rLeg', 'rFoot', 'rToe',
36+
'Normalized_hips', 'Normalized_upper_legL', 'Normalized_upper_legR',
37+
'Normalized_lower_legL', 'Normalized_lower_legR',
38+
'Normalized_footL', 'Normalized_footR',
39+
'Normalized_toesL', 'Normalized_toesR',
40+
'upper_legL', 'upper_legR', 'lower_legL', 'lower_legR',
41+
'footL', 'footR', 'toesL', 'toesR'
42+
])
43+
44+
const locoMachine = createMachine({
45+
id: 'loco',
46+
initial: 'IdleLoop',
47+
states: {
48+
IdleLoop: { on: { WALK: 'WalkLoop', JOG: 'JogFwdLoop', SPRINT: 'SprintLoop', CROUCH_IDLE: 'CrouchIdleLoop', JUMP: 'JumpLoop', DEATH: 'Death' } },
49+
WalkLoop: { on: { IDLE: 'IdleLoop', JOG: 'JogFwdLoop', SPRINT: 'SprintLoop', CROUCH_FWD: 'CrouchFwdLoop', JUMP: 'JumpLoop', DEATH: 'Death' } },
50+
JogFwdLoop: { on: { IDLE: 'IdleLoop', WALK: 'WalkLoop', SPRINT: 'SprintLoop', JUMP: 'JumpLoop', DEATH: 'Death' } },
51+
SprintLoop: { on: { IDLE: 'IdleLoop', WALK: 'WalkLoop', JOG: 'JogFwdLoop', JUMP: 'JumpLoop', DEATH: 'Death' } },
52+
CrouchIdleLoop: { on: { IDLE: 'IdleLoop', CROUCH_FWD: 'CrouchFwdLoop', JUMP: 'JumpLoop', DEATH: 'Death' } },
53+
CrouchFwdLoop: { on: { IDLE: 'IdleLoop', CROUCH_IDLE: 'CrouchIdleLoop', JUMP: 'JumpLoop', DEATH: 'Death' } },
54+
JumpLoop: { on: { IDLE: 'IdleLoop', LAND: 'JumpLand', DEATH: 'Death' } },
55+
JumpLand: { on: { IDLE: 'IdleLoop', WALK: 'WalkLoop', JOG: 'JogFwdLoop', DEATH: 'Death' } },
56+
Death: { on: { REVIVE: 'IdleLoop' } }
57+
}
58+
})
59+
60+
export function createAnimationStateMachine(mixer, root, actions, additiveActions, animConfig = {}) {
61+
const FADE = animConfig.fadeTime || FADE_TIME
62+
const LOCO_STATES = new Set(['IdleLoop', 'WalkLoop', 'JogFwdLoop', 'SprintLoop', 'CrouchIdleLoop', 'CrouchFwdLoop'])
63+
const AIR_GRACE = 0.15
64+
const SPEED_SMOOTH = 8.0
65+
const TIMESCALE_SMOOTH = 10.0
66+
const LOCO_COOLDOWN = 0.3
67+
68+
const actor = createActor(locoMachine)
69+
actor.start()
70+
let current = null
71+
let oneShot = null
72+
let oneShotTimer = 0
73+
let wasOnGround = true
74+
let airTime = 0
75+
let smoothSpeed = 0
76+
let smoothTimeScale = 1.0
77+
let locomotionCooldown = 0
78+
79+
function transitionTo(name) {
80+
if (current === name) return
81+
if (name !== 'IdleLoop' && name !== 'CrouchIdleLoop' && LOCO_STATES.has(name) && LOCO_STATES.has(current) && locomotionCooldown > 0) return
82+
const prev = actions.get(current)
83+
const next = actions.get(name)
84+
if (!next) return
85+
if (prev) prev.fadeOut(FADE)
86+
next.reset().fadeIn(FADE).play()
87+
current = name
88+
if (LOCO_STATES.has(name) && name !== 'IdleLoop' && name !== 'CrouchIdleLoop') locomotionCooldown = LOCO_COOLDOWN
89+
}
90+
91+
function sendLoco(event) {
92+
const snap = actor.getSnapshot()
93+
if (snap.can({ type: event })) {
94+
actor.send({ type: event })
95+
transitionTo(actor.getSnapshot().value)
96+
}
97+
}
98+
99+
if (actions.has('IdleLoop')) { actions.get('IdleLoop').play(); current = 'IdleLoop' }
100+
101+
mixer.addEventListener('finished', () => {
102+
if (oneShot && !STATES[oneShot]?.additive) {
103+
const cfg = STATES[oneShot]
104+
if (cfg?.clamp) return
105+
oneShot = null; oneShotTimer = 0
106+
if (cfg?.next) sendLoco(cfg.next === 'IdleLoop' ? 'IDLE' : cfg.next)
107+
}
108+
})
109+
110+
function aim(active) {
111+
const action = additiveActions.get('Aim')
112+
if (!action) return
113+
if (active) { if (!action.isRunning()) action.fadeIn(FADE).play() }
114+
else { if (action.isRunning()) action.fadeOut(FADE) }
115+
}
116+
117+
function resolveLocoEvent(smoothSpeed, crouching, skipWalk) {
118+
if (crouching) return smoothSpeed < 0.8 ? 'CROUCH_IDLE' : 'CROUCH_FWD'
119+
if (skipWalk) {
120+
const idle2jog = current === 'IdleLoop' ? 2.0 : 0.8
121+
const jog2sprint = current === 'JogFwdLoop' ? 15.5 : 15.0
122+
if (smoothSpeed < idle2jog) return 'IDLE'
123+
if (smoothSpeed < jog2sprint) return 'JOG'
124+
return 'SPRINT'
125+
}
126+
const idle2walk = current === 'IdleLoop' ? 0.5 : 0.3
127+
const walk2jog = current === 'WalkLoop' ? 4.0 : 3.5
128+
const jog2sprint = current === 'JogFwdLoop' ? 15.5 : 15.0
129+
if (smoothSpeed < idle2walk) return 'IDLE'
130+
if (smoothSpeed < walk2jog) return 'WALK'
131+
if (smoothSpeed < jog2sprint) return 'JOG'
132+
return 'SPRINT'
133+
}
134+
135+
function update(dt, velocity, onGround, health, aiming, crouching) {
136+
if (locomotionCooldown > 0) locomotionCooldown -= dt
137+
if (oneShotTimer > 0) {
138+
oneShotTimer -= dt
139+
if (oneShotTimer <= 0) {
140+
const cfg = STATES[oneShot]
141+
oneShot = null
142+
if (cfg?.next) sendLoco(cfg.next === 'IdleLoop' ? 'IDLE' : cfg.next)
143+
}
144+
}
145+
if (!onGround) airTime += dt; else airTime = 0
146+
const effectiveOnGround = onGround || airTime < AIR_GRACE
147+
148+
if (health <= 0 && current !== 'Death') {
149+
sendLoco('DEATH'); oneShot = 'Death'
150+
} else if (health > 0 && (oneShot === 'Death' || current === 'Death')) {
151+
const deathAction = actions.get('Death')
152+
if (deathAction) { deathAction.stop(); deathAction.reset() }
153+
oneShot = null; oneShotTimer = 0; current = null
154+
sendLoco('REVIVE')
155+
} else if (!oneShot || STATES[oneShot]?.additive) {
156+
const vx = velocity?.[0] || 0, vz = velocity?.[2] || 0
157+
const rawSpeed = Math.sqrt(vx * vx + vz * vz)
158+
smoothSpeed += (rawSpeed - smoothSpeed) * Math.min(1, SPEED_SMOOTH * dt)
159+
if (!effectiveOnGround && !wasOnGround) sendLoco('JUMP')
160+
else if (!wasOnGround && effectiveOnGround && smoothSpeed < 1.5) {
161+
sendLoco('LAND'); oneShot = 'JumpLand'; oneShotTimer = STATES.JumpLand.duration
162+
} else if (effectiveOnGround) sendLoco(resolveLocoEvent(smoothSpeed, crouching, animConfig.skipWalk))
163+
}
164+
165+
if (current && LOCO_STATES.has(current) && current !== 'IdleLoop' && current !== 'CrouchIdleLoop') {
166+
const locoAction = actions.get(current)
167+
if (locoAction) {
168+
const baseScale = current === 'WalkLoop' ? (animConfig.walkTimeScale || 1.0)
169+
: current === 'JogFwdLoop' ? (animConfig.jogTimeScale || 1.0)
170+
: current === 'SprintLoop' ? (animConfig.sprintTimeScale || 1.0) : 1.0
171+
const stateMin = current === 'WalkLoop' ? 0.3 : current === 'JogFwdLoop' ? 3.5 : current === 'SprintLoop' ? 12.0 : 0.3
172+
const stateMax = current === 'WalkLoop' ? 4.0 : current === 'JogFwdLoop' ? 15.5 : current === 'SprintLoop' ? 24.0 : 6.0
173+
const ratio = Math.max(0.5, Math.min(1.5, smoothSpeed / Math.max(1, (stateMin + stateMax) * 0.5)))
174+
const target = baseScale * ratio
175+
smoothTimeScale += (target - smoothTimeScale) * Math.min(1, TIMESCALE_SMOOTH * dt)
176+
locoAction.timeScale = smoothTimeScale
177+
}
178+
}
179+
aim(aiming)
180+
wasOnGround = effectiveOnGround
181+
mixer.update(dt)
182+
}
183+
function shoot() {
184+
const action = actions.get('PistolShoot')
185+
if (!action) return
186+
action.reset().fadeIn(0.05).play()
187+
}
188+
function reload() {
189+
const action = actions.get('PistolReload')
190+
if (!action) throw new Error('[anim] PistolReload animation not found')
191+
action.reset().fadeIn(0.1).play()
192+
}
193+
function dispose() {
194+
actor.stop()
195+
mixer.stopAllAction()
196+
mixer.uncacheRoot(root)
197+
}
198+
function getState() { return current }
199+
return { transitionTo, update, aim, shoot, reload, dispose, getState }
200+
}

0 commit comments

Comments
 (0)