Merge pull request #405 from Hitjack007/animated-legendary-camos

This commit is contained in:
TrèsAbhi authored and GitHub committed 2026-11-07 18:32:38 -06:00
commit 5f4cf15500
12 files changed
+955 -88

No files matched your search

+5 -1
View File
@@ -158,7 +158,11 @@ export type Component =
"ro.notShadowOnly": boolean;
"ro.sOclCull": boolean;
"ro.sOclIndex": number;
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number>;
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number> &
Record<
`skinnedObject.batch${number}.targetsData`,
ArrayBuffer | undefined
>;
}
| {
"comp.typename": "SlotComponent";
@@ -9,6 +9,7 @@ import { skinTree } from "../../../../../../core/blitzkit/skinTree";
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
import { useModel } from "../../../../../../hooks/useModel";
import { useSkin } from "../../../../../../hooks/useSkin";
import { useSkinAnimation } from "../../../../../../hooks/useSkinAnimation";
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
import { useTankTransform } from "../../../../../../hooks/useTankTransform";
import { Duel } from "../../../../../../stores/duel";
@@ -36,12 +37,17 @@ export function TankModel() {
const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
useTankTransform(track, turret, turretContainer, gunContainer);
useSkinAnimation(skins);
useEffect(() => {
const emissiveMaterials = new Set<MeshStandardMaterial>();
const roots = [
gltf.scene,
...skins.flatMap((skin) => [...skin.parts.values()].flat()),
];
for (const scene of [gltf.scene, ...skins.map((skin) => skin.scene)]) {
scene.traverse((object) => {
for (const root of roots) {
root.traverse((object) => {
if (
object instanceof Mesh &&
object.material instanceof MeshStandardMaterial &&
@@ -144,6 +150,15 @@ export function TankModel() {
/>
);
},
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
});
})}
@@ -221,6 +236,15 @@ export function TankModel() {
/>
);
},
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
});
})}
@@ -302,6 +326,15 @@ export function TankModel() {
/>
);
},
primitive(object, key) {
return (
<primitive
key={key}
object={object}
onPointerDown={onPointerDown}
/>
);
},
});
})}
</group>
@@ -1,4 +1,6 @@
export function isHalloween() {
return false;
const data = new Date();
return data.getMonth() === 9 && data.getDate() === 31;
}
+19 -8
View File
@@ -1,5 +1,21 @@
import type { ComponentProps, ReactNode } from "react";
import { AdditiveBlending, Mesh, MeshStandardMaterial, Object3D } from "three";
import {
AdditiveBlending,
Mesh,
MeshStandardMaterial,
Object3D,
type Material,
} from "three";
export function prepareMaterial(material: Material | Material[]) {
if (!(material instanceof MeshStandardMaterial)) return;
material.depthWrite = true;
if (material.userData.blending === "additive") {
material.blending = AdditiveBlending;
}
}
export function jsxTree(
node: Object3D,
@@ -14,6 +30,7 @@ export function jsxTree(
props: ComponentProps<"group">,
key: string,
) => ReactNode;
primitive?: (object3d: Object3D, key: string) => ReactNode;
},
): ReactNode {
if (node instanceof Mesh) {
@@ -25,13 +42,7 @@ export function jsxTree(
scale: node.scale,
};
if (props.material instanceof MeshStandardMaterial) {
props.material.depthWrite = true;
if (props.material.userData.blending === "additive") {
props.material.blending = AdditiveBlending;
}
}
prepareMaterial(props.material);
return mergers?.mesh ? (
mergers.mesh(node, props, node.uuid)
@@ -0,0 +1,64 @@
import { ReadStream } from "@blitzkit/core";
import { times } from "lodash-es";
const HEADER_SIZE = 16;
export enum MotionChannel {
Translation,
Rotation,
Scale,
}
export interface MotionKeys {
times: number[];
values: number[];
}
export interface Motion {
duration: number;
tracks: Map<string, Map<MotionChannel, MotionKeys>>;
}
export class MotionReadStream extends ReadStream {
motion(): Motion {
this.seek(HEADER_SIZE);
const duration = this.float32();
const tracks: Motion["tracks"] = new Map();
times(this.uint32(), () => {
const uid = this.paddedString();
const channels = new Map<MotionChannel, MotionKeys>();
this.paddedString();
this.seek(4);
times(this.uint32(), () => {
const type = this.uint32() & 0xff;
this.seek(4);
const dimension = this.uint32() & 0xff;
const keys: MotionKeys = { times: [], values: [] };
times(this.uint32(), () => {
keys.times.push(this.float32());
times(dimension, () => keys.values.push(this.float32()));
});
channels.set(type, keys);
});
tracks.set(uid, channels);
});
return { duration, tracks };
}
paddedString() {
const end = new Uint8Array(this.buffer).indexOf(0, this.index);
const value = this.utf8(end - this.index);
this.index = (end + 4) & ~3;
return value;
}
}
@@ -0,0 +1,287 @@
import { VertexAttribute, type Component } from "@blitzkit/core";
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
import {
MathUtils,
type Buffer,
type Document,
type mat4,
type Node,
type Primitive,
type Skin,
type TypedArray,
type vec3,
type vec4,
} from "@gltf-transform/core";
import { isEqual, times } from "lodash-es";
import { dirname, normalize } from "path/posix";
import { Matrix4 } from "three";
import { MotionChannel, MotionReadStream, type Motion } from "./motion";
type SkeletonComponent = Extract<
Component,
{ "comp.typename": "SkeletonComponent" }
>;
type MotionComponent = Extract<
Component,
{ "comp.typename": "MotionComponent" }
>;
type AccessorType = "SCALAR" | "VEC3" | "VEC4" | "MAT4";
const JOINT_ROOT = 0xffffff;
const MOTION_PATHS = {
[MotionChannel.Translation]: "translation",
[MotionChannel.Rotation]: "rotation",
[MotionChannel.Scale]: "scale",
} as const;
function createAccessor(
document: Document,
buffer: Buffer,
type: AccessorType,
array: TypedArray,
) {
return document
.createAccessor()
.setType(type)
.setArray(array)
.setBuffer(buffer);
}
export async function loadMotion(
vfs: AbstractVFS,
path: string,
component: MotionComponent,
) {
const file = await vfs.file(
normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`),
);
return new MotionReadStream(
file.buffer.slice(
file.byteOffset,
file.byteOffset + file.byteLength,
) as ArrayBuffer,
).motion();
}
export function createSkeleton(
document: Document,
buffer: Buffer,
node: Node,
skeleton: SkeletonComponent,
motion: Motion | undefined,
) {
const joints = times(
skeleton.jointsCount,
(index) => skeleton.joints[index.toString().padStart(4, "0")],
);
const jointNodes = joints.map((joint) => {
const translation: vec3 = [0, 0, 0];
const rotation: vec4 = [0, 0, 0, 1];
const scale: vec3 = [1, 1, 1];
const track = motion?.tracks.get(joint["joint.uid"]);
const trackScale = track?.get(MotionChannel.Scale)?.values[0];
MathUtils.decompose(
joint["joint.bindPose"].flat() as mat4,
translation,
rotation,
scale,
);
// rest on the first frame so the static pose matches the animation
return document
.createNode(joint["joint.name"])
.setTranslation(
(track?.get(MotionChannel.Translation)?.values.slice(0, 3) as vec3) ??
translation,
)
.setRotation(
(track?.get(MotionChannel.Rotation)?.values.slice(0, 4) as vec4) ??
rotation,
)
.setScale(
trackScale === undefined ? scale : [trackScale, trackScale, trackScale],
);
});
joints.forEach((joint, index) => {
const parent = joint["joint.parentIndex"];
(parent === JOINT_ROOT ? node : jointNodes[parent]).addChild(
jointNodes[index],
);
});
const skin = document
.createSkin()
.setInverseBindMatrices(
createAccessor(
document,
buffer,
"MAT4",
new Float32Array(
joints.flatMap((joint) => joint["joint.invBindPose"].flat()),
),
),
);
jointNodes.forEach((jointNode) => skin.addJoint(jointNode));
return {
skin,
joints: new Map(
joints.map((joint, index) => [joint["joint.uid"], jointNodes[index]]),
),
};
}
export function createMotionClip(
document: Document,
buffer: Buffer,
clip: string,
joints: Map<string, Node>,
motion: Motion,
component: MotionComponent,
) {
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);
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(
createAccessor(
document,
buffer,
"SCALAR",
new Float32Array(keyTimes),
),
)
.setOutput(
createAccessor(
document,
buffer,
type === MotionChannel.Rotation ? "VEC4" : "VEC3",
new Float32Array(keyValues),
),
)
.setInterpolation("LINEAR");
animation
.addSampler(sampler)
.addChannel(
document
.createAnimationChannel()
.setTargetNode(joint)
.setTargetPath(MOTION_PATHS[type])
.setSampler(sampler),
);
}
}
}
/**
* 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) {
joint.addChild(socket);
socket.setMatrix(new Matrix4().toArray() as mat4);
socket.traverse((child) => {
const childSkin = child.getSkin();
// skinned meshes carrying a copy of the owner's skeleton deform with it
if (
childSkin &&
isEqual(
childSkin.listJoints().map((joint) => joint.getName()),
skin.listJoints().map((joint) => joint.getName()),
)
) {
child.setSkin(skin);
}
});
}
export function skinPrimitive(
document: Document,
buffer: Buffer,
primitive: Primitive,
attributes: Map<VertexAttribute, number[][]>,
targetsData: ArrayBuffer | undefined,
) {
const jointIndices =
attributes.get(VertexAttribute.JOINTINDEX) ??
attributes.get(VertexAttribute.HARD_JOINTINDEX);
if (!jointIndices || !targetsData) return false;
const targets = new Uint32Array(new Uint8Array(targetsData).slice().buffer);
const weights =
attributes.get(VertexAttribute.JOINTWEIGHT) ?? jointIndices.map(() => [1]);
primitive
.setAttribute(
"JOINTS_0",
createAccessor(
document,
buffer,
"VEC4",
new Uint16Array(
jointIndices.flatMap((indices) =>
times(4, (index) => {
if (index >= indices.length) return 0;
const target = targets[indices[index]];
if (target === undefined) {
throw new Error(`Missing joint target ${indices[index]}`);
}
return target;
}),
),
),
),
)
.setAttribute(
"WEIGHTS_0",
createAccessor(
document,
buffer,
"VEC4",
new Float32Array(
weights.flatMap((vertexWeights) => {
const padded = times(4, (index) => vertexWeights[index] ?? 0);
// some game weights sum below 1; glTF requires normalized weights
const sum = padded.reduce((total, weight) => total + weight, 0);
return padded.map((weight) => (sum > 0 ? weight / sum : weight));
}),
),
),
);
return true;
}
+20 -26
View File
@@ -5,24 +5,24 @@ import { jsxTree } from "./jsxTree";
type Mergers = Parameters<typeof jsxTree>[1];
// names get sanitized and made unique on load
function nameOf(object3d: Object3D): string {
export function nameOf(object3d: Object3D): string {
return object3d.userData.name ?? object3d.name;
}
/**
* skins are layered in order, so an attachment's states win over the
* camouflage's
* camouflage's; `primitive` renders customization parts as they are so their
* bones can animate
*/
export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) {
const skinNodes = new Map<string, Object3D[]>();
export function skinTree(
node: Object3D,
skins: Skin[],
mergers: NonNullable<Mergers>,
) {
const materials = new Map<string, Material>();
const states: Record<string, string | null> = {};
for (const skin of skins) {
for (const node of skin.scene.children) {
skinNodes.set(node.name, [...(skinNodes.get(node.name) ?? []), node]);
}
for (const [name, material] of skin.materials) {
materials.set(name, material);
}
@@ -30,7 +30,7 @@ export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) {
Object.assign(states, skin.scene.userData.states);
}
function switchChildren(object3d: Object3D, childMergers: Mergers) {
function switchChildren(object3d: Object3D) {
if (object3d.userData.state === undefined) return;
const name = nameOf(object3d);
@@ -38,37 +38,31 @@ export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) {
return object3d.children
.filter((child) => nameOf(child) === state)
.map((child) => jsxTree(child, childMergers));
.map((child) => jsxTree(child, skinMergers));
}
const partMergers: Mergers = {
...mergers,
group(object3d, props, key) {
return (
<group key={key} {...props}>
{switchChildren(object3d, partMergers) ?? props.children}
</group>
);
},
};
const skinMergers: Mergers = {
mesh(mesh, props, key) {
const material = materials.get((mesh.material as Material).name);
return mergers!.mesh!(
return mergers.mesh!(
mesh,
material ? { ...props, material } : props,
key,
);
},
group(object3d, props, key) {
const parts = skinNodes.get(object3d.name) ?? [];
return (
<group key={key} {...props}>
{switchChildren(object3d, skinMergers) ?? props.children}
{parts.flatMap((part) =>
part.children.map((child) => jsxTree(child, partMergers)),
{switchChildren(object3d) ?? props.children}
{skins.flatMap((skin) =>
(skin.parts.get(object3d.name) ?? []).map((part) =>
mergers.primitive ? (
mergers.primitive(part, part.uuid)
) : (
<primitive key={part.uuid} object={part} />
),
),
)}
</group>
);
@@ -1,5 +1,9 @@
import { parseHierarchies, type Sc2ReadStream } from "@blitzkit/core";
import { times } from "lodash-es";
import {
parseHierarchies,
type Hierarchy,
type Sc2ReadStream,
} from "@blitzkit/core";
import { sortBy, times } from "lodash-es";
interface Value {
"any.type": string;
@@ -17,12 +21,15 @@ interface ScriptNode {
interface Script {
nodes: Record<string, ScriptNode>;
variables?: Record<string, Value>;
}
interface Action {
"act.delay": number;
"act.entityName": string;
"act.event": number;
"act.eventToTrigger": string;
"act.stopAfterNRepeats": number;
"act.switchIndex": number;
"act.type": number;
"act.userEventId": string;
@@ -32,12 +39,17 @@ interface Entity {
name: string;
parent?: Entity;
children: Entity[];
hierarchy?: Hierarchy;
repeats?: number;
}
const SELF_ENTITY = "*** Self ***";
const SCENE_LOADED_EVENT = 1;
const USER_EVENT = 2;
const ANIMATION_END_EVENTS = [3, 8];
const START_ANIMATION_ACTION = 6;
const SWITCH_ACTIONS = [8, 9];
const TRIGGER_EVENT_ACTION = 10;
// what the game fires as a tank is shown in the garage, in order
const LOAD_EVENTS = ["Added", "TankAliveInHangar", "TankIsShownInNewScreen"];
const MAX_DEPTH = 64;
@@ -72,25 +84,141 @@ function resolveTarget(owner: Entity, entityName: string) {
}
/**
* replays what picks each state switcher's state as a tank is shown in the
* garage, with the camo's preset applied and its items attached; delayed
* actions are skipped so states stay as they are when the tank appears
* replays what the game does as a tank is shown in the garage, with the camo's
* preset applied and its items attached; `animate` follows customization parts
* through their motions, delays and alarms until they settle
*/
export function switcherStates(
function replay(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
attachments: Map<string, string[]>,
animate: boolean,
) {
const scripts: Record<string, Script> = {};
const root: Entity = { name: "", children: [] };
const stateNames = new Map<Entity, string[]>();
const scenarios: { owner: Entity; script: Script }[] = [];
const scenarios: {
owner: Entity;
script: Script;
variables: Map<string, unknown>;
}[] = [];
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 finished = new Set<Entity>();
const pending: (() => void)[] = [];
const fired = new Set<unknown>();
function activateState(entity: Entity | undefined, state: number) {
const names = entity && stateNames.get(entity);
if (names) states[entity.name] = names[state] ?? null;
if (!names) return;
const previous = states.get(entity);
states.set(entity, names[state] ?? null);
if (!animate || previous === states.get(entity)) return;
for (const scenario of scenarios) {
if (scenario.owner === entity) {
runScenario(scenario, "State Activated", state);
}
}
}
function isShown(entity: Entity) {
for (let child = entity; child.parent; child = child.parent) {
if (
stateNames.get(child.parent)?.includes(child.name) &&
states.get(child.parent) !== child.name
) {
return false;
}
}
return true;
}
function startAnimation(entity: Entity, repeats?: number) {
if (entity.repeats === undefined) {
for (const child of entity.children) startAnimation(child, repeats);
return;
}
if (looping.has(entity)) return;
looping.add(entity);
// 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 = new Map(states);
const shownBefore = [...looping].filter(isShown);
looping.delete(entity);
finished.add(entity);
for (const { owner, action } of actions) {
if (
owner === entity &&
ANIMATION_END_EVENTS.includes(action["act.event"])
) {
runAction(owner, action);
}
}
for (
let ancestor: Entity | undefined = entity;
ancestor;
ancestor = ancestor.parent
) {
const owned = scenarios.filter(({ owner }) => owner === ancestor);
for (const scenario of owned) {
runScenario(scenario, "Motion Fragment Stop");
runScenario(scenario, "Motion Sequence Stop");
}
if (owned.length > 0) break;
}
// 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 (
[...states].every(
([other, state]) => statesBefore.get(other) === state,
) &&
[...looping].every(
(other) => !isShown(other) || shownBefore.includes(other),
)
) {
looping.add(entity);
}
}
function runAction(owner: Entity, action: Action) {
const target = resolveTarget(owner, action["act.entityName"]);
if (SWITCH_ACTIONS.includes(action["act.type"])) {
// what shows for a while in game stays, as its animation loops here
if (action["act.delay"] > 0 && action["act.switchIndex"] < 0) return;
activateState(target, action["act.switchIndex"]);
} else if (animate && action["act.type"] === START_ANIMATION_ACTION) {
if (target) {
const repeats = action["act.stopAfterNRepeats"];
startAnimation(target, repeats > 0 ? repeats : undefined);
}
} else if (animate && action["act.type"] === TRIGGER_EVENT_ACTION) {
fireEvent(action["act.eventToTrigger"]);
}
}
for (const component of Object.values(
@@ -103,9 +231,14 @@ export function switcherStates(
parseHierarchies<Entity>(
sc2["#hierarchy"],
{ name: "", children: [] },
root,
(hierarchy, components, parent) => {
const entity: Entity = { name: hierarchy.name, parent, children: [] };
const entity: Entity = {
name: hierarchy.name,
parent,
children: [],
hierarchy,
};
parent.children.push(entity);
@@ -122,14 +255,24 @@ export function switcherStates(
);
stateNames.set(entity, names);
states[entity.name] = names[component["ssc.activeState"]] ?? null;
states.set(entity, names[component["ssc.activeState"]] ?? null);
break;
}
case "ScenarioComponent": {
const script = scripts[component["scenario.scriptId"]];
if (script) scenarios.push({ owner: entity, script });
if (script) {
scenarios.push({
owner: entity,
script,
variables: new Map(
Object.entries(script.variables ?? {}).map(
([name, variable]) => [name, variable["any.value"]],
),
),
});
}
break;
}
@@ -141,6 +284,12 @@ export function switcherStates(
});
});
break;
case "MotionComponent":
if (animate) {
entity.repeats = component["simpleMotion.repeatsCount"];
}
break;
}
}
@@ -149,9 +298,11 @@ export function switcherStates(
);
function runScenario(
{ owner, script }: (typeof scenarios)[number],
scenario: (typeof scenarios)[number],
event: string,
value?: number,
) {
const { owner, script, variables } = scenario;
const { nodes } = script;
const links = (id: string) => Object.values(nodes[id].connections ?? {});
@@ -168,31 +319,79 @@ export function switcherStates(
switch (type) {
case "ScenarioSceneInfoNode":
return node["scenarioSceneInfoNode.infoKind"] === "GetCurrentEntity"
? owner
: undefined;
switch (node["scenarioSceneInfoNode.infoKind"]) {
case "GetCurrentEntity":
return owner;
case "GetCurrentScene":
return root;
}
return undefined;
case "ScenarioGetMemberNode":
return node["scenarioGetMemberNode.fieldName"] === "parent" &&
pin === "parent"
? (input(id, "object") as Entity | undefined)?.parent
: undefined;
case "ScenarioGetVarNode":
return variables.get(node["scenarioGetVarNode.varPath"] as string);
case "ScenarioFunctionNode":
switch (
`${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`
) {
case "ScenarioSetVarNode":
return variables.get(node["scenarioSetVarNode.varPath"] as string);
case "ScenarioGetMemberNode": {
const object = input(id, "object") as Entity | undefined;
switch (node["scenarioGetMemberNode.fieldName"]) {
case "parent":
return pin === "parent" ? object?.parent : undefined;
case "name":
return object?.name;
case "stateSwitcherVisibility":
return object && isShown(object);
}
return undefined;
}
case "ScenarioFunctionNode": {
const name = node["scenarioFunctionNode.functionName"] as string;
const number = (pin: string) => Number(input(id, pin) ?? 0);
switch (`${node["scenarioFunctionNode.className"]}.${name}`) {
case "Entity.FindChildByName": {
const entity = input(id, "entity") as Entity | undefined;
return entity && findChild(entity, input(id, "name") as string);
}
case "Entity.GetVisibility": {
const entity = input(id, "arg0") as Entity | undefined;
return entity ? isShown(entity) : false;
}
case "TankComponent.GetAppliedSkinMaterialConfigName":
return preset;
case "String.Compare":
return input(id, "1") === input(id, "2");
case "String.Merge":
// graphs use Separator as the prefix and Postfix as the number
return ["Suffix", "Separator", "Postfix"]
.map((pin) => input(id, pin) ?? "")
.join("");
case "bool.Or":
return Boolean(input(id, "a")) || Boolean(input(id, "b"));
case "Random.Random":
case "Random.RandomFloat":
case "TankComponent.GetSpeed":
case "TankComponent.GetTurretAngle":
case "TankComponent.GetGunAngle":
return 0;
}
if (name === "==") return number("a") === number("b");
if (name === ">") return number("a") > number("b");
if (name === ">=") return number("a") >= number("b");
if (name === "<=") return number("a") <= number("b");
if (name.startsWith("Add")) return number("a") + number("b");
if (name.startsWith("Subtract")) return number("a") - number("b");
}
}
}
@@ -206,13 +405,41 @@ export function switcherStates(
pin = "event";
} else if (type === "ScenarioBranchNode") {
pin = input(id, "condition") ? "true" : "false";
} else if (
node["scenarioFunctionNode.functionName"] === "ActivateState"
) {
activateState(
input(id, "entity") as Entity | undefined,
Number(input(id, "state")),
} else if (type === "ScenarioSetVarNode") {
variables.set(
node["scenarioSetVarNode.varPath"] as string,
input(id, "set"),
);
} else if (type === "ScenarioSetAlarmClockNode") {
const entity = (input(id, "entity") as Entity | undefined) ?? owner;
// alarms re-arm themselves
if (animate && !fired.has(node)) {
fired.add(node);
pending.push(() => {
for (const other of scenarios) {
if (other.owner === entity) runScenario(other, "Alarm Clock");
}
});
}
} else {
const entity = input(id, "entity") as Entity | undefined;
switch (node["scenarioFunctionNode.functionName"]) {
case "ActivateState":
activateState(entity, Number(input(id, "state")));
break;
case "StartAnimation":
if (animate && entity) {
startAnimation(entity, Number(input(id, "repeats") ?? 0));
}
break;
case "TriggerCustomEvent":
if (animate) fireEvent(input(id, "event name") as string);
break;
}
}
for (const link of links(id)) {
@@ -227,29 +454,87 @@ export function switcherStates(
? node.defaults?.["Event Name"]?.["any.value"]
: node["ScenarioEventNode.eventName"];
if (type === "ScenarioEventNode" && name === event) execute(id, 0);
if (
type === "ScenarioEventNode" &&
name === event &&
(event !== "State Activated" ||
Number(node.defaults?.State?.["any.value"] ?? 0) === value)
) {
execute(id, 0);
}
}
}
for (const event of LOAD_EVENTS) {
for (const { owner, action } of actions) {
function fireEvent(event: string) {
if (fired.has(event)) return;
fired.add(event);
// 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 (
action["act.delay"] === 0 &&
SWITCH_ACTIONS.includes(action["act.type"]) &&
(animate || action["act.delay"] === 0) &&
(event === LOAD_EVENTS[0]
? action["act.event"] === SCENE_LOADED_EVENT
: action["act.event"] === USER_EVENT &&
action["act.userEventId"] === event)
) {
activateState(
resolveTarget(owner, action["act.entityName"]),
action["act.switchIndex"],
);
runAction(owner, action);
}
}
for (const scenario of scenarios) runScenario(scenario, event);
}
for (const event of LOAD_EVENTS) fireEvent(event);
for (let run = pending.shift(); run; run = pending.shift()) run();
return {
states: new Map(
[...states].map(([entity, state]) => [
entity.hierarchy as Hierarchy,
state,
]),
),
motions: new Set(
[...looping]
.filter(isShown)
.map((entity) => entity.hierarchy as Hierarchy),
),
};
}
/**
* picks each state switcher's state; delayed actions are skipped so states stay
* as they are when the tank appears
*/
export function switcherStates(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
attachments: Map<string, string[]>,
) {
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: each state switcher's state and the
* motions left looping
*/
export function partGarage(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
) {
return replay(sc2, preset, new Map(), true);
}
+46 -1
View File
@@ -1,13 +1,25 @@
import { alias } from "@blitzkit/core";
import { useEffect } from "react";
import type { Group, Material } from "three";
import {
AnimationMixer,
Mesh,
SkinnedMesh,
type Group,
type Material,
type Object3D,
} from "three";
import { GLTFLoader } from "three-stdlib";
import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { prepareMaterial } from "../core/blitzkit/jsxTree";
import { nameOf } from "../core/blitzkit/skinTree";
import { Tankopedia } from "../stores/tankopedia";
export interface Skin {
scene: Group;
materials: Map<string, Material>;
/** customization parts by the tank node they attach to */
parts: Map<string, Object3D[]>;
mixer?: AnimationMixer;
}
const skins = new Map<string, Promise<Skin>>();
@@ -23,13 +35,46 @@ async function loadSkin(path: string) {
.filter((material) => material.userData.camouflage)
.map((material) => [material.name, material]),
),
// taken now because rendering moves the parts out of the scene
parts: new Map(
gltf.scene.children.map((target) => [target.name, [...target.children]]),
),
mixer:
gltf.animations.length > 0 ? new AnimationMixer(gltf.scene) : undefined,
};
// bound now for the same reason, every exported clip loops in the garage
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);
}
loadedSkins.set(path, skin);
return skin;
}
// parts render as they are, so what jsxTree and skinTree would do happens here
function preparePart(part: Object3D) {
part.traverse((object) => {
if (object.userData.state !== undefined) {
for (const child of object.children) {
child.visible = nameOf(child) === object.userData.state;
}
}
if (!(object instanceof Mesh)) return;
object.castShadow = true;
object.receiveShadow = true;
// bounds are from the bind pose
if (object instanceof SkinnedMesh) object.frustumCulled = false;
prepareMaterial(object.material);
});
}
function skinPath(id: number, skin: number) {
return alias("models_legendary", `/${id}/skins/${skin}.glb`);
}
@@ -0,0 +1,13 @@
import { invalidate, useFrame } from "@react-three/fiber";
import type { Skin } from "./useSkin";
export function useSkinAnimation(skins: Skin[]) {
useFrame((_, delta) => {
for (const { mixer } of skins) {
if (!mixer) continue;
mixer.update(delta);
invalidate();
}
});
}
@@ -172,13 +172,7 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
continue;
}
const part = await extractModel(vfs, path, true);
const partSc2 = new Sc2ReadStream(
(await vfs.file(`Data/3d/${path}.sc2`)).buffer as ArrayBuffer,
).sc2();
Object.assign(states, switcherStates(partSc2, preset, new Map()));
const part = await extractModel(vfs, path, true, preset);
const map = mergeDocuments(document, part);
const partScene = map.get(part.getRoot().listScenes()[0]) as Scene;
const node = document
@@ -8,18 +8,35 @@ import {
toUniqueId,
VertexAttribute,
type ConfigArchive,
type Hierarchy,
type TankParameters,
type Textures,
type VehicleDefinitionList,
} from "@blitzkit/core";
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
import { Document, Material, Node, NodeIO, Scene } from "@gltf-transform/core";
import {
Document,
Material,
Node,
NodeIO,
Scene,
type Skin,
} from "@gltf-transform/core";
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dedup, prune } from "@gltf-transform/functions";
import type { APIContext } from "astro";
import { isEqual, times } from "lodash-es";
import { dirname } from "path";
import sharp from "sharp";
import type { Motion } from "../../../core/blitzkit/skinAnimations/motion";
import {
bindSocket,
createMotionClip,
createSkeleton,
loadMotion,
skinPrimitive,
} from "../../../core/blitzkit/skinAnimations/skeleton";
import { partGarage } from "../../../core/blitzkit/switcherStates";
import { vfs } from "../../../core/blitzkit/vfs";
import {
vertexAttributeGLTFName,
@@ -79,6 +96,7 @@ export async function extractModel(
vfs: AbstractVFS,
path: string,
isPart = false,
preset?: string,
) {
const sc2Path = `Data/3d/${path}.sc2`;
const scgPath = `Data/3d/${path}.scg`;
@@ -92,6 +110,35 @@ export async function extractModel(
const scene = document.createScene();
const buffer = document.createBuffer();
const materials = await extractMaterials(document, sc2, path);
const skeletons = new Map<Hierarchy, ReturnType<typeof createSkeleton>>();
const jointBindings: [
owner: Node,
entity: string,
joint: Node,
skin: Skin,
][] = [];
const motions = new Map<Hierarchy, Motion>();
const garage = isPart ? partGarage(sc2, preset) : undefined;
if (isPart) {
const loading: Promise<void>[] = [];
parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => {
for (const component of components) {
if (component["comp.typename"] !== "MotionComponent") continue;
loading.push(
loadMotion(vfs, path, component).then((motion) => {
motions.set(hierarchy, motion);
}),
);
}
return null;
});
await Promise.all(loading);
}
parseHierarchies<Scene | Node>(
sc2["#hierarchy"],
@@ -105,6 +152,40 @@ export async function extractModel(
const node = document.createNode(hierarchy.name);
if (isPart) {
const skeleton = components.find(
(component) => component["comp.typename"] === "SkeletonComponent",
);
const motionComponent = components.find(
(component) => component["comp.typename"] === "MotionComponent",
);
const motion = motions.get(hierarchy);
if (skeleton) {
const created = createSkeleton(
document,
buffer,
node,
skeleton,
motion,
);
skeletons.set(hierarchy, created);
// only what loops in the garage is exported
if (motion && motionComponent && garage?.motions.has(hierarchy)) {
createMotionClip(
document,
buffer,
`motion${document.getRoot().listAnimations().length}`,
created.joints,
motion,
motionComponent,
);
}
}
}
components.forEach((component) => {
switch (component["comp.typename"]) {
case "LodComponent":
@@ -128,10 +209,36 @@ export async function extractModel(
case "StateSwitcherComponent":
node.setExtras({
state: component[`ssc.state${component["ssc.activeState"]}`],
// a part's scenarios can pick its state as it loads
state: garage
? (garage.states.get(hierarchy) ?? null)
: component[`ssc.state${component["ssc.activeState"]}`],
});
break;
case "SkeletonJointBindingComponent": {
const skeleton = skeletons.get(hierarchy);
if (!skeleton) break;
times(component.count, (index) => {
const binding = component.map[
index.toString().padStart(4, "0")
] as unknown as { entityUid: string; jointUid: string };
const joint = skeleton.joints.get(binding.jointUid);
if (!joint) throw new Error(`Missing joint ${binding.jointUid}`);
jointBindings.push([
node,
binding.entityUid,
joint,
skeleton.skin,
]);
});
break;
}
case "RenderComponent": {
const renderObject = component["rc.renderObj"];
@@ -200,10 +307,24 @@ export async function extractModel(
primitive.setAttribute(name, attributeAccessor);
});
const skeleton = skeletons.get(hierarchy);
const mesh = document
.createMesh(batch["##name"])
.addPrimitive(primitive);
lodNode.setMesh(mesh);
if (
skeleton &&
skinPrimitive(
document,
buffer,
primitive,
attributes,
renderObject[`skinnedObject.batch${batchIndex}.targetsData`],
)
) {
lodNode.setSkin(skeleton.skin);
}
node.addChild(lodNode);
});
@@ -226,6 +347,20 @@ export async function extractModel(
},
);
for (const [owner, entity, joint, skin] of jointBindings) {
// 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}`);
bindSocket(socket, joint, skin);
}
scene.addChild(document.createNode("test"));
await document.transform(prune({ keepAttributes: true }), dedup());