diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json
index c55989b8f..2913ea96f 100644
--- a/packages/i18n/strings/en.json
+++ b/packages/i18n/strings/en.json
@@ -461,6 +461,18 @@
"sandbox": {
"load_model": "Load model",
+ "skin_events": {
+ "garage": "Idle",
+ "shoot": "Shoot",
+ "kill": "Kill",
+ "damage": "Take damage",
+ "damage_enemy": "Damage enemy",
+ "no_pierce": "Ricochet / No penetration",
+ "move": "Start moving",
+ "consumable": "Consumable",
+ "reload": "Reload"
+ },
+
"poses": {
"hull_down": "Hull down",
"face_hug": "Face hug",
diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinAnimationSwitcher.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinAnimationSwitcher.tsx
new file mode 100644
index 000000000..7400dd82c
--- /dev/null
+++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinAnimationSwitcher.tsx
@@ -0,0 +1,131 @@
+import { alias } from "@blitzkit/core";
+import type { BlitzKitStrings } from "@blitzkit/i18n";
+import { Flex, Tooltip } from "@radix-ui/themes";
+import { GARAGE_EVENT } from "../../../../../../core/blitzkit/skinAnimations/runtime";
+import { useLocale } from "../../../../../../hooks/useLocale";
+import { skinAnimationEvent } from "../../../../../../hooks/useSkinAnimation";
+import { Tankopedia } from "../../../../../../stores/tankopedia";
+import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
+import { SidebarIconButton } from "./SidebarIconButton";
+
+type SkinEventGroup =
+ keyof BlitzKitStrings["website"]["tools"]["tankopedia"]["sandbox"]["skin_events"];
+
+// the camo column is one button wide
+const SKIN_COLUMN_OFFSET = {
+ initial: "calc(var(--space-3) + 40px + var(--space-2))",
+ sm: "calc(var(--space-3) + 48px + var(--space-2))",
+};
+
+// icons are served from /icons/skin-events/[group].webp
+const SKIN_EVENT_GROUPS: {
+ group: SkinEventGroup;
+ events: string[];
+}[] = [
+ { group: "garage", events: [GARAGE_EVENT] },
+ {
+ group: "shoot",
+ events: ["OnShoot", "OnShootFirstInBurst", "OnShootFirstInBurstSniper"],
+ },
+ {
+ group: "kill",
+ events: [
+ "KillEnemy",
+ "KillEnemyAmmoBayDestroyed",
+ "KillEnemyRam",
+ "OnKillEnemyAmmoBayDestroyed",
+ "OnKillEnemyRam",
+ ],
+ },
+ { group: "damage", events: ["PlayerHasDamageFromEnemy"] },
+ { group: "damage_enemy", events: ["AttackerDamagedEnemy"] },
+ // camos play the same animation for both
+ {
+ group: "no_pierce",
+ events: ["AttackerDidNotPierceEnemy", "AttackerRichochetEmeny"],
+ },
+ { group: "move", events: ["OnStartMovement"] },
+ {
+ group: "consumable",
+ events: [
+ "ConsumableUsedAfterburning",
+ "ConsumableUsedFreeAfterburning",
+ "ConsumableUsedAbilityAfterburning",
+ "ConsumableUsedImprovedAfterburning",
+ ],
+ },
+ { group: "reload", events: ["ReloadStarted"] },
+];
+
+export function SkinAnimationSwitcher() {
+ const { strings } = useLocale();
+ const skinEvents = Tankopedia.use((state) => state.skinEvents);
+ const disturbed = Tankopedia.use((state) => state.disturbed);
+ const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
+ // one animation at a time; overlapping triggers look broken
+ const disabled = Tankopedia.use((state) => state.skinAnimating);
+ const groups = SKIN_EVENT_GROUPS.flatMap(({ events, ...group }) => {
+ // the first event the camo listens to stands in for its group
+ const event = events.find((event) => skinEvents.includes(event));
+
+ return event ? [{ ...group, event }] : [];
+ });
+ // replaying the garage animation sits apart from the battle triggers
+ const sections = [
+ groups.filter(({ group }) => group === "garage"),
+ groups.filter(({ group }) => group !== "garage"),
+ ].filter((section) => section.length > 0);
+ const visible =
+ disturbed &&
+ requestedDisplay === TankopediaDisplay.Model &&
+ groups.length > 0;
+
+ return (
+
+ {sections.map((section) => (
+
+ {section.map(({ group, event }, index) => {
+ const name =
+ strings.website.tools.tankopedia.sandbox.skin_events[group];
+ const top = index === 0 ? "var(--radius-full)" : "0";
+ const bottom =
+ index === section.length - 1 ? "var(--radius-full)" : "0";
+
+ return (
+
+ skinAnimationEvent.dispatch(event)}
+ />
+
+ );
+ })}
+
+ ))}
+
+ );
+}
diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx
index 725ec6803..d36d61023 100644
--- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx
+++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx
@@ -50,6 +50,7 @@ import {
SidebarIconButton,
SidebarIconGroup,
} from "./components/SidebarIconButton";
+import { SkinAnimationSwitcher } from "./components/SkinAnimationSwitcher";
import { SkinSwitcher } from "./components/SkinSwitcher";
import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher";
import { Thicknesses } from "./components/Thicknesses";
@@ -93,6 +94,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
+
@@ -107,6 +109,15 @@ export function TankModel() {
/>
);
},
+ primitive(object, key) {
+ return (
+
+ );
+ },
});
})}
@@ -184,6 +195,15 @@ export function TankModel() {
/>
);
},
+ primitive(object, key) {
+ return (
+
+ );
+ },
});
})}
@@ -265,6 +285,15 @@ export function TankModel() {
/>
);
},
+ primitive(object, key) {
+ return (
+
+ );
+ },
});
})}
diff --git a/packages/website/src/core/blitzkit/skinAnimations/extras.ts b/packages/website/src/core/blitzkit/skinAnimations/extras.ts
new file mode 100644
index 000000000..ba5d8725b
--- /dev/null
+++ b/packages/website/src/core/blitzkit/skinAnimations/extras.ts
@@ -0,0 +1,18 @@
+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,
+ ),
+ );
+}
diff --git a/packages/website/src/core/blitzkit/skinAnimations/motion.ts b/packages/website/src/core/blitzkit/skinAnimations/motion.ts
new file mode 100644
index 000000000..71b2afab3
--- /dev/null
+++ b/packages/website/src/core/blitzkit/skinAnimations/motion.ts
@@ -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>;
+}
+
+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();
+
+ 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;
+ }
+}
diff --git a/packages/website/src/core/blitzkit/skinAnimations/runtime.ts b/packages/website/src/core/blitzkit/skinAnimations/runtime.ts
new file mode 100644
index 000000000..4cdc990d6
--- /dev/null
+++ b/packages/website/src/core/blitzkit/skinAnimations/runtime.ts
@@ -0,0 +1,834 @@
+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 & { defaults?: Record };
+ connections?: Record<
+ string,
+ { pinIn: string; pinOut: string; target: string }
+ >;
+}
+
+interface Script {
+ nodes: Record;
+ variables?: Record;
+}
+
+interface Scenario {
+ owner: Object3D;
+ script: Script;
+ variables: Map;
+}
+
+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(
+ (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();
+ const scenarios: Scenario[] = [];
+ const actions = new Map();
+ const playing = new Map();
+ 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();
+ let ambient = false;
+ const events = new Set();
+ const internalEvents = new Set();
+ const activeStates = new Map();
+ const motionSpeeds = new Map();
+ // reset whenever the menu starts a new session (a trigger or a replay)
+ const firedAlarms = new Set();
+ const started = new Set();
+ 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
+ | 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();
+
+ 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;
diff --git a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts
new file mode 100644
index 000000000..83cd377d3
--- /dev/null
+++ b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts
@@ -0,0 +1,290 @@
+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" }
+> & { "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",
+ [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"]}`))
+ .catch(() => undefined);
+
+ if (!file) return;
+
+ 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,
+ motion: Motion,
+ component: MotionComponent,
+): SkinMotion {
+ const animation = document.createAnimation(clip);
+
+ for (const [uid, track] of motion.tracks) {
+ const joint = joints.get(uid);
+
+ if (!joint) continue;
+
+ for (const [type, { times, values }] of track) {
+ const sampler = document
+ .createAnimationSampler()
+ .setInput(
+ createAccessor(document, buffer, "SCALAR", new Float32Array(times)),
+ )
+ .setOutput(
+ createAccessor(
+ document,
+ buffer,
+ type === MotionChannel.Rotation ? "VEC4" : "VEC3",
+ new Float32Array(
+ type === MotionChannel.Scale
+ ? values.flatMap((value) => [value, value, value])
+ : values,
+ ),
+ ),
+ )
+ .setInterpolation("LINEAR");
+
+ animation
+ .addSampler(sampler)
+ .addChannel(
+ document
+ .createAnimationChannel()
+ .setTargetNode(joint)
+ .setTargetPath(MOTION_PATHS[type])
+ .setSampler(sampler),
+ );
+ }
+ }
+
+ return {
+ clip,
+ duration: motion.duration,
+ playbackRate: component["motion.playbackRate"],
+ repeats: component["simpleMotion.repeatsCount"],
+ rewind: component["simpleMotion.rewindOnFinish"] ?? false,
+ };
+}
+
+/**
+ * Makes a socket follow a joint. Socket content is authored in the owner's
+ * space, so it follows the joint's movement away from its bind pose.
+ */
+export function bindSocket(socket: Node, joint: Node, skin: Skin) {
+ const index = skin.listJoints().indexOf(joint);
+ const inverseBind = skin
+ .getInverseBindMatrices()!
+ .getElement(index, []) as number[];
+
+ joint.addChild(socket);
+ socket.setMatrix(
+ new Matrix4()
+ .fromArray(inverseBind)
+ .multiply(new Matrix4().fromArray(socket.getMatrix()))
+ .toArray() as mat4,
+ );
+ socket.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,
+ 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) =>
+ index < indices.length ? targets[indices[index]] ?? 0 : 0,
+ ),
+ ),
+ ),
+ ),
+ )
+ .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;
+}
diff --git a/packages/website/src/core/blitzkit/skinTree.tsx b/packages/website/src/core/blitzkit/skinTree.tsx
index f22336518..ad989de63 100644
--- a/packages/website/src/core/blitzkit/skinTree.tsx
+++ b/packages/website/src/core/blitzkit/skinTree.tsx
@@ -1,17 +1,18 @@
+import type { ReactNode } from "react";
import type { Material, Object3D } from "three";
import type { Skin } from "../../hooks/useSkin";
import { jsxTree } from "./jsxTree";
-type Mergers = Parameters[1];
+type Mergers = NonNullable[1]> & {
+ /** renders a customization part as is so its animations play */
+ primitive?: (object: Object3D, key: string) => ReactNode;
+};
export function skinTree(
node: Object3D,
skin: Skin | undefined,
mergers: Mergers,
) {
- const skinNodes = new Map(
- skin?.scene.children.map((node) => [node.name, node]),
- );
const skinMergers: Mergers = {
mesh(mesh, props, key) {
const material = skin?.materials.get((mesh.material as Material).name);
@@ -23,7 +24,7 @@ export function skinTree(
);
},
group(object3d, props, key) {
- const skinNode = skinNodes.get(object3d.name);
+ const parts = skin?.parts.get(object3d.name);
const isSwitch = object3d.userData.state !== undefined;
const state = skin
? skin.scene.userData.states[object3d.name]
@@ -36,7 +37,13 @@ export function skinTree(
.filter((child) => child.name === state)
.map((child) => jsxTree(child, skinMergers))
: props.children}
- {skinNode?.children.map((child) => jsxTree(child, mergers))}
+ {parts?.map((part) =>
+ mergers.primitive ? (
+ mergers.primitive(part, part.uuid)
+ ) : (
+
+ ),
+ )}
);
},
diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts
index 4fc69e7c5..43a377fc5 100644
--- a/packages/website/src/hooks/useSkin.ts
+++ b/packages/website/src/hooks/useSkin.ts
@@ -1,6 +1,13 @@
import { alias } from "@blitzkit/core";
import { useEffect, useRef, useState } from "react";
-import type { Group, Material } from "three";
+import {
+ Mesh,
+ SkinnedMesh,
+ type AnimationClip,
+ 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 { Tankopedia } from "../stores/tankopedia";
@@ -8,6 +15,9 @@ import { Tankopedia } from "../stores/tankopedia";
export interface Skin {
scene: Group;
materials: Map;
+ animations: AnimationClip[];
+ /** customization parts by the tank node they attach to */
+ parts: Map;
}
const skins = new Map>();
@@ -19,8 +29,26 @@ async function loadSkin(path: string) {
const skin = {
scene: gltf.scene,
materials: new Map(materials.map((material) => [material.name, material])),
+ animations: gltf.animations,
+ // captured now because rendering moves the parts out of the scene
+ parts: new Map(
+ gltf.scene.children.map((target) => [target.name, [...target.children]]),
+ ),
};
+ for (const parts of skin.parts.values()) {
+ for (const part of parts) {
+ part.traverse((child) => {
+ if (!(child instanceof Mesh)) return;
+
+ child.castShadow = true;
+ child.receiveShadow = true;
+ // bounds are from the bind pose
+ if (child instanceof SkinnedMesh) child.frustumCulled = false;
+ });
+ }
+ }
+
loadedSkins.set(path, skin);
return skin;
diff --git a/packages/website/src/hooks/useSkinAnimation.ts b/packages/website/src/hooks/useSkinAnimation.ts
new file mode 100644
index 000000000..de05cd7bd
--- /dev/null
+++ b/packages/website/src/hooks/useSkinAnimation.ts
@@ -0,0 +1,85 @@
+import { invalidate, useFrame } from "@react-three/fiber";
+import { Quicklime, type QuicklimeEvent } from "quicklime";
+import { useEffect, useRef } from "react";
+import {
+ createSkinRuntime,
+ GARAGE_EVENT,
+ type SkinRuntime,
+} from "../core/blitzkit/skinAnimations/runtime";
+import { Tankopedia } from "../stores/tankopedia";
+import type { Skin } from "./useSkin";
+
+// demand rendering can leave long gaps between frames
+const MAX_DELTA = 0.1;
+
+export const skinAnimationEvent = new Quicklime();
+
+export function useSkinAnimation(skin?: Skin) {
+ const runtime = useRef(undefined);
+ const active = useRef(false);
+
+ useEffect(() => {
+ if (!skin) return;
+
+ let created: SkinRuntime;
+
+ try {
+ created = createSkinRuntime(
+ [...skin.parts.values()].flat(),
+ skin.animations,
+ );
+ } catch (error) {
+ // the camo still shows, just without animations
+ console.warn("Skin animations failed to start", error);
+ return;
+ }
+
+ function handleSkinAnimation(event: QuicklimeEvent) {
+ if (event.data === GARAGE_EVENT) {
+ created.replay();
+ } else {
+ created.trigger(event.data);
+ }
+
+ active.current = true;
+ invalidate();
+ }
+
+ runtime.current = created;
+ active.current = true;
+ skinAnimationEvent.on(handleSkinAnimation);
+ Tankopedia.mutate((draft) => {
+ draft.skinEvents = created.hasGarageAnimation
+ ? [GARAGE_EVENT, ...created.events]
+ : created.events;
+ });
+ invalidate();
+
+ return () => {
+ skinAnimationEvent.off(handleSkinAnimation);
+ created.dispose();
+ runtime.current = undefined;
+ Tankopedia.mutate((draft) => {
+ draft.skinEvents = [];
+ draft.skinAnimating = false;
+ });
+ };
+ }, [skin]);
+
+ useFrame((_, delta) => {
+ if (!runtime.current || !active.current) return;
+
+ active.current = runtime.current.update(Math.min(delta, MAX_DELTA));
+
+ const busy = runtime.current.busy();
+
+ // only on change so the store doesn't update every frame
+ if (busy !== Tankopedia.state.skinAnimating) {
+ Tankopedia.mutate((draft) => {
+ draft.skinAnimating = busy;
+ });
+ }
+
+ if (active.current) invalidate();
+ });
+}
diff --git a/packages/website/src/pages/[...chunk_api]/icons/skin-events/[event].webp.ts b/packages/website/src/pages/[...chunk_api]/icons/skin-events/[event].webp.ts
new file mode 100644
index 000000000..cb16d9c58
--- /dev/null
+++ b/packages/website/src/pages/[...chunk_api]/icons/skin-events/[event].webp.ts
@@ -0,0 +1,67 @@
+import type { APIContext } from "astro";
+import sharp from "sharp";
+import { extractPackedIcon } from "../../../../core/blitz/extractPackedIcon";
+import { parsePackedSpriteRect } from "../../../../core/blitz/parsePackedSpriteRect";
+import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths";
+import { vfs } from "../../../../core/blitzkit/vfs";
+import { getStaticPaths as _getStaticPaths } from "../../_index";
+
+interface Icon {
+ path: string;
+ /** sprite inside a texture0 atlas described by a .txt file */
+ atlas: boolean;
+}
+
+// white game icons for the camo animation triggers
+const ICONS: Record = {
+ garage: { path: "Lobby/icons/platoon/icon_hangar_m", atlas: false },
+ shoot: { path: "UI/BattleScreenHUD/icon_aim-battle_m", atlas: true },
+ kill: {
+ path: "UI/BattleScreenHUD/icon_battle-chat_kill-type_default",
+ atlas: true,
+ },
+ damage: { path: "UI/BattleScreenHUD/icon_damage_shadow_m", atlas: true },
+ damage_enemy: {
+ path: "UI/BattleScreenHUD/icon_attack-battle_m",
+ atlas: true,
+ },
+ no_pierce: {
+ path: "UI/BattleScreenHUD/icon_shooting-fb_shield_m",
+ atlas: true,
+ },
+ move: { path: "Lobby/icons/icon_speed_s", atlas: false },
+ consumable: { path: "Lobby/icons/icon_consumable_l", atlas: false },
+ reload: { path: "Lobby/icons/icon_rate-of-fire_s", atlas: false },
+};
+
+export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () =>
+ Object.keys(ICONS).map((event) => ({ params: { event }, props: { event } })),
+);
+
+export async function GET({ props }: APIContext<{ event: string }>) {
+ const { path, atlas } = ICONS[props.event];
+ let buffer: Buffer;
+
+ if (atlas) {
+ const directory = path.split("/").slice(0, -1).join("/");
+ const sizes = (await vfs.text(`Data/Gfx/${path}@2x.txt`))
+ .split("\n")[4]
+ .trim()
+ .split(" ")
+ .map(Number);
+
+ buffer = await extractPackedIcon(
+ sharp(await vfs.file(`Data/Gfx/${directory}/texture0@2x.packed.webp`)),
+ sizes.slice(0, 4),
+ );
+ } else {
+ const packedBuffer = await vfs.file(`Data/Gfx/${path}@2x.packed.webp`);
+ const sizes = parsePackedSpriteRect(packedBuffer);
+
+ buffer = sizes
+ ? await extractPackedIcon(sharp(packedBuffer), sizes)
+ : packedBuffer;
+ }
+
+ return new Response(new Uint8Array(await sharp(buffer).webp().toBuffer()));
+}
diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts
index faf26b002..1ed66dc07 100644
--- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts
+++ b/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts
@@ -8,18 +8,38 @@ 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 {
+ addExtras,
+ toExtras,
+} from "../../../../core/blitzkit/skinAnimations/extras";
+import type { Motion } from "../../../../core/blitzkit/skinAnimations/motion";
+import {
+ bindSocket,
+ createMotionClip,
+ createSkeleton,
+ loadMotion,
+ skinPrimitive,
+} from "../../../../core/blitzkit/skinAnimations/skeleton";
import { vfs } from "../../../../core/blitzkit/vfs";
import {
vertexAttributeGLTFName,
@@ -86,6 +106,42 @@ export async function extractModel(
const scene = document.createScene();
const buffer = document.createBuffer();
const materials = await extractMaterials(document, sc2, path);
+ const skeletons = new Map>();
+ const jointBindings: [
+ owner: Node,
+ entity: string,
+ joint: Node,
+ skin: Skin,
+ ][] = [];
+ const motions = new Map();
+
+ if (isPart) {
+ const loading: Promise[] = [];
+
+ 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) => {
+ if (motion) motions.set(hierarchy, motion);
+ }),
+ );
+ }
+
+ return null;
+ });
+
+ await Promise.all(loading);
+
+ for (const component of Object.values(
+ (sc2["#sceneComponents"] ?? {}) as Record>,
+ )) {
+ if (component["comp.typename"] === "ScenarioSingletonComponent") {
+ scene.setExtras({ scripts: toExtras(component["ssc.scripts"]) });
+ }
+ }
+ }
parseHierarchies(
sc2["#hierarchy"],
@@ -99,6 +155,41 @@ 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 = motionComponent ? motions.get(hierarchy) : undefined;
+
+ if (skeleton) {
+ const created = createSkeleton(
+ document,
+ buffer,
+ node,
+ skeleton,
+ motion,
+ );
+
+ skeletons.set(hierarchy, created);
+
+ if (motion && motionComponent) {
+ addExtras(node, {
+ motion: createMotionClip(
+ document,
+ buffer,
+ `motion${document.getRoot().listAnimations().length}`,
+ created.joints,
+ motion,
+ motionComponent,
+ ),
+ });
+ }
+ }
+ }
+
components.forEach((component) => {
switch (component["comp.typename"]) {
case "LodComponent":
@@ -121,18 +212,70 @@ export async function extractModel(
}
case "StateSwitcherComponent":
- node.setExtras({
+ addExtras(node, {
state: component[`ssc.state${component["ssc.activeState"]}`],
});
+
+ if (isPart) {
+ addExtras(node, {
+ stateNames: times(
+ component["ssc.statesCount"],
+ (index) => component[`ssc.state${index}`],
+ ),
+ });
+ }
break;
+ case "ActionComponent":
+ if (!isPart) break;
+
+ addExtras(node, {
+ actions: toExtras(
+ times(
+ component["ac.actionCount"],
+ (index) => component[index.toString().padStart(4, "0")],
+ ),
+ ),
+ });
+ break;
+
+ case "ScenarioComponent":
+ if (!isPart) break;
+
+ addExtras(node, { scenario: component["scenario.scriptId"] });
+ 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) {
+ jointBindings.push([
+ node,
+ binding.entityUid,
+ joint,
+ skeleton.skin,
+ ]);
+ }
+ });
+ break;
+ }
+
case "RenderComponent": {
const renderObject = component["rc.renderObj"];
times(renderObject["ro.batchCount"], (batchIndex): void => {
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
- if (lodIndex !== 0) return;
+ // -1 means the batch has no lods and is always drawn
+ if (lodIndex !== 0 && !(isPart && lodIndex === -1)) return;
const batchKey = batchIndex.toString().padStart(4, "0");
const batch = renderObject["ro.batches"][batchKey];
@@ -193,10 +336,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);
});
@@ -219,6 +376,12 @@ export async function extractModel(
},
);
+ for (const [owner, entity, joint, skin] of jointBindings) {
+ const socket = findNode(owner, entity);
+
+ if (socket) bindSocket(socket, joint, skin);
+ }
+
scene.addChild(document.createNode("test"));
await document.transform(prune({ keepAttributes: true }), dedup());
@@ -226,6 +389,16 @@ export async function extractModel(
return document;
}
+function findNode(node: Node, name: string): Node | undefined {
+ for (const child of node.listChildren()) {
+ if (child.getName() === name) return child;
+
+ const found = findNode(child, name);
+
+ if (found) return found;
+ }
+}
+
export async function extractMaterials(
document: Document,
sc2: ReturnType,
diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts b/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts
index bee9b21f9..993dc504b 100644
--- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts
+++ b/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts
@@ -6,6 +6,7 @@ import {
type TankParameters,
} from "@blitzkit/core";
import {
+ Animation,
Document,
Node,
NodeIO,
@@ -19,6 +20,7 @@ import type { Vector3Tuple, Vector4Tuple } from "three";
import { SKIN_NUMBER } from "../../../../../constants/skinNumber";
import { api } from "../../../../../core/blitzkit/api";
import { mixStaticPaths } from "../../../../../core/blitzkit/mixStaticPaths";
+import type { SkinMotion } from "../../../../../core/blitzkit/skinAnimations/skeleton";
import { vfs } from "../../../../../core/blitzkit/vfs";
import { getStaticPaths as _getStaticPaths } from "../../../_index";
import { extractMaterials, extractModel } from "../model.glb";
@@ -166,7 +168,26 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
.createNode(name)
.setTranslation(slot.position)
.setRotation(slot.rotation)
- .setScale(slot.scale);
+ .setScale(slot.scale)
+ .setExtras(partScene.getExtras());
+
+ // clip names repeat across parts
+ for (const animation of part.getRoot().listAnimations()) {
+ (map.get(animation) as Animation).setName(
+ `${name}:${animation.getName()}`,
+ );
+ }
+
+ partScene.traverse((child) => {
+ const { motion } = child.getExtras() as { motion?: SkinMotion };
+
+ if (motion) {
+ child.setExtras({
+ ...child.getExtras(),
+ motion: { ...motion, clip: `${name}:${motion.clip}` },
+ });
+ }
+ });
let target = targets.get(slot.target);
if (!target) {
diff --git a/packages/website/src/stores/tankopedia.ts b/packages/website/src/stores/tankopedia.ts
index 6685a419e..979199b81 100644
--- a/packages/website/src/stores/tankopedia.ts
+++ b/packages/website/src/stores/tankopedia.ts
@@ -104,6 +104,10 @@ interface Tankopedia {
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
customShell?: ShellDefinition;
skin?: number;
+ /** events the selected camo's animations can be triggered with */
+ skinEvents: string[];
+ /** a triggered camo animation is still playing */
+ skinAnimating: boolean;
requestedDisplay: TankopediaDisplay;
display: TankopediaDisplay;
statSearch?: string;
@@ -120,6 +124,8 @@ export const Tankopedia = new Varuna((model) => ({
skills: createDefaultSkills(skillDefinitions),
model,
xpMultiplier: 1,
+ skinEvents: [],
+ skinAnimating: false,
requestedDisplay: TankopediaDisplay.Model,
display: TankopediaDisplay.Model,
}));