diff --git a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts index 4f97fef01..c6a006414 100644 --- a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts +++ b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts @@ -146,6 +146,7 @@ export function createMotionClip( ) { const animation = document.createAnimation(clip); const playbackRate = component["motion.playbackRate"]; + const duration = motion.duration / playbackRate; for (const [uid, track] of motion.tracks) { const joint = joints.get(uid); @@ -153,6 +154,20 @@ export function createMotionClip( if (!joint) continue; for (const [type, { times, values }] of track) { + const keyTimes = times.map((time) => time / playbackRate); + const keyValues = + type === MotionChannel.Scale + ? values.flatMap((value) => [value, value, value]) + : [...values]; + + // the game loops over the clip's duration, which can outlast its keys + if (keyTimes[keyTimes.length - 1] < duration) { + const size = keyValues.length / keyTimes.length; + + keyTimes.push(duration); + keyValues.push(...keyValues.slice(-size)); + } + const sampler = document .createAnimationSampler() .setInput( @@ -160,7 +175,7 @@ export function createMotionClip( document, buffer, "SCALAR", - new Float32Array(times.map((time) => time / playbackRate)), + new Float32Array(keyTimes), ), ) .setOutput( @@ -168,11 +183,7 @@ export function createMotionClip( document, buffer, type === MotionChannel.Rotation ? "VEC4" : "VEC3", - new Float32Array( - type === MotionChannel.Scale - ? values.flatMap((value) => [value, value, value]) - : values, - ), + new Float32Array(keyValues), ), ) .setInterpolation("LINEAR"); @@ -191,22 +202,12 @@ export function createMotionClip( } /** - * Makes a socket follow a joint. Socket content is authored in the owner's - * space, so it follows the joint's movement away from its bind pose. + * Makes a socket follow a joint. The game replaces the socket's own transform + * with the joint's, so it sits right on the joint. */ export function bindSocket(socket: Node, joint: Node, skin: Skin) { - const index = skin.listJoints().indexOf(joint); - const inverseBind = skin - .getInverseBindMatrices()! - .getElement(index, []) as number[]; - joint.addChild(socket); - socket.setMatrix( - new Matrix4() - .fromArray(inverseBind) - .multiply(new Matrix4().fromArray(socket.getMatrix())) - .toArray() as mat4, - ); + socket.setMatrix(new Matrix4().toArray() as mat4); socket.traverse((child) => { const childSkin = child.getSkin(); diff --git a/packages/website/src/core/blitzkit/switcherStates.ts b/packages/website/src/core/blitzkit/switcherStates.ts index 394105ebe..f645b9e4a 100644 --- a/packages/website/src/core/blitzkit/switcherStates.ts +++ b/packages/website/src/core/blitzkit/switcherStates.ts @@ -3,7 +3,7 @@ import { type Hierarchy, type Sc2ReadStream, } from "@blitzkit/core"; -import { times } from "lodash-es"; +import { sortBy, times } from "lodash-es"; interface Value { "any.type": string; @@ -103,8 +103,10 @@ function replay( variables: Map; }[] = []; const actions: { owner: Entity; action: Action }[] = []; - const states: Record = {}; + // names can repeat, so states are kept per entity + const states = new Map(); const looping = new Set(); + const finished = new Set(); const pending: (() => void)[] = []; const fired = new Set(); @@ -113,11 +115,11 @@ function replay( if (!names) return; - const previous = states[entity.name]; + const previous = states.get(entity); - states[entity.name] = names[state] ?? null; + states.set(entity, names[state] ?? null); - if (!animate || previous === states[entity.name]) return; + if (!animate || previous === states.get(entity)) return; for (const scenario of scenarios) { if (scenario.owner === entity) { @@ -130,7 +132,7 @@ function replay( for (let child = entity; child.parent; child = child.parent) { if ( stateNames.get(child.parent)?.includes(child.name) && - states[child.parent.name] !== child.name + states.get(child.parent) !== child.name ) { return false; } @@ -145,20 +147,23 @@ function replay( return; } - // some clips restart themselves when they end if (looping.has(entity)) return; looping.add(entity); - if ((repeats ?? entity.repeats) > 0) { + // some clips restart themselves when they end, so each finishes once + if ((repeats ?? entity.repeats) > 0 && !finished.has(entity)) { pending.push(() => finishAnimation(entity)); } } function finishAnimation(entity: Entity) { - const statesBefore = { ...states }; + const statesBefore = new Map(states); const shownBefore = [...looping].filter(isShown); + looping.delete(entity); + finished.add(entity); + for (const { owner, action } of actions) { if ( owner === entity && @@ -183,16 +188,17 @@ function replay( if (owned.length > 0) break; } - // an intro hands over to whatever it revealed; with nothing to hand over - // to, it's the only animation there is so it keeps looping + // as in game, a clip stops on whatever it switched to or revealed; one that + // changes nothing is the only animation there is, so it keeps looping if ( - [...looping].some( - (other) => isShown(other) && !shownBefore.includes(other), + [...states].every( + ([other, state]) => statesBefore.get(other) === state, + ) && + [...looping].every( + (other) => !isShown(other) || shownBefore.includes(other), ) ) { - looping.delete(entity); - } else { - Object.assign(states, statesBefore); + looping.add(entity); } } @@ -249,7 +255,7 @@ function replay( ); stateNames.set(entity, names); - states[entity.name] = names[component["ssc.activeState"]] ?? null; + states.set(entity, names[component["ssc.activeState"]] ?? null); break; } @@ -464,7 +470,12 @@ function replay( fired.add(event); - for (const { owner, action } of actions) { + // the game runs delayed actions once their delay is up, so they go in that + // order rather than the order they're stored in + for (const { owner, action } of sortBy( + actions, + ({ action }) => action["act.delay"], + )) { if ( (animate || action["act.delay"] === 0) && (event === LOAD_EVENTS[0] @@ -484,7 +495,12 @@ function replay( for (let run = pending.shift(); run; run = pending.shift()) run(); return { - states, + states: new Map( + [...states].map(([entity, state]) => [ + entity.hierarchy as Hierarchy, + state, + ]), + ), motions: new Set( [...looping] .filter(isShown) @@ -502,10 +518,20 @@ export function switcherStates( preset: string | undefined, attachments: Map, ) { - return replay(sc2, preset, attachments, false).states; + const states: Record = {}; + + for (const [hierarchy, state] of replay(sc2, preset, attachments, false) + .states) { + states[hierarchy.name] = state; + } + + return states; } -/** where a customization part ends up: its states and the motions left looping */ +/** + * where a customization part ends up: each state switcher's state and the + * motions left looping + */ export function partGarage( sc2: ReturnType, preset: string | undefined, diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts index b9b2250f5..ce4af4325 100644 --- a/packages/website/src/hooks/useSkin.ts +++ b/packages/website/src/hooks/useSkin.ts @@ -47,7 +47,7 @@ async function loadSkin(path: string) { for (const clip of gltf.animations) skin.mixer!.clipAction(clip).play(); for (const parts of skin.parts.values()) { - for (const part of parts) preparePart(part, gltf.scene.userData.states); + for (const part of parts) preparePart(part); } loadedSkins.set(path, skin); @@ -56,14 +56,11 @@ async function loadSkin(path: string) { } // parts render as they are, so what jsxTree and skinTree would do happens here -function preparePart(part: Object3D, states: Record) { +function preparePart(part: Object3D) { part.traverse((object) => { if (object.userData.state !== undefined) { - const name = nameOf(object); - const state = name in states ? states[name] : object.userData.state; - for (const child of object.children) { - child.visible = nameOf(child) === state; + child.visible = nameOf(child) === object.userData.state; } } diff --git a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts index 00ce9a45d..0ac78610b 100644 --- a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts @@ -20,10 +20,7 @@ import type { Vector3Tuple, Vector4Tuple } from "three"; import { SKIN_NUMBER } from "../../../../constants/skinNumber"; import { api } from "../../../../core/blitzkit/api"; import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths"; -import { - partGarage, - switcherStates, -} from "../../../../core/blitzkit/switcherStates"; +import { switcherStates } from "../../../../core/blitzkit/switcherStates"; import { vfs } from "../../../../core/blitzkit/vfs"; import { extractMaterials, @@ -176,12 +173,6 @@ export async function GET({ props }: APIContext<{ skin: number }>) { } const part = await extractModel(vfs, path, true, preset); - const partSc2 = new Sc2ReadStream( - (await vfs.file(`Data/3d/${path}.sc2`)).buffer as ArrayBuffer, - ).sc2(); - - Object.assign(states, partGarage(partSc2, preset).states); - const map = mergeDocuments(document, part); const partScene = map.get(part.getRoot().listScenes()[0]) as Scene; const node = document diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts index dbd96721e..fdc63216b 100644 --- a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts @@ -118,7 +118,7 @@ export async function extractModel( skin: Skin, ][] = []; const motions = new Map(); - const garageMotions = isPart ? partGarage(sc2, preset).motions : new Set(); + const garage = isPart ? partGarage(sc2, preset) : undefined; if (isPart) { const loading: Promise[] = []; @@ -173,7 +173,7 @@ export async function extractModel( skeletons.set(hierarchy, created); // only what loops in the garage is exported - if (motion && motionComponent && garageMotions.has(hierarchy)) { + if (motion && motionComponent && garage?.motions.has(hierarchy)) { createMotionClip( document, buffer, @@ -210,9 +210,9 @@ export async function extractModel( case "StateSwitcherComponent": node.setExtras({ // a part's scenarios can pick its state as it loads - state: - component[`ssc.state${component["ssc.activeState"]}`] ?? - (isPart ? null : undefined), + state: garage + ? (garage.states.get(hierarchy) ?? null) + : component[`ssc.state${component["ssc.activeState"]}`], }); break; @@ -348,7 +348,13 @@ export async function extractModel( ); for (const [owner, entity, joint, skin] of jointBindings) { - const socket = findNode(owner, entity); + // the game only binds the skeleton entity's own children + const socket = owner + .listChildren() + .find( + (child) => + child.getName() === entity && !skin.listJoints().includes(child), + ); if (!socket) throw new Error(`Missing socket ${entity}`); @@ -362,16 +368,6 @@ export async function extractModel( return document; } -function findNode(node: Node, name: string): Node | undefined { - for (const child of node.listChildren()) { - if (child.getName() === name) return child; - - const found = findNode(child, name); - - if (found) return found; - } -} - export async function extractMaterials( document: Document, sc2: ReturnType,