Fix: Address karelpak1's review on animated legendary camouflages

- Sockets bound to a joint now sit right on the joint, as in game. Only the skeleton's own children are bound. This fixes the WZ-114 gun barrels collapsing.
- Clips keep their full duration when it runs past their last key.
- Part states are kept per entity and exported on each part's own switchers, so parts with matching names can't overwrite each other.
- A finished one-shot animation now stops on whatever it switched to, as in game. It only keeps looping if it changes nothing.
- Delayed actions run in delay order.
This commit is contained in:
Mark Greene committed 2026-11-07 15:35:12 +13:00
1 parent 99373872ed
commit 232b3e7770
5 files changed
+83 -72

No files matched your search

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