mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-13 12:46:11 +01:00
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:
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,
|
||||||
|
|||||||
@@ -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"]>,
|
||||||
|
|||||||
Reference in new issue
Block a user