mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-13 20:56:11 +01:00
Feature: Looping garage animations for legendary camouflages
Legendary camo add-ons now loop their garage animation in the Tankopedia viewer. It's always on, with no settings. - The garage replay now also works out which motions end up looping, and the export writes only those clips - Customization parts export skeletons, skin weights and sockets - Parts render as primitives so their bones can animate, with attachments layered on top - Base tank models are unchanged
This commit is contained in:
commit
8566b2cc2f
94 files changed
+2037
-1861
No files matched your search
@@ -1,5 +1,21 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { 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,
|
||||
@@ -25,9 +41,7 @@ export function jsxTree(
|
||||
scale: node.scale,
|
||||
};
|
||||
|
||||
if (props.material instanceof MeshStandardMaterial) {
|
||||
props.material.depthWrite = true;
|
||||
}
|
||||
prepareMaterial(props.material);
|
||||
|
||||
return mergers?.mesh ? (
|
||||
mergers.mesh(node, props, node.uuid)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Node, Scene } from "@gltf-transform/core";
|
||||
|
||||
export function addExtras(property: Node | Scene, extras: object) {
|
||||
property.setExtras({ ...property.getExtras(), ...extras });
|
||||
}
|
||||
|
||||
/** glTF extras must be plain JSON; sc2 data holds bigints */
|
||||
export function toExtras(value: unknown) {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_, item) =>
|
||||
typeof item === "bigint"
|
||||
? Number(item)
|
||||
: item instanceof ArrayBuffer
|
||||
? undefined
|
||||
: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,834 +0,0 @@
|
||||
import {
|
||||
AnimationMixer,
|
||||
LoopRepeat,
|
||||
Object3D,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
type AnimationAction,
|
||||
type AnimationClip,
|
||||
} from "three";
|
||||
import { degToRad } from "three/src/math/MathUtils.js";
|
||||
import type { SkinMotion } from "./skeleton";
|
||||
|
||||
interface Action {
|
||||
"act.delay": number;
|
||||
"act.delayVariation": number;
|
||||
"act.entityName": string;
|
||||
"act.event": number;
|
||||
"act.eventToTrigger": string;
|
||||
"act.motionSpeed"?: number;
|
||||
"act.stopAfterNRepeats": number;
|
||||
"act.switchIndex": number;
|
||||
"act.type": number;
|
||||
"act.userEventId": string;
|
||||
}
|
||||
|
||||
interface Value {
|
||||
"any.type": string;
|
||||
"any.value": unknown;
|
||||
}
|
||||
|
||||
interface ScriptNode {
|
||||
type: string;
|
||||
node: Record<string, unknown> & { defaults?: Record<string, Value> };
|
||||
connections?: Record<
|
||||
string,
|
||||
{ pinIn: string; pinOut: string; target: string }
|
||||
>;
|
||||
}
|
||||
|
||||
interface Script {
|
||||
nodes: Record<string, ScriptNode>;
|
||||
variables?: Record<string, Value>;
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
owner: Object3D;
|
||||
script: Script;
|
||||
variables: Map<string, unknown>;
|
||||
}
|
||||
|
||||
const SELF_ENTITY = "*** Self ***";
|
||||
const MAX_DEPTH = 256;
|
||||
/** events firing events, as a last guard against graphs that loop */
|
||||
const MAX_NESTING = 32;
|
||||
|
||||
const SCENE_LOADED_EVENT = 1;
|
||||
const USER_EVENT = 2;
|
||||
const ANIMATION_END_EVENTS = [3, 8];
|
||||
|
||||
const START_ANIMATION_ACTION = 6;
|
||||
const STOP_ANIMATION_ACTION = 7;
|
||||
const STATE_SWITCH_ACTION = 9;
|
||||
const TRIGGER_EVENT_ACTION = 10;
|
||||
const ANIMATING_ACTIONS = [
|
||||
START_ANIMATION_ACTION,
|
||||
STOP_ANIMATION_ACTION,
|
||||
STATE_SWITCH_ACTION,
|
||||
TRIGGER_EVENT_ACTION,
|
||||
];
|
||||
const ANIMATING_FUNCTIONS = [
|
||||
"StartAnimation",
|
||||
"StopAnimation",
|
||||
"ActivateState",
|
||||
"MotionUpdateSpeed",
|
||||
"MotionResume",
|
||||
"SetRotation",
|
||||
"TriggerCustomEvent",
|
||||
];
|
||||
// the pins graph execution leaves a node through
|
||||
const EXEC_OUTPUTS = ["event", "exit", "true", "false"];
|
||||
|
||||
/** fired once the camo is shown, like entering the garage */
|
||||
export const HANGAR_EVENTS = ["TankAliveInHangar", "TankIsShownInNewScreen"];
|
||||
/** not a game event; replays the garage animations */
|
||||
export const GARAGE_EVENT = "Garage";
|
||||
|
||||
/** events the game fires around battles, never offered as triggers */
|
||||
const IGNORED_EVENTS = [
|
||||
...HANGAR_EVENTS,
|
||||
"onBattleStart",
|
||||
"onBattleRestart",
|
||||
"TankAliveInBattle",
|
||||
"RestartFX",
|
||||
];
|
||||
|
||||
function nameOf(object: Object3D): string {
|
||||
return object.userData.name ?? object.name;
|
||||
}
|
||||
|
||||
function findChild(entity: Object3D, name: string): Object3D | undefined {
|
||||
for (const child of entity.children) {
|
||||
if (nameOf(child) === name) return child;
|
||||
|
||||
const found = findChild(child, name);
|
||||
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTarget(owner: Object3D, entityName: string, part: Object3D) {
|
||||
if (entityName === SELF_ENTITY) return owner;
|
||||
|
||||
const [first, ...rest] = entityName.split("->");
|
||||
let start = findChild(owner, first);
|
||||
|
||||
// targets may also be an ancestor or one of its other descendants
|
||||
for (let ancestor = owner; ancestor !== part && !start; ) {
|
||||
ancestor = ancestor.parent!;
|
||||
start = nameOf(ancestor) === first ? ancestor : findChild(ancestor, first);
|
||||
}
|
||||
|
||||
return rest.reduce<Object3D | undefined>(
|
||||
(current, name) => current && findChild(current, name),
|
||||
start,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays the animations of camo parts. Parts are passed as their roots because
|
||||
* they get rendered away from the loaded scene.
|
||||
*/
|
||||
export function createSkinRuntime(parts: Object3D[], clips: AnimationClip[]) {
|
||||
const mixer = new AnimationMixer(new Object3D());
|
||||
const partOf = new Map<Object3D, Object3D>();
|
||||
const scenarios: Scenario[] = [];
|
||||
const actions = new Map<Object3D, Action[]>();
|
||||
const playing = new Map<AnimationAction, Object3D>();
|
||||
const timers: [time: number, run: () => void, ambient: boolean][] = [];
|
||||
// garage animations are ambient: they don't block the menu and a trigger
|
||||
// stops them, unlike animations a trigger started
|
||||
const ambientActions = new Set<AnimationAction>();
|
||||
let ambient = false;
|
||||
const events = new Set<string>();
|
||||
const internalEvents = new Set<string>();
|
||||
const activeStates = new Map<Object3D, number>();
|
||||
const motionSpeeds = new Map<Object3D, number>();
|
||||
// reset whenever the menu starts a new session (a trigger or a replay)
|
||||
const firedAlarms = new Set<string>();
|
||||
const started = new Set<Object3D>();
|
||||
let nesting = 0;
|
||||
let elapsed = 0;
|
||||
|
||||
for (const part of parts) {
|
||||
part.traverse((object) => partOf.set(object, part));
|
||||
}
|
||||
|
||||
partOf.forEach((_, object) => {
|
||||
const { scenario, actions: objectActions } = object.userData;
|
||||
|
||||
if (objectActions) {
|
||||
actions.set(object, objectActions);
|
||||
|
||||
for (const action of objectActions as Action[]) {
|
||||
if (action["act.event"] === USER_EVENT) {
|
||||
events.add(action["act.userEventId"]);
|
||||
}
|
||||
if (action["act.type"] === TRIGGER_EVENT_ACTION) {
|
||||
internalEvents.add(action["act.eventToTrigger"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scenario === undefined) return;
|
||||
|
||||
const scripts = partOf.get(object)!.userData.scripts as
|
||||
| Record<string, Script>
|
||||
| undefined;
|
||||
const script = scripts?.[scenario];
|
||||
|
||||
if (!script) return;
|
||||
|
||||
scenarios.push({ owner: object, script, variables: new Map() });
|
||||
|
||||
for (const node of Object.values(script.nodes)) {
|
||||
if (node.node["ScenarioEventNode.eventName"] === "Custom") {
|
||||
events.add(node.node.defaults!["Event Name"]["any.value"] as string);
|
||||
}
|
||||
if (
|
||||
node.node["scenarioFunctionNode.functionName"] === "TriggerCustomEvent"
|
||||
) {
|
||||
internalEvents.add(
|
||||
node.node.defaults?.["event name"]?.["any.value"] as string,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
mixer.addEventListener("finished", ({ action }) => {
|
||||
const entity = playing.get(action);
|
||||
|
||||
if (!entity) return;
|
||||
|
||||
playing.delete(action);
|
||||
|
||||
within(ambientActions.has(action), () => {
|
||||
for (const event of ANIMATION_END_EVENTS) fireAction(entity, event);
|
||||
|
||||
for (
|
||||
let ancestor: Object3D | null = entity;
|
||||
ancestor;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
const owned = scenarios.filter(({ owner }) => owner === ancestor);
|
||||
|
||||
if (owned.length === 0) continue;
|
||||
|
||||
for (const scenario of owned) {
|
||||
fireScenario(scenario, "Motion Fragment Stop");
|
||||
fireScenario(scenario, "Motion Sequence Stop");
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function schedule(delay: number, run: () => void) {
|
||||
if (delay <= 0) {
|
||||
run();
|
||||
} else {
|
||||
timers.push([elapsed + delay, run, ambient]);
|
||||
}
|
||||
}
|
||||
|
||||
// runs follow-up work in the session (ambient or triggered) that caused it
|
||||
function within(isAmbient: boolean, run: () => void) {
|
||||
const previous = ambient;
|
||||
|
||||
ambient = isAmbient;
|
||||
try {
|
||||
run();
|
||||
} finally {
|
||||
ambient = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function startAnimation(entity: Object3D, repeats?: number, speed = 1) {
|
||||
const motion = entity.userData.motion as SkinMotion | undefined;
|
||||
|
||||
if (!motion) {
|
||||
for (const child of entity.children) {
|
||||
startAnimation(child, repeats, speed);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const clip = clips.find((clip) => clip.name === motion.clip);
|
||||
|
||||
// some clips restart themselves when they end; once per session keeps
|
||||
// them from looping forever
|
||||
if (!clip || started.has(entity)) return;
|
||||
|
||||
started.add(entity);
|
||||
|
||||
const action = mixer.clipAction(clip, partOf.get(entity));
|
||||
const count = repeats ?? motion.repeats;
|
||||
|
||||
action.reset();
|
||||
// endless loops play once so garage animations settle and the triggers
|
||||
// free up; the menu can replay them
|
||||
action.setLoop(LoopRepeat, count > 0 ? count : 1);
|
||||
action.clampWhenFinished = !motion.rewind;
|
||||
action.timeScale =
|
||||
motion.playbackRate * speed * (motionSpeeds.get(entity) ?? 1);
|
||||
action.play();
|
||||
playing.set(action, entity);
|
||||
|
||||
if (ambient) {
|
||||
ambientActions.add(action);
|
||||
} else {
|
||||
ambientActions.delete(action);
|
||||
}
|
||||
}
|
||||
|
||||
function stopAnimation(entity: Object3D) {
|
||||
for (const [action, owner] of playing) {
|
||||
if (owner !== entity) continue;
|
||||
|
||||
action.stop();
|
||||
playing.delete(action);
|
||||
}
|
||||
}
|
||||
|
||||
// graphs may set the speed before starting the motion
|
||||
function setMotionSpeed(entity: Object3D, speed: number) {
|
||||
motionSpeeds.set(entity, speed);
|
||||
|
||||
for (const [action, owner] of playing) {
|
||||
if (owner !== entity) continue;
|
||||
|
||||
action.timeScale =
|
||||
(owner.userData.motion as SkinMotion).playbackRate * speed;
|
||||
}
|
||||
}
|
||||
|
||||
function resumeMotion(entity: Object3D) {
|
||||
for (const [action, owner] of playing) {
|
||||
if (owner === entity) action.paused = false;
|
||||
}
|
||||
}
|
||||
|
||||
function activateState(entity: Object3D, state: number) {
|
||||
const states = entity.userData.stateNames as string[] | undefined;
|
||||
|
||||
// re-activating the current state is a no-op; graphs rely on this
|
||||
if (!states || activeStates.get(entity) === state) return;
|
||||
|
||||
activeStates.set(entity, state);
|
||||
|
||||
for (const child of entity.children) {
|
||||
const index = states.indexOf(nameOf(child));
|
||||
|
||||
if (index !== -1) child.visible = index === state;
|
||||
}
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
if (scenario.owner === entity) {
|
||||
fireScenario(scenario, "State Activated", state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function runAction(owner: Object3D, action: Action) {
|
||||
const target = resolveTarget(
|
||||
owner,
|
||||
action["act.entityName"],
|
||||
partOf.get(owner)!,
|
||||
);
|
||||
|
||||
if (!target) return;
|
||||
|
||||
schedule(
|
||||
action["act.delay"] + Math.random() * action["act.delayVariation"],
|
||||
() => {
|
||||
switch (action["act.type"]) {
|
||||
case START_ANIMATION_ACTION:
|
||||
startAnimation(
|
||||
target,
|
||||
action["act.stopAfterNRepeats"] > 0
|
||||
? action["act.stopAfterNRepeats"]
|
||||
: undefined,
|
||||
action["act.motionSpeed"] || 1,
|
||||
);
|
||||
break;
|
||||
|
||||
case STOP_ANIMATION_ACTION:
|
||||
stopAnimation(target);
|
||||
break;
|
||||
|
||||
case STATE_SWITCH_ACTION:
|
||||
activateState(target, action["act.switchIndex"]);
|
||||
break;
|
||||
|
||||
case TRIGGER_EVENT_ACTION:
|
||||
trigger(action["act.eventToTrigger"]);
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function fireAction(entity: Object3D, event: number, name?: string) {
|
||||
for (const action of actions.get(entity) ?? []) {
|
||||
if (
|
||||
action["act.event"] === event &&
|
||||
(name === undefined || action["act.userEventId"] === name)
|
||||
) {
|
||||
runAction(entity, action);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fireScenario(scenario: Scenario, event: string, value?: unknown) {
|
||||
if (nesting >= MAX_NESTING) return;
|
||||
|
||||
nesting++;
|
||||
try {
|
||||
runScenario(scenario, event, value);
|
||||
} catch (error) {
|
||||
// an unsupported graph only loses its own animations
|
||||
console.warn(`Skin scenario failed on "${event}"`, error);
|
||||
} finally {
|
||||
nesting--;
|
||||
}
|
||||
}
|
||||
|
||||
function runScenario(scenario: Scenario, event: string, value?: unknown) {
|
||||
const { nodes } = scenario.script;
|
||||
let depth = 0;
|
||||
|
||||
function input(id: string, pin: string): unknown {
|
||||
const node = nodes[id];
|
||||
const connection = Object.values(node.connections ?? {}).find(
|
||||
(connection) => connection.pinIn === pin,
|
||||
);
|
||||
|
||||
return connection
|
||||
? output(connection.target, connection.pinOut)
|
||||
: node.node.defaults?.[pin]?.["any.value"];
|
||||
}
|
||||
|
||||
function output(id: string, pin: string): unknown {
|
||||
const { type, node } = nodes[id];
|
||||
const get = (name: string) => input(id, name);
|
||||
const number = (name: string) => Number(get(name) ?? 0);
|
||||
|
||||
switch (type) {
|
||||
case "ScenarioSceneInfoNode":
|
||||
return node["scenarioSceneInfoNode.infoKind"] === "GetCurrentScene"
|
||||
? partOf.get(scenario.owner)
|
||||
: scenario.owner;
|
||||
|
||||
case "ScenarioGetVarNode":
|
||||
return (
|
||||
scenario.variables.get(
|
||||
node["scenarioGetVarNode.varPath"] as string,
|
||||
) ?? 0
|
||||
);
|
||||
|
||||
case "ScenarioSetVarNode":
|
||||
return (
|
||||
scenario.variables.get(
|
||||
node["scenarioSetVarNode.varPath"] as string,
|
||||
) ?? 0
|
||||
);
|
||||
|
||||
case "ScenarioGetMemberNode": {
|
||||
const object = get("object") as Object3D | number[] | undefined;
|
||||
const field = node["scenarioGetMemberNode.fieldName"];
|
||||
|
||||
if (Array.isArray(object)) return field === "X" ? object[0] : 0;
|
||||
if (!object) return undefined;
|
||||
|
||||
switch (field) {
|
||||
case "stateSwitcherVisibility":
|
||||
return object.visible;
|
||||
case "parent":
|
||||
return object.parent ?? undefined;
|
||||
case "name":
|
||||
return nameOf(object);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
case "ScenarioFunctionNode": {
|
||||
const name = `${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`;
|
||||
const entity = get("entity") as Object3D | undefined;
|
||||
|
||||
switch (name) {
|
||||
case "Entity.FindChildByName":
|
||||
return entity && findChild(entity, get("name") as string);
|
||||
case "Entity.FindParentByName": {
|
||||
const target = get("name") as string;
|
||||
|
||||
for (
|
||||
let ancestor = entity?.parent;
|
||||
ancestor;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
if (nameOf(ancestor) === target) return ancestor;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
case "Entity.GetVisibility":
|
||||
return (get("arg0") as Object3D | undefined)?.visible ?? false;
|
||||
case "Random.Random":
|
||||
return Math.floor(Math.random() * number("n"));
|
||||
case "Random.RandomFloat":
|
||||
return Math.random();
|
||||
case "Vector4.Make":
|
||||
return [number("x"), number("y"), number("z"), number("w")];
|
||||
case "Vector4.Add":
|
||||
case "Vector4.Subtract": {
|
||||
const vector = (value: unknown) =>
|
||||
Array.isArray(value) ? (value as number[]) : [0, 0, 0, 0];
|
||||
const a = vector(get("a"));
|
||||
const b = vector(get("b"));
|
||||
const sign = name === "Vector4.Add" ? 1 : -1;
|
||||
|
||||
return a.map((value, index) => value + sign * b[index]);
|
||||
}
|
||||
case "Quaternion.MakeFastAroundX":
|
||||
case "Quaternion.MakeFastAroundY":
|
||||
return new Quaternion().setFromAxisAngle(
|
||||
name.endsWith("X")
|
||||
? new Vector3(1, 0, 0)
|
||||
: new Vector3(0, 1, 0),
|
||||
degToRad(number("deg")),
|
||||
);
|
||||
case "Quaternion.Multiply":
|
||||
return (get("a") as Quaternion | undefined)
|
||||
?.clone()
|
||||
.multiply(
|
||||
(get("b") as Quaternion | undefined) ?? new Quaternion(),
|
||||
);
|
||||
case "String.Merge":
|
||||
// graphs use Separator as the prefix and Postfix as the number
|
||||
return [get("Suffix"), get("Separator"), get("Postfix")]
|
||||
.map((part) => (part === undefined ? "" : String(part)))
|
||||
.join("");
|
||||
case "String.Compare":
|
||||
return get("1") === get("2");
|
||||
case "bool.Or":
|
||||
return Boolean(get("a")) || Boolean(get("b"));
|
||||
case "TankComponent.GetSpeed":
|
||||
case "TankComponent.GetTurretAngle":
|
||||
case "TankComponent.GetGunAngle":
|
||||
return 0;
|
||||
}
|
||||
|
||||
const operator = node["scenarioFunctionNode.functionName"] as string;
|
||||
const a = number("a");
|
||||
const b = number("b");
|
||||
|
||||
if (operator === "==") return a === b;
|
||||
if (operator === ">") return a > b;
|
||||
if (operator === ">=") return a >= b;
|
||||
if (operator === "<=") return a <= b;
|
||||
if (operator.startsWith("Add")) return a + b;
|
||||
if (operator.startsWith("Subtract")) return a - b;
|
||||
if (operator.startsWith("Multiply")) return a * b;
|
||||
if (operator.startsWith("Divide")) return b === 0 ? 0 : a / b;
|
||||
if (operator.startsWith("Lerp")) return a + (b - a) * number("t");
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function follow(id: string, pin: string) {
|
||||
for (const connection of Object.values(nodes[id].connections ?? {})) {
|
||||
if (connection.pinIn === pin) run(connection.target);
|
||||
}
|
||||
}
|
||||
|
||||
function run(id: string) {
|
||||
if (++depth > MAX_DEPTH) return;
|
||||
|
||||
const { type, node } = nodes[id];
|
||||
const get = (name: string) => input(id, name);
|
||||
|
||||
switch (type) {
|
||||
case "ScenarioBranchNode":
|
||||
follow(id, get("condition") ? "true" : "false");
|
||||
return;
|
||||
|
||||
case "ScenarioSetVarNode":
|
||||
scenario.variables.set(
|
||||
node["scenarioSetVarNode.varPath"] as string,
|
||||
get("set"),
|
||||
);
|
||||
follow(id, "exit");
|
||||
return;
|
||||
|
||||
case "ScenarioSetAlarmClockNode": {
|
||||
const entity =
|
||||
(get("entity") as Object3D | undefined) ?? scenario.owner;
|
||||
const alarm = `${scenarios.indexOf(scenario)}:${id}`;
|
||||
|
||||
// alarms that re-arm themselves would loop forever
|
||||
if (firedAlarms.has(alarm)) {
|
||||
follow(id, "exit");
|
||||
return;
|
||||
}
|
||||
|
||||
firedAlarms.add(alarm);
|
||||
schedule(Number(get("delay") ?? 0), () => {
|
||||
for (const other of scenarios) {
|
||||
if (other.owner === entity) fireScenario(other, "Alarm Clock");
|
||||
}
|
||||
});
|
||||
follow(id, "exit");
|
||||
return;
|
||||
}
|
||||
|
||||
case "ScenarioFunctionNode": {
|
||||
const name = `${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`;
|
||||
const entity = get("entity") as Object3D | undefined;
|
||||
|
||||
switch (name) {
|
||||
case "Entity.StartAnimation":
|
||||
if (entity) {
|
||||
startAnimation(entity, Number(get("repeats") ?? 0));
|
||||
}
|
||||
break;
|
||||
case "Entity.StopAnimation":
|
||||
if (entity) stopAnimation(entity);
|
||||
break;
|
||||
case "Entity.ActivateState":
|
||||
if (entity) activateState(entity, Number(get("state") ?? 0));
|
||||
break;
|
||||
case "Entity.MotionUpdateSpeed":
|
||||
if (entity) setMotionSpeed(entity, Number(get("speed") ?? 1));
|
||||
break;
|
||||
case "Entity.MotionResume":
|
||||
if (entity) resumeMotion(entity);
|
||||
break;
|
||||
case "Entity.SetRotation": {
|
||||
const self = get("self") as Object3D | undefined;
|
||||
const value = get("value");
|
||||
|
||||
if (self && value instanceof Quaternion) {
|
||||
self.quaternion.copy(value);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "Entity.TriggerCustomEvent":
|
||||
trigger(get("event name") as string);
|
||||
break;
|
||||
}
|
||||
|
||||
follow(id, "exit");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, { type, node }] of Object.entries(nodes)) {
|
||||
if (
|
||||
type !== "ScenarioEventNode" ||
|
||||
node["ScenarioEventNode.eventName"] !== event
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaults = node.defaults ?? {};
|
||||
|
||||
if (
|
||||
event === "Custom" &&
|
||||
defaults["Event Name"]?.["any.value"] !== value
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
event === "State Activated" &&
|
||||
Number(defaults.State?.["any.value"] ?? 0) !== value
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
follow(id, "event");
|
||||
}
|
||||
}
|
||||
|
||||
function trigger(name: string) {
|
||||
if (nesting >= MAX_NESTING) return;
|
||||
|
||||
nesting++;
|
||||
try {
|
||||
for (const scenario of scenarios) fireScenario(scenario, "Custom", name);
|
||||
for (const entity of actions.keys()) fireAction(entity, USER_EVENT, name);
|
||||
} finally {
|
||||
nesting--;
|
||||
}
|
||||
}
|
||||
|
||||
// start like the garage: initial states, load events, then hangar events
|
||||
function start() {
|
||||
partOf.forEach((_, object) => {
|
||||
const { stateNames: states, state } = object.userData as {
|
||||
stateNames?: string[];
|
||||
state?: string;
|
||||
};
|
||||
|
||||
if (!states) return;
|
||||
|
||||
// no state name means the switcher starts with nothing active
|
||||
activeStates.set(
|
||||
object,
|
||||
state === undefined ? -1 : states.indexOf(state),
|
||||
);
|
||||
|
||||
for (const child of object.children) {
|
||||
if (states.includes(nameOf(child))) {
|
||||
child.visible = nameOf(child) === state;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
scenario.variables = new Map(
|
||||
Object.entries(scenario.script.variables ?? {}).map(
|
||||
([name, variable]) => [name, variable["any.value"]],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (const entity of actions.keys()) {
|
||||
fireAction(entity, SCENE_LOADED_EVENT);
|
||||
}
|
||||
for (const scenario of scenarios) fireScenario(scenario, "Added");
|
||||
for (const event of HANGAR_EVENTS) trigger(event);
|
||||
}
|
||||
|
||||
within(true, start);
|
||||
|
||||
const hasGarageAnimation = playing.size > 0 || timers.length > 0;
|
||||
|
||||
// whether firing the event can move or switch anything; some events only
|
||||
// drive particles, which would make for buttons that do nothing
|
||||
function animates(event: string) {
|
||||
for (const entityActions of actions.values()) {
|
||||
for (const action of entityActions) {
|
||||
if (
|
||||
action["act.event"] === USER_EVENT &&
|
||||
action["act.userEventId"] === event &&
|
||||
ANIMATING_ACTIONS.includes(action["act.type"])
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scenarios.some(({ script: { nodes } }) => {
|
||||
const visited = new Set<string>();
|
||||
|
||||
function reaches(id: string): boolean {
|
||||
if (visited.has(id)) return false;
|
||||
|
||||
visited.add(id);
|
||||
|
||||
const { type, node } = nodes[id];
|
||||
|
||||
if (
|
||||
type === "ScenarioSetAlarmClockNode" ||
|
||||
ANIMATING_FUNCTIONS.includes(
|
||||
node["scenarioFunctionNode.functionName"] as string,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.values(nodes[id].connections ?? {}).some(
|
||||
(connection) =>
|
||||
EXEC_OUTPUTS.includes(connection.pinIn) &&
|
||||
reaches(connection.target),
|
||||
);
|
||||
}
|
||||
|
||||
return Object.entries(nodes).some(
|
||||
([id, { node }]) =>
|
||||
node["ScenarioEventNode.eventName"] === "Custom" &&
|
||||
node.defaults?.["Event Name"]?.["any.value"] === event &&
|
||||
reaches(id),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
/** events the camo reacts to that can be triggered from the menu */
|
||||
events: [...events].filter(
|
||||
(event) =>
|
||||
!IGNORED_EVENTS.includes(event) &&
|
||||
!internalEvents.has(event) &&
|
||||
animates(event),
|
||||
),
|
||||
|
||||
/** fires an event from the menu, cutting ambient animations short */
|
||||
trigger(name: string) {
|
||||
for (const action of ambientActions) {
|
||||
action.stop();
|
||||
playing.delete(action);
|
||||
}
|
||||
|
||||
ambientActions.clear();
|
||||
|
||||
for (let index = timers.length - 1; index >= 0; index--) {
|
||||
if (timers[index][2]) timers.splice(index, 1);
|
||||
}
|
||||
|
||||
firedAlarms.clear();
|
||||
started.clear();
|
||||
within(false, () => trigger(name));
|
||||
},
|
||||
|
||||
/** whether a triggered animation is playing or about to */
|
||||
busy() {
|
||||
return (
|
||||
[...playing.keys()].some((action) => !ambientActions.has(action)) ||
|
||||
timers.some(([, , isAmbient]) => !isAmbient)
|
||||
);
|
||||
},
|
||||
|
||||
/** whether the camo animates on its own in the garage */
|
||||
hasGarageAnimation,
|
||||
|
||||
/** plays the garage animations again from the start */
|
||||
replay() {
|
||||
mixer.stopAllAction();
|
||||
playing.clear();
|
||||
ambientActions.clear();
|
||||
timers.length = 0;
|
||||
firedAlarms.clear();
|
||||
started.clear();
|
||||
motionSpeeds.clear();
|
||||
within(true, start);
|
||||
},
|
||||
|
||||
/** returns whether anything is still animating */
|
||||
update(delta: number) {
|
||||
elapsed += delta;
|
||||
|
||||
for (let index = timers.length - 1; index >= 0; index--) {
|
||||
if (timers[index][0] > elapsed) continue;
|
||||
|
||||
const [[, run, isAmbient]] = timers.splice(index, 1);
|
||||
|
||||
within(isAmbient, run);
|
||||
}
|
||||
|
||||
mixer.update(delta);
|
||||
|
||||
return playing.size > 0 || timers.length > 0;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
mixer.stopAllAction();
|
||||
for (const part of parts) mixer.uncacheRoot(part);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type SkinRuntime = ReturnType<typeof createSkinRuntime>;
|
||||
@@ -24,17 +24,9 @@ type SkeletonComponent = Extract<
|
||||
type MotionComponent = Extract<
|
||||
Component,
|
||||
{ "comp.typename": "MotionComponent" }
|
||||
> & { "simpleMotion.rewindOnFinish"?: boolean };
|
||||
>;
|
||||
type AccessorType = "SCALAR" | "VEC3" | "VEC4" | "MAT4";
|
||||
|
||||
export interface SkinMotion {
|
||||
clip: string;
|
||||
duration: number;
|
||||
playbackRate: number;
|
||||
repeats: number;
|
||||
rewind: boolean;
|
||||
}
|
||||
|
||||
const JOINT_ROOT = 0xffffff;
|
||||
const MOTION_PATHS = {
|
||||
[MotionChannel.Translation]: "translation",
|
||||
@@ -60,11 +52,9 @@ export async function loadMotion(
|
||||
path: string,
|
||||
component: MotionComponent,
|
||||
) {
|
||||
const file = await vfs
|
||||
.file(normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`))
|
||||
.catch(() => undefined);
|
||||
|
||||
if (!file) return;
|
||||
const file = await vfs.file(
|
||||
normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`),
|
||||
);
|
||||
|
||||
return new MotionReadStream(
|
||||
file.buffer.slice(
|
||||
@@ -153,8 +143,9 @@ export function createMotionClip(
|
||||
joints: Map<string, Node>,
|
||||
motion: Motion,
|
||||
component: MotionComponent,
|
||||
): SkinMotion {
|
||||
) {
|
||||
const animation = document.createAnimation(clip);
|
||||
const playbackRate = component["motion.playbackRate"];
|
||||
|
||||
for (const [uid, track] of motion.tracks) {
|
||||
const joint = joints.get(uid);
|
||||
@@ -165,7 +156,12 @@ export function createMotionClip(
|
||||
const sampler = document
|
||||
.createAnimationSampler()
|
||||
.setInput(
|
||||
createAccessor(document, buffer, "SCALAR", new Float32Array(times)),
|
||||
createAccessor(
|
||||
document,
|
||||
buffer,
|
||||
"SCALAR",
|
||||
new Float32Array(times.map((time) => time / playbackRate)),
|
||||
),
|
||||
)
|
||||
.setOutput(
|
||||
createAccessor(
|
||||
@@ -192,14 +188,6 @@ export function createMotionClip(
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
clip,
|
||||
duration: motion.duration,
|
||||
playbackRate: component["motion.playbackRate"],
|
||||
repeats: component["simpleMotion.repeatsCount"],
|
||||
rewind: component["simpleMotion.rewindOnFinish"] ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -261,9 +249,17 @@ export function skinPrimitive(
|
||||
"VEC4",
|
||||
new Uint16Array(
|
||||
jointIndices.flatMap((indices) =>
|
||||
times(4, (index) =>
|
||||
index < indices.length ? targets[indices[index]] ?? 0 : 0,
|
||||
),
|
||||
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;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,19 +3,50 @@ import type { Material, Object3D } from "three";
|
||||
import type { Skin } from "../../hooks/useSkin";
|
||||
import { jsxTree } from "./jsxTree";
|
||||
|
||||
type Mergers = NonNullable<Parameters<typeof jsxTree>[1]> & {
|
||||
/** renders a customization part as is so its animations play */
|
||||
primitive?: (object: Object3D, key: string) => ReactNode;
|
||||
};
|
||||
type Mergers = Parameters<typeof jsxTree>[1];
|
||||
|
||||
// names get sanitized and made unique on load
|
||||
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; `primitive` renders customization parts as they are so their
|
||||
* bones can animate
|
||||
*/
|
||||
export function skinTree(
|
||||
node: Object3D,
|
||||
skin: Skin | undefined,
|
||||
mergers: Mergers,
|
||||
skins: Skin[],
|
||||
mergers: Mergers & {
|
||||
primitive: (object: Object3D, key: string) => ReactNode;
|
||||
},
|
||||
) {
|
||||
const materials = new Map<string, Material>();
|
||||
const states: Record<string, string | null> = {};
|
||||
|
||||
for (const skin of skins) {
|
||||
for (const [name, material] of skin.materials) {
|
||||
materials.set(name, material);
|
||||
}
|
||||
|
||||
Object.assign(states, skin.scene.userData.states);
|
||||
}
|
||||
|
||||
function switchChildren(object3d: Object3D) {
|
||||
if (object3d.userData.state === undefined) return;
|
||||
|
||||
const name = nameOf(object3d);
|
||||
const state = name in states ? states[name] : object3d.userData.state;
|
||||
|
||||
return object3d.children
|
||||
.filter((child) => nameOf(child) === state)
|
||||
.map((child) => jsxTree(child, skinMergers));
|
||||
}
|
||||
|
||||
const skinMergers: Mergers = {
|
||||
mesh(mesh, props, key) {
|
||||
const material = skin?.materials.get((mesh.material as Material).name);
|
||||
const material = materials.get((mesh.material as Material).name);
|
||||
|
||||
return mergers!.mesh!(
|
||||
mesh,
|
||||
@@ -24,24 +55,12 @@ export function skinTree(
|
||||
);
|
||||
},
|
||||
group(object3d, props, key) {
|
||||
const parts = skin?.parts.get(object3d.name);
|
||||
const isSwitch = object3d.userData.state !== undefined;
|
||||
const state = skin
|
||||
? skin.scene.userData.states[object3d.name]
|
||||
: object3d.userData.state;
|
||||
|
||||
return (
|
||||
<group key={key} {...props}>
|
||||
{isSwitch
|
||||
? object3d.children
|
||||
.filter((child) => child.name === state)
|
||||
.map((child) => jsxTree(child, skinMergers))
|
||||
: props.children}
|
||||
{parts?.map((part) =>
|
||||
mergers.primitive ? (
|
||||
mergers.primitive(part, part.uuid)
|
||||
) : (
|
||||
<primitive key={part.uuid} object={part} />
|
||||
{switchChildren(object3d) ?? props.children}
|
||||
{skins.flatMap((skin) =>
|
||||
(skin.parts.get(object3d.name) ?? []).map((part) =>
|
||||
mergers.primitive(part, part.uuid),
|
||||
),
|
||||
)}
|
||||
</group>
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
import {
|
||||
parseHierarchies,
|
||||
type Hierarchy,
|
||||
type Sc2ReadStream,
|
||||
} from "@blitzkit/core";
|
||||
import { times } from "lodash-es";
|
||||
|
||||
interface Value {
|
||||
"any.type": string;
|
||||
"any.value": unknown;
|
||||
}
|
||||
|
||||
interface ScriptNode {
|
||||
type: string;
|
||||
node: Record<string, unknown> & { defaults?: Record<string, Value> };
|
||||
connections?: Record<
|
||||
string,
|
||||
{ pinIn: string; pinOut: string; target: string }
|
||||
>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
function findChild(entity: Entity, name: string): Entity | undefined {
|
||||
for (const child of entity.children) {
|
||||
if (child.name === name) return child;
|
||||
|
||||
const found = findChild(child, name);
|
||||
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTarget(owner: Entity, entityName: string) {
|
||||
if (entityName === SELF_ENTITY) return owner;
|
||||
|
||||
const [first, ...rest] = entityName.split("->");
|
||||
let target: Entity | undefined;
|
||||
|
||||
for (
|
||||
let ancestor: Entity | undefined = owner;
|
||||
ancestor && !target;
|
||||
ancestor = ancestor.parent
|
||||
) {
|
||||
target = ancestor.name === first ? ancestor : findChild(ancestor, first);
|
||||
}
|
||||
|
||||
for (const name of rest) target = target && findChild(target, name);
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
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;
|
||||
variables: Map<string, unknown>;
|
||||
}[] = [];
|
||||
const actions: { owner: Entity; action: Action }[] = [];
|
||||
const states: Record<string, string | null> = {};
|
||||
const looping = 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) return;
|
||||
|
||||
const previous = states[entity.name];
|
||||
|
||||
states[entity.name] = names[state] ?? null;
|
||||
|
||||
if (!animate || previous === states[entity.name]) 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[child.parent.name] !== 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;
|
||||
}
|
||||
|
||||
// some clips restart themselves when they end
|
||||
if (looping.has(entity)) return;
|
||||
|
||||
looping.add(entity);
|
||||
|
||||
if ((repeats ?? entity.repeats) > 0) {
|
||||
pending.push(() => finishAnimation(entity));
|
||||
}
|
||||
}
|
||||
|
||||
function finishAnimation(entity: Entity) {
|
||||
const statesBefore = { ...states };
|
||||
const shownBefore = [...looping].filter(isShown);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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
|
||||
if (
|
||||
[...looping].some(
|
||||
(other) => isShown(other) && !shownBefore.includes(other),
|
||||
)
|
||||
) {
|
||||
looping.delete(entity);
|
||||
} else {
|
||||
Object.assign(states, statesBefore);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
sc2["#sceneComponents"] as unknown as Record<string, unknown>,
|
||||
) as { "comp.typename"?: string; "ssc.scripts"?: Record<string, Script> }[]) {
|
||||
if (component?.["comp.typename"] !== "ScenarioSingletonComponent") continue;
|
||||
|
||||
Object.assign(scripts, component["ssc.scripts"]);
|
||||
}
|
||||
|
||||
parseHierarchies<Entity>(
|
||||
sc2["#hierarchy"],
|
||||
root,
|
||||
(hierarchy, components, parent) => {
|
||||
const entity: Entity = {
|
||||
name: hierarchy.name,
|
||||
parent,
|
||||
children: [],
|
||||
hierarchy,
|
||||
};
|
||||
|
||||
parent.children.push(entity);
|
||||
|
||||
for (const item of attachments.get(hierarchy.name) ?? []) {
|
||||
entity.children.push({ name: item, parent: entity, children: [] });
|
||||
}
|
||||
|
||||
for (const component of components) {
|
||||
switch (component["comp.typename"]) {
|
||||
case "StateSwitcherComponent": {
|
||||
const names = times(
|
||||
component["ssc.statesCount"],
|
||||
(index) => component[`ssc.state${index}`],
|
||||
);
|
||||
|
||||
stateNames.set(entity, names);
|
||||
states[entity.name] = names[component["ssc.activeState"]] ?? null;
|
||||
break;
|
||||
}
|
||||
|
||||
case "ScenarioComponent": {
|
||||
const script = scripts[component["scenario.scriptId"]];
|
||||
|
||||
if (script) {
|
||||
scenarios.push({
|
||||
owner: entity,
|
||||
script,
|
||||
variables: new Map(
|
||||
Object.entries(script.variables ?? {}).map(
|
||||
([name, variable]) => [name, variable["any.value"]],
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "ActionComponent":
|
||||
times(component["ac.actionCount"], (index) => {
|
||||
actions.push({
|
||||
owner: entity,
|
||||
action: component[index.toString().padStart(4, "0")] as Action,
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
case "MotionComponent":
|
||||
if (animate) {
|
||||
entity.repeats = component["simpleMotion.repeatsCount"];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return entity;
|
||||
},
|
||||
);
|
||||
|
||||
function runScenario(
|
||||
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 ?? {});
|
||||
|
||||
function input(id: string, pin: string): unknown {
|
||||
const link = links(id).find((link) => link.pinIn === pin);
|
||||
|
||||
return link
|
||||
? output(link.target, link.pinOut)
|
||||
: nodes[id].node.defaults?.[pin]?.["any.value"];
|
||||
}
|
||||
|
||||
function output(id: string, pin: string): unknown {
|
||||
const { type, node } = nodes[id];
|
||||
|
||||
switch (type) {
|
||||
case "ScenarioSceneInfoNode":
|
||||
switch (node["scenarioSceneInfoNode.infoKind"]) {
|
||||
case "GetCurrentEntity":
|
||||
return owner;
|
||||
case "GetCurrentScene":
|
||||
return root;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "ScenarioGetVarNode":
|
||||
return variables.get(node["scenarioGetVarNode.varPath"] as string);
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function execute(id: string, depth: number) {
|
||||
if (depth > MAX_DEPTH) return;
|
||||
|
||||
const { type, node } = nodes[id];
|
||||
let pin = "exit";
|
||||
|
||||
if (type === "ScenarioEventNode") {
|
||||
pin = "event";
|
||||
} else if (type === "ScenarioBranchNode") {
|
||||
pin = input(id, "condition") ? "true" : "false";
|
||||
} 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)) {
|
||||
if (link.pinIn === pin) execute(link.target, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id in nodes) {
|
||||
const { type, node } = nodes[id];
|
||||
const name =
|
||||
node["ScenarioEventNode.eventName"] === "Custom"
|
||||
? node.defaults?.["Event Name"]?.["any.value"]
|
||||
: node["ScenarioEventNode.eventName"];
|
||||
|
||||
if (
|
||||
type === "ScenarioEventNode" &&
|
||||
name === event &&
|
||||
(event !== "State Activated" ||
|
||||
Number(node.defaults?.State?.["any.value"] ?? 0) === value)
|
||||
) {
|
||||
execute(id, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fireEvent(event: string) {
|
||||
if (fired.has(event)) return;
|
||||
|
||||
fired.add(event);
|
||||
|
||||
for (const { owner, action } of actions) {
|
||||
if (
|
||||
(animate || action["act.delay"] === 0) &&
|
||||
(event === LOAD_EVENTS[0]
|
||||
? action["act.event"] === SCENE_LOADED_EVENT
|
||||
: action["act.event"] === USER_EVENT &&
|
||||
action["act.userEventId"] === event)
|
||||
) {
|
||||
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,
|
||||
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[]>,
|
||||
) {
|
||||
return replay(sc2, preset, attachments, false).states;
|
||||
}
|
||||
|
||||
/** where a customization part ends up: its states and the motions left looping */
|
||||
export function partGarage(
|
||||
sc2: ReturnType<Sc2ReadStream["sc2"]>,
|
||||
preset: string | undefined,
|
||||
) {
|
||||
return replay(sc2, preset, new Map(), true);
|
||||
}
|
||||
@@ -7,12 +7,23 @@ import {
|
||||
import type { DuelMember } from "../../stores/duel";
|
||||
import { genericDefaultEquipmentMatrix } from "../../stores/duel/constants";
|
||||
|
||||
export function defaultTankWeapon(tank: TankDefinition) {
|
||||
for (const turret of [...tank.turrets].reverse()) {
|
||||
for (const gun of [...turret.guns].reverse()) {
|
||||
const shell = gun.shells[0];
|
||||
|
||||
if (shell) return { turret, gun, shell };
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Tank ${tank.id} has no gun with shells`);
|
||||
}
|
||||
|
||||
export function tankToDuelMember(
|
||||
tank: TankDefinition,
|
||||
provisionDefinitions: ProvisionDefinitions,
|
||||
) {
|
||||
const turret = tank.turrets.at(-1)!;
|
||||
const gun = turret.guns.at(-1)!;
|
||||
const { turret, gun, shell } = defaultTankWeapon(tank);
|
||||
|
||||
return {
|
||||
camouflage: true,
|
||||
@@ -22,7 +33,7 @@ export function tankToDuelMember(
|
||||
provisions: createDefaultProvisions(tank, gun, provisionDefinitions),
|
||||
engine: tank.engines.at(-1)!,
|
||||
gun,
|
||||
shell: tank.turrets.at(-1)!.guns.at(-1)!.shells[0],
|
||||
shell,
|
||||
tank,
|
||||
track: tank.tracks.at(-1)!,
|
||||
turret,
|
||||
|
||||
Reference in new issue
Block a user