From 85e03d3b42e6c1b299a200f8def1a01cb1804196 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Tue, 20 Oct 2026 17:20:57 +1300 Subject: [PATCH 1/6] Feature: Legendary camouflages in the Tankopedia viewer --- .../api/server/2_camouflageDefinitions.ts | 80 ++++- .../src/protos/camouflage_definitions.proto | 1 + .../core/src/protos/camouflage_definitions.ts | 19 +- packages/core/src/types/camouflagesYaml.ts | 2 + .../Options/components/SidebarIconButton.tsx | 44 +++ .../Options/components/SkinSwitcher.tsx | 81 +++++ .../HeroSection/components/Options/index.tsx | 41 +-- .../components/TankSandbox/Title/index.tsx | 20 +- .../TankSandbox/components/Lighting.tsx | 43 ++- .../TankSandbox/components/TankModel.tsx | 11 +- packages/website/src/constants/skinNumber.ts | 1 + .../website/src/core/blitzkit/skinTree.tsx | 46 +++ packages/website/src/hooks/useSkin.ts | 85 +++++ .../icons/camouflages/[id].webp.ts | 53 +++ .../[...chunk_api]/tanks/[id]/model.glb.ts | 307 ++++++++++-------- .../tanks/[id]/skins/[skin].glb.ts | 234 +++++++++++++ packages/website/src/stores/tankopedia.ts | 1 + 17 files changed, 897 insertions(+), 172 deletions(-) create mode 100644 packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SidebarIconButton.tsx create mode 100644 packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx create mode 100644 packages/website/src/constants/skinNumber.ts create mode 100644 packages/website/src/core/blitzkit/skinTree.tsx create mode 100644 packages/website/src/hooks/useSkin.ts create mode 100644 packages/website/src/pages/[...chunk_api]/icons/camouflages/[id].webp.ts create mode 100644 packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts diff --git a/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts b/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts index 30ce31d6a..f78c781cd 100644 --- a/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts @@ -1,11 +1,52 @@ -import { CamouflageDefinitions } from "@blitzkit/core"; +import { + CamouflageDefinitions, + Sc2ReadStream, + type TankParameters, +} from "@blitzkit/core"; +import { isEqual, times } from "lodash-es"; import { Cache } from "./0_base"; import { ServerBlitzKitAPI1 } from "./1_tankDefinitions"; export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 { + private async skinPresets(vehicle: string) { + const [nation, tankKey] = vehicle.split(":"); + const parameters = await this.vfs.yaml( + `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, + ); + const sc2 = new Sc2ReadStream( + ( + await this.vfs.file(`Data/3d/${parameters.resourcesPath.blitzModelPath}`) + ).buffer as ArrayBuffer, + ).sc2(); + const presets = new Set(); + + for (const node of sc2["#dataNodes"]) { + if (typeof node.configCount !== "number") continue; + + times(node.configCount, (index) => node[`configArchive_${index}`]) + .filter( + (archive) => + !isEqual(archive.textures, node.configArchive_0.textures), + ) + .forEach((archive) => presets.add(archive.configName)); + } + + return presets; + } + @Cache() async camouflageDefinitions() { const camouflageDefinitions = CamouflageDefinitions.create(); + const customization = await this.vfs.yaml< + Record + >("Data/3d/Customization.yaml"); + const items = new Map( + Object.values(customization).map(({ Name, Path }) => [ + Name, + Path.replace("~res:/", "Data/").replace(/\.sc2$/, ""), + ]), + ); + const skinPresets = new Map>>(); for (const camoKey in this.camouflagesXml!.root.camouflages) { const camo = this.camouflagesXml!.root.camouflages[camoKey]; @@ -20,12 +61,49 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 { const resolvedTankName = shortName ?? fullName; const resolvedTankNameFull = resolvedTankName === fullName ? undefined : fullName; + const include = camo.vehicleFilter.include; + const vehicle = + include && !Array.isArray(include) && "vehicle" in include + ? include.vehicle?.name + : undefined; + let tankId: number | undefined = undefined; + + if (vehicle && vehicle in this.tankStringIdMap) { + const resolvedParts = await Promise.all( + Object.values(yamlEntry.customEntities ?? {}).map( + async ({ item }) => { + const path = items.get(item); + + return ( + path !== undefined && + (await this.vfs.resolve(`${path}.sc2`)) && + (await this.vfs.resolve(`${path}.scg`)) + ); + }, + ), + ); + + let hasPreset = false; + + if (camo.group === "legendary" && yamlEntry.preset !== undefined) { + if (!skinPresets.has(vehicle)) { + skinPresets.set(vehicle, this.skinPresets(vehicle)); + } + + hasPreset = (await skinPresets.get(vehicle)!).has(yamlEntry.preset); + } + + if (resolvedParts.some(Boolean) || hasPreset) { + tankId = this.tankStringIdMap[vehicle]; + } + } camouflageDefinitions.camouflages[camo.id] = { id: camo.id, name: this.getString(camo.userString), tank_name: resolvedTankName, tank_name_full: resolvedTankNameFull, + tank_id: tankId, }; } diff --git a/packages/core/src/protos/camouflage_definitions.proto b/packages/core/src/protos/camouflage_definitions.proto index b3b9bbbac..bae9a1c46 100644 --- a/packages/core/src/protos/camouflage_definitions.proto +++ b/packages/core/src/protos/camouflage_definitions.proto @@ -13,4 +13,5 @@ message Camouflage { required I18nString name = 2; optional I18nString tank_name = 3; optional I18nString tank_name_full = 4; + optional uint32 tank_id = 5; } \ No newline at end of file diff --git a/packages/core/src/protos/camouflage_definitions.ts b/packages/core/src/protos/camouflage_definitions.ts index ca6c2f9fa..3ba5ae248 100644 --- a/packages/core/src/protos/camouflage_definitions.ts +++ b/packages/core/src/protos/camouflage_definitions.ts @@ -24,6 +24,7 @@ export interface Camouflage { name: I18nString | undefined; tank_name?: I18nString | undefined; tank_name_full?: I18nString | undefined; + tank_id?: number | undefined; } function createBaseCamouflageDefinitions(): CamouflageDefinitions { @@ -191,7 +192,7 @@ export const CamouflageDefinitions_CamouflagesEntry: MessageFns = { @@ -208,6 +209,9 @@ export const Camouflage: MessageFns = { if (message.tank_name_full !== undefined) { I18nString.encode(message.tank_name_full, writer.uint32(34).fork()).join(); } + if (message.tank_id !== undefined && message.tank_id !== 0) { + writer.uint32(40).uint32(message.tank_id); + } return writer; }, @@ -250,6 +254,14 @@ export const Camouflage: MessageFns = { message.tank_name_full = I18nString.decode(reader, reader.uint32()); continue; } + case 5: { + if (tag !== 40) { + break; + } + + message.tank_id = reader.uint32(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -265,6 +277,7 @@ export const Camouflage: MessageFns = { name: isSet(object.name) ? I18nString.fromJSON(object.name) : undefined, tank_name: isSet(object.tank_name) ? I18nString.fromJSON(object.tank_name) : undefined, tank_name_full: isSet(object.tank_name_full) ? I18nString.fromJSON(object.tank_name_full) : undefined, + tank_id: isSet(object.tank_id) ? globalThis.Number(object.tank_id) : 0, }; }, @@ -282,6 +295,9 @@ export const Camouflage: MessageFns = { if (message.tank_name_full !== undefined) { obj.tank_name_full = I18nString.toJSON(message.tank_name_full); } + if (message.tank_id !== undefined && message.tank_id !== 0) { + obj.tank_id = Math.round(message.tank_id); + } return obj; }, @@ -300,6 +316,7 @@ export const Camouflage: MessageFns = { message.tank_name_full = (object.tank_name_full !== undefined && object.tank_name_full !== null) ? I18nString.fromPartial(object.tank_name_full) : undefined; + message.tank_id = object.tank_id ?? 0; return message; }, }; diff --git a/packages/core/src/types/camouflagesYaml.ts b/packages/core/src/types/camouflagesYaml.ts index 7671432fe..1be674afa 100644 --- a/packages/core/src/types/camouflagesYaml.ts +++ b/packages/core/src/types/camouflagesYaml.ts @@ -3,5 +3,7 @@ export type CamouflagesYaml = Record< { userString?: string; shortUserString?: string; + preset?: string; + customEntities?: Record; } >; diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SidebarIconButton.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SidebarIconButton.tsx new file mode 100644 index 000000000..473f1a598 --- /dev/null +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SidebarIconButton.tsx @@ -0,0 +1,44 @@ +import { Flex, IconButton, type FlexProps } from "@radix-ui/themes"; +import type { ComponentProps, CSSProperties } from "react"; + +type SidebarIconButtonProps = ComponentProps & { + selected: boolean; + src: string; + alt: string; + imageStyle?: CSSProperties; +}; + +export function SidebarIconGroup({ style, ...props }: FlexProps) { + return ( + + ); +} + +export function SidebarIconButton({ + selected, + src, + alt, + imageStyle, + ...props +}: SidebarIconButtonProps) { + return ( + + {alt} + + ); +} diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx new file mode 100644 index 000000000..556f4e62c --- /dev/null +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx @@ -0,0 +1,81 @@ +import { alias } from "@blitzkit/core"; +import { Flex, Tooltip } from "@radix-ui/themes"; +import { uniqBy } from "lodash-es"; +import { SKIN_NUMBER } from "../../../../../../constants/skinNumber"; +import { api } from "../../../../../../core/blitzkit/api"; +import { useLocale } from "../../../../../../hooks/useLocale"; +import { Duel } from "../../../../../../stores/duel"; +import { Tankopedia } from "../../../../../../stores/tankopedia"; +import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants"; +import { SidebarIconButton } from "./SidebarIconButton"; + +const camouflageDefinitions = await api.camouflageDefinitions(); + +export function SkinSwitcher() { + const tank = Duel.use((state) => state.protagonist.tank); + const skin = Tankopedia.use((state) => state.skin); + const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); + const disturbed = Tankopedia.use((state) => state.disturbed); + const { unwrap } = useLocale(); + const skins = uniqBy( + Object.values(camouflageDefinitions.camouflages).filter( + (camouflage) => camouflage.tank_id === tank.id, + ), + (camouflage) => unwrap(camouflage.name!), + ); + + if (skins.length === 0) return null; + + return ( + + {skins.map((camouflage, index) => { + const name = unwrap(camouflage.name!).replace( + "%(camo_num)", + SKIN_NUMBER, + ); + const top = index === 0 ? "var(--radius-full)" : "0"; + const bottom = index === skins.length - 1 ? "var(--radius-full)" : "0"; + + return ( + + { + Tankopedia.mutate((draft) => { + draft.skin = + draft.skin === camouflage.id ? undefined : camouflage.id; + }); + }} + /> + + ); + })} + + ); +} 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 410a7b8c5..725ec6803 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx @@ -46,6 +46,11 @@ import { TankSearch } from "../../../../TankSearch"; import { CustomShellButton } from "./components/CustomShellButton"; import { DynamicArmorSwitcher } from "./components/DynamicArmorSwitcher"; import { QuickInputs } from "./components/QuickInputs"; +import { + SidebarIconButton, + SidebarIconGroup, +} from "./components/SidebarIconButton"; +import { SkinSwitcher } from "./components/SkinSwitcher"; import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher"; import { Thicknesses } from "./components/Thicknesses"; @@ -87,6 +92,8 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) { + + )} - + {antagonistGun.shells.map((thisShell) => ( - { Duel.mutate((draft) => { draft.antagonist.shell = thisShell; @@ -208,19 +204,10 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) { draft.customShell = undefined; }); }} - > - {unwrap(thisShell.name!)} - + /> ))} - + state.modelRequested); const protagonist = Duel.use((state) => state.protagonist.tank); const revealed = Tankopedia.use((state) => state.revealed); const disturbed = Tankopedia.use((state) => state.disturbed); - const name = unwrap(protagonist.name!); + const hasRevealed = useRef(false); + const skin = Tankopedia.use((state) => state.skin); + const camouflage = + skin === undefined ? undefined : camouflageDefinitions.camouflages[skin]; + const tankName = unwrap(protagonist.name!); + const name = camouflage + ? `${tankName} ${unwrap(camouflage.name!).replace("%(camo_num)", SKIN_NUMBER)}` + : tankName; const fontSize = revealed ? disturbed ? "1.5rem" : "2rem" : `min(12vh, ${75 / name.length}vw)`; + useEffect(() => { + if (revealed) hasRevealed.current = true; + }, [revealed]); + return ( { event.preventDefault(); - if (!modelRequested) return; + if (!modelRequested || hasRevealed.current) return; Tankopedia.mutate((draft) => { draft.revealed = true; diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting.tsx index d2cfd9804..6ee93ccf5 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting.tsx @@ -18,7 +18,7 @@ import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent const ANGLE = degToRad(15); const REVEAL_ANIMATION_TIME = 3; -const TRANSITION_ANIMATION_TIME = 0.5; +export const TRANSITION_ANIMATION_TIME = 0.5; const LIGHTS_COUNT = 3; const THETA_OFFSET = degToRad(180 - 45); @@ -46,11 +46,14 @@ export function Lighting({ hasPbr }: Props) { const highGraphics = TankopediaPersistent.use((state) => state.highGraphics); const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); + const revealed = Tankopedia.use((state) => state.revealed); const animationTime = useRef(REVEAL_ANIMATION_TIME); const t0 = useRef(performance.now() / 1e3 - animationTime.current); + const holding = useRef(false); const isRevealing = useRef(true); + const isFirstReveal = useRef(true); const [animate, setAnimate] = useState(true); @@ -71,6 +74,28 @@ export function Lighting({ hasPbr }: Props) { setAnimate(true); }, [requestedDisplay]); + useEffect(() => { + if (isFirstReveal.current) { + isFirstReveal.current = false; + return; + } + + if (!revealed) { + animationTime.current = TRANSITION_ANIMATION_TIME; + t0.current = performance.now() / 1e3; + holding.current = true; + } else if (holding.current) { + animationTime.current = REVEAL_ANIMATION_TIME; + t0.current = performance.now() / 1e3 - animationTime.current; + holding.current = false; + } else { + return; + } + + setAnimate(true); + invalidate(); + }, [revealed]); + return ( <> { setAnimate(false); @@ -145,11 +171,19 @@ interface AnimatorProps { t0: RefObject; animationTime: RefObject; + holding: RefObject; wrapper: RefObject; hasPbr: boolean; } -function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) { +function Animator({ + stop, + t0, + animationTime, + holding, + wrapper, + hasPbr, +}: AnimatorProps) { const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); const display = Tankopedia.use((state) => state.display); @@ -176,7 +210,7 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) { useFrame(({ invalidate }) => { const dt = performance.now() / 1e3 - t0.current; - const x = clamp(dt / animationTime.current, 0, 2); + const x = clamp(dt / animationTime.current, 0, holding.current ? 1 : 2); if (x === 2) { apply(1); @@ -188,6 +222,9 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) { const t = (0.5 * Math.sin(Math.PI * (x + 0.5)) + 0.5) ** 2; apply(t); + + if (holding.current && x === 1) return; + invalidate(); if (x >= 1 && Tankopedia.state.display !== requestedDisplay) { diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel.tsx index c74722bff..28007e575 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel.tsx @@ -3,10 +3,11 @@ import { useRef } from "react"; import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three"; import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits"; import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment"; -import { jsxTree } from "../../../../../../core/blitzkit/jsxTree"; import { modelTransformEvent } from "../../../../../../core/blitzkit/modelTransform"; +import { skinTree } from "../../../../../../core/blitzkit/skinTree"; import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled"; import { useModel } from "../../../../../../hooks/useModel"; +import { useSkin } from "../../../../../../hooks/useSkin"; import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition"; import { useTankTransform } from "../../../../../../hooks/useTankTransform"; import { Duel } from "../../../../../../stores/duel"; @@ -28,6 +29,8 @@ export function TankModel() { const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id]; const { gltf } = useModel(protagonist.tank.id); const nodes = Object.values(gltf.nodes); + const skin = Tankopedia.use((state) => state.skin); + const skinGltf = useSkin(protagonist.tank.id, skin); useTankTransform(track, turret, turretContainer, gunContainer); @@ -92,7 +95,7 @@ export function TankModel() { window.removeEventListener("pointerup", handlePointerUp); } - return jsxTree(node, { + return skinTree(node, skinGltf, { mesh(_, props, key) { return ( [1]; + +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); + + return mergers!.mesh!( + mesh, + material ? { ...props, material } : props, + key, + ); + }, + group(object3d, props, key) { + const skinNode = skinNodes.get(object3d.name); + const isSwitch = object3d.userData.state !== undefined; + const state = skin + ? skin.scene.userData.states[object3d.name] + : object3d.userData.state; + + return ( + + {isSwitch + ? object3d.children + .filter((child) => child.name === state) + .map((child) => jsxTree(child, skinMergers)) + : props.children} + {skinNode?.children.map((child) => jsxTree(child, mergers))} + + ); + }, + }; + + return jsxTree(node, skinMergers); +} diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts new file mode 100644 index 000000000..4fc69e7c5 --- /dev/null +++ b/packages/website/src/hooks/useSkin.ts @@ -0,0 +1,85 @@ +import { alias } from "@blitzkit/core"; +import { useEffect, useRef, useState } from "react"; +import type { Group, Material } from "three"; +import { GLTFLoader } from "three-stdlib"; +import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting"; +import { Tankopedia } from "../stores/tankopedia"; + +export interface Skin { + scene: Group; + materials: Map; +} + +const skins = new Map>(); +const loadedSkins = new Map(); + +async function loadSkin(path: string) { + const gltf = await new GLTFLoader().loadAsync(path); + const materials: Material[] = await gltf.parser.getDependencies("material"); + const skin = { + scene: gltf.scene, + materials: new Map(materials.map((material) => [material.name, material])), + }; + + loadedSkins.set(path, skin); + + return skin; +} + +function skinPath(id: number, skin: number) { + return alias("api", `/tanks/${id}/skins/${skin}.glb`); +} + +export function useSkin(id: number, skin?: number) { + const [loaded, setLoaded] = useState(); + const mounted = useRef(false); + + useEffect(() => { + const path = skin === undefined ? undefined : skinPath(id, skin); + const cached = path === undefined ? undefined : loadedSkins.get(path); + let settled = false; + + if (!mounted.current) { + mounted.current = true; + + if (path === undefined || cached) { + setLoaded(cached); + return; + } + } + + Tankopedia.mutate((draft) => { + draft.revealed = false; + }); + + if (path !== undefined && !skins.has(path)) { + skins.set(path, loadSkin(path)); + } + + Promise.all([ + path === undefined ? undefined : skins.get(path), + new Promise((resolve) => + setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3), + ), + ]).then(([loaded]) => { + if (settled) return; + + settled = true; + setLoaded(loaded); + Tankopedia.mutate((draft) => { + draft.revealed = true; + }); + }); + + return () => { + if (settled) return; + + settled = true; + Tankopedia.mutate((draft) => { + draft.revealed = true; + }); + }; + }, [id, skin]); + + return loaded; +} diff --git a/packages/website/src/pages/[...chunk_api]/icons/camouflages/[id].webp.ts b/packages/website/src/pages/[...chunk_api]/icons/camouflages/[id].webp.ts new file mode 100644 index 000000000..caff69054 --- /dev/null +++ b/packages/website/src/pages/[...chunk_api]/icons/camouflages/[id].webp.ts @@ -0,0 +1,53 @@ +import type { CamouflagesXml } from "@blitzkit/core"; +import type { APIContext, GetStaticPathsItem } from "astro"; +import sharp from "sharp"; +import { extractPackedIcon } from "../../../../core/blitz/extractPackedIcon"; +import { parsePackedSpriteRect } from "../../../../core/blitz/parsePackedSpriteRect"; +import { api } from "../../../../core/blitzkit/api"; +import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths"; +import { vfs } from "../../../../core/blitzkit/vfs"; +import { getStaticPaths as _getStaticPaths } from "../../_index"; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const camouflageDefinitions = await api.camouflageDefinitions(); + const camouflagesXml = await vfs.xml<{ root: CamouflagesXml }>( + "Data/XML/item_defs/vehicles/common/camouflages.xml", + ); + const icons = new Map( + Object.values(camouflagesXml.root.camouflages).map((camouflage) => [ + camouflage.id, + camouflage.icon, + ]), + ); + const paths: GetStaticPathsItem[] = []; + + for (const camouflage of Object.values(camouflageDefinitions.camouflages)) { + const icon = icons.get(camouflage.id); + + if (camouflage.tank_id === undefined || !icon) continue; + + paths.push({ + params: { id: camouflage.id }, + props: { + path: icon.replace("~res:/", "Data/").replace(/\.txt$/, ""), + }, + }); + } + + return paths; +}); + +export async function GET({ props }: APIContext<{ path: string }>) { + const path = (await vfs.resolve(`${props.path}@2x.packed.webp`)) + ? `${props.path}@2x.packed.webp` + : `${props.path}.packed.webp`; + const packed = await vfs.file(path); + const texture = sharp(packed); + const sizes = parsePackedSpriteRect(packed); + const image = sizes + ? sharp(await extractPackedIcon(texture, sizes)) + : texture; + const bytes = await image.webp().toBuffer(); + + return new Response(new Uint8Array(bytes)); +} 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 3b531668c..faf26b002 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 @@ -17,7 +17,7 @@ import { Document, Material, Node, NodeIO, Scene } from "@gltf-transform/core"; import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; import { dedup, prune } from "@gltf-transform/functions"; import type { APIContext } from "astro"; -import { times } from "lodash-es"; +import { isEqual, times } from "lodash-es"; import { dirname } from "path"; import sharp from "sharp"; import { vfs } from "../../../../core/blitzkit/vfs"; @@ -69,7 +69,11 @@ const omitMeshNames = { end: ["_POINT"], }; -async function extractModel(vfs: AbstractVFS, path: string) { +export async function extractModel( + vfs: AbstractVFS, + path: string, + isPart = false, +) { const sc2Path = `Data/3d/${path}.sc2`; const scgPath = `Data/3d/${path}.scg`; const sc2 = new Sc2ReadStream( @@ -81,6 +85,153 @@ async function extractModel(vfs: AbstractVFS, path: string) { const document = new Document(); const scene = document.createScene(); const buffer = document.createBuffer(); + const materials = await extractMaterials(document, sc2, path); + + parseHierarchies( + sc2["#hierarchy"], + scene, + (hierarchy, components, parent) => { + if ( + omitMeshNames.start.some((omit) => hierarchy.name.startsWith(omit)) || + omitMeshNames.end.some((omit) => hierarchy.name.endsWith(omit)) + ) + return; + + const node = document.createNode(hierarchy.name); + + components.forEach((component) => { + switch (component["comp.typename"]) { + case "LodComponent": + // found and used later by transform component + // TODO: lod component always shows up before transform component; cache it before parsing transform + break; + + case "TransformComponent": { + const localTranslation = component["tc.localTranslation"]; + // The game resets top-level node translation to [0, 0, 0] on load. + // Child nodes and customization parts must keep their authored + // local offsets. + node.setTranslation( + parent instanceof Scene && !isPart ? [0, 0, 0] : localTranslation, + ); + node.setRotation(component["tc.localRotation"]); + node.setScale(component["tc.localScale"]); + + break; + } + + case "StateSwitcherComponent": + node.setExtras({ + state: component[`ssc.state${component["ssc.activeState"]}`], + }); + break; + + case "RenderComponent": { + const renderObject = component["rc.renderObj"]; + + times(renderObject["ro.batchCount"], (batchIndex): void => { + const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; + + if (lodIndex !== 0) return; + + const batchKey = batchIndex.toString().padStart(4, "0"); + const batch = renderObject["ro.batches"][batchKey]; + const material = materials.get(batch["rb.nmatname"]); + const polygonGroup = scg.get(batch["rb.datasource"]); + + if (!(material instanceof Material)) { + // probably shadow material + return; + } + if (polygonGroup === undefined) { + throw new Error( + `Missing polygon group ${batch["rb.datasource"]}`, + ); + } + + const lodNode = document.createNode(batchKey); + const indicesAccessor = document + .createAccessor() + .setType("SCALAR") + .setArray(new Uint16Array(polygonGroup.indices)) + .setBuffer(buffer); + const primitive = document + .createPrimitive() + .setIndices(indicesAccessor) + .setMaterial(material) + .setName(batchKey); + + const attributes = new Map(); + + polygonGroup.vertices.forEach((vertex) => { + vertex.forEach(({ attribute, value }) => { + if (!attributes.has(attribute)) { + attributes.set(attribute, []); + } + + attributes.get(attribute)!.push(value); + }); + }); + + attributes.forEach((value, attribute) => { + const name = vertexAttributeGLTFName[attribute]; + + if ( + name === undefined || + primitive.getAttribute(name) !== null + ) { + return; + } + + const vertexSize = vertexAttributeGltfVectorSizes[attribute]; + const attributeAccessor = document + .createAccessor(name) + .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`) + .setArray(new Float32Array(value.flat())) + .setBuffer(buffer); + + primitive.setAttribute(name, attributeAccessor); + }); + + const mesh = document + .createMesh(batch["##name"]) + .addPrimitive(primitive); + lodNode.setMesh(mesh); + node.addChild(lodNode); + }); + + break; + } + + default: { + if (ERROR_ON_UNKNOWN_COMPONENT) { + throw new Error( + `Unhandled component type: ${component["comp.typename"]}`, + ); + } + } + } + }); + + parent.addChild(node); + + return node; + }, + ); + + scene.addChild(document.createNode("test")); + + await document.transform(prune({ keepAttributes: true }), dedup()); + + return document; +} + +export async function extractMaterials( + document: Document, + sc2: ReturnType, + path: string, + config?: string, +) { const materials = new Map(); for (const node of sc2["#dataNodes"]) { @@ -95,15 +246,32 @@ async function extractModel(vfs: AbstractVFS, path: string) { continue; } - const material = document.createMaterial(node.materialName); let textures: Textures | undefined = undefined; - if (node.textures) { + if (config !== undefined) { + if (typeof node.configCount !== "number") continue; + + const archive = times( + node.configCount, + (index) => node[`configArchive_${index}`], + ).find((archive) => archive.configName === config); + + if ( + !archive || + isEqual(archive.textures, node.configArchive_0.textures) + ) { + continue; + } + + textures = archive.textures; + } else if (node.textures) { textures = node.textures; } else if (typeof node.configCount === "number") { textures = node.configArchive_0.textures; } + const material = document.createMaterial(node.materialName); + if (textures) { const baseColor = await readBaseColor( `Data/3d/${dirname(path)}/${textures.baseColorMap ?? textures.albedo}`, @@ -240,136 +408,7 @@ async function extractModel(vfs: AbstractVFS, path: string) { materials.set(id, resolvedMaterial); }); - parseHierarchies( - sc2["#hierarchy"], - scene, - (hierarchy, components, parent) => { - if ( - omitMeshNames.start.some((omit) => hierarchy.name.startsWith(omit)) || - omitMeshNames.end.some((omit) => hierarchy.name.endsWith(omit)) - ) - return; - - const node = document.createNode(hierarchy.name); - - components.forEach((component) => { - switch (component["comp.typename"]) { - case "LodComponent": - // found and used later by transform component - // TODO: lod component always shows up before transform component; cache it before parsing transform - break; - - case "TransformComponent": { - const localTranslation = component["tc.localTranslation"]; - // The game resets top-level node translation to [0, 0, 0] on load. - // Child nodes must keep their authored local offsets. - node.setTranslation( - parent instanceof Scene ? [0, 0, 0] : localTranslation, - ); - node.setRotation(component["tc.localRotation"]); - node.setScale(component["tc.localScale"]); - - break; - } - - case "RenderComponent": { - const renderObject = component["rc.renderObj"]; - - times(renderObject["ro.batchCount"], (batchIndex): void => { - const lodIndex = renderObject[`rb${batchIndex}.lodIndex`]; - - if (lodIndex !== 0) return; - - const batchKey = batchIndex.toString().padStart(4, "0"); - const batch = renderObject["ro.batches"][batchKey]; - const material = materials.get(batch["rb.nmatname"]); - const polygonGroup = scg.get(batch["rb.datasource"]); - - if (!(material instanceof Material)) { - // probably shadow material - return; - } - if (polygonGroup === undefined) { - throw new Error( - `Missing polygon group ${batch["rb.datasource"]}`, - ); - } - - const lodNode = document.createNode(batchKey); - const indicesAccessor = document - .createAccessor() - .setType("SCALAR") - .setArray(new Uint16Array(polygonGroup.indices)) - .setBuffer(buffer); - const primitive = document - .createPrimitive() - .setIndices(indicesAccessor) - .setMaterial(material) - .setName(batchKey); - - const attributes = new Map(); - - polygonGroup.vertices.forEach((vertex) => { - vertex.forEach(({ attribute, value }) => { - if (!attributes.has(attribute)) { - attributes.set(attribute, []); - } - - attributes.get(attribute)!.push(value); - }); - }); - - attributes.forEach((value, attribute) => { - const name = vertexAttributeGLTFName[attribute]; - - if ( - name === undefined || - primitive.getAttribute(name) !== null - ) { - return; - } - - const vertexSize = vertexAttributeGltfVectorSizes[attribute]; - const attributeAccessor = document - .createAccessor(name) - .setType(vertexSize === 1 ? "SCALAR" : `VEC${vertexSize}`) - .setArray(new Float32Array(value.flat())) - .setBuffer(buffer); - - primitive.setAttribute(name, attributeAccessor); - }); - - const mesh = document - .createMesh(batch["##name"]) - .addPrimitive(primitive); - lodNode.setMesh(mesh); - node.addChild(lodNode); - }); - - break; - } - - default: { - if (ERROR_ON_UNKNOWN_COMPONENT) { - throw new Error( - `Unhandled component type: ${component["comp.typename"]}`, - ); - } - } - } - }); - - parent.addChild(node); - - return node; - }, - ); - - scene.addChild(document.createNode("test")); - - await document.transform(prune({ keepAttributes: true }), dedup()); - - return document; + return materials; } async function readBaseColor(path: string) { 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 new file mode 100644 index 000000000..bee9b21f9 --- /dev/null +++ b/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts @@ -0,0 +1,234 @@ +import { + parseHierarchies, + Sc2ReadStream, + type CamouflagesXml, + type CamouflagesYaml, + type TankParameters, +} from "@blitzkit/core"; +import { + Document, + Node, + NodeIO, + PropertyType, + Scene, +} from "@gltf-transform/core"; +import { ALL_EXTENSIONS } from "@gltf-transform/extensions"; +import { dedup, mergeDocuments, unpartition } from "@gltf-transform/functions"; +import type { APIContext } from "astro"; +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 { vfs } from "../../../../../core/blitzkit/vfs"; +import { getStaticPaths as _getStaticPaths } from "../../../_index"; +import { extractMaterials, extractModel } from "../model.glb"; + +const SKIN_NUMBER_SLOTS = 5; +const DIGIT_COLUMNS = 5; +const DIGIT_ROWS = 2; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const camouflages = await api.camouflageDefinitions(); + + return Object.values(camouflages.camouflages) + .filter((camouflage) => camouflage.tank_id) + .map((camouflage) => ({ + params: { id: camouflage.tank_id!, skin: camouflage.id }, + props: { skin: camouflage.id }, + })); +}); + +const camouflagesXml = await vfs.xml<{ root: CamouflagesXml }>( + "Data/XML/item_defs/vehicles/common/camouflages.xml", +); +const camouflagesYaml = await vfs.yaml( + "Data/camouflages.yaml", +); +const customization = await vfs.yaml< + Record +>("Data/3d/Customization.yaml"); + +export async function GET({ props }: APIContext<{ skin: number }>) { + const nodeIO = new NodeIO().registerExtensions(ALL_EXTENSIONS); + + const items = new Map( + Object.values(customization).map(({ Name, Path }) => [ + Name, + Path.replace("~res:/3d/", "").replace(/\.sc2$/, ""), + ]), + ); + const key = Object.keys(camouflagesXml.root.camouflages).find( + (key) => camouflagesXml.root.camouflages[key].id === props.skin, + )!; + const include = camouflagesXml.root.camouflages[key].vehicleFilter.include; + const [nation, tankKey] = ( + include as { vehicle: { name: string } } + ).vehicle.name.split(":"); + const { preset, customEntities } = camouflagesYaml[key]; + const skinNumber = Number(preset?.match(/(\d+)$/)?.[1] ?? 1); + const parameters = await vfs.yaml( + `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, + ); + const basePath = parameters.resourcesPath.blitzModelPath.replace( + /\.sc2$/, + "", + ); + const sc2 = new Sc2ReadStream( + (await vfs.file(`Data/3d/${basePath}.sc2`)).buffer as ArrayBuffer, + ).sc2(); + const slots: Record< + string, + { + target: string; + position: Vector3Tuple; + rotation: Vector4Tuple; + scale: Vector3Tuple; + } + > = {}; + const states: Record = {}; + + parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => { + for (const component of components) { + if (component["comp.typename"] === "NewSlotComponent") { + for (const slot of Object.values(component.slots)) { + slots[slot.name] = { + target: hierarchy.name, + position: slot.transform.position, + rotation: slot.transform.rotation, + scale: slot.transform.scale, + }; + } + } + + if (component["comp.typename"] === "SlotComponent") { + slots[component["sc.slotName"]] = { + target: hierarchy.name, + position: component["sc.attachmentTranslation"] as Vector3Tuple, + rotation: component["sc.attachmentRotation"] as Vector4Tuple, + scale: component["sc.attachmentScale"] as Vector3Tuple, + }; + } + + if (component["comp.typename"] !== "StateSwitcherComponent") continue; + + const active = component[`ssc.state${component["ssc.activeState"]}`]; + // camos without their own variant keep the default piece, but groups + // of elements to hide stay hidden + let state: string | null = + active && !/skin\d*/.test(active) && !active.includes("hide") + ? active + : null; + + for (let index = 0; index < component["ssc.statesCount"]; index++) { + const name = component[`ssc.state${index}`]; + const match = name.match(/skin(\d*)/); + + if ( + match && + !name.includes("hide") && + Number(match[1] || 1) === skinNumber + ) { + state = name; + } + } + + states[hierarchy.name] = state; + } + + return null; + }); + + const document = new Document(); + const scene = document.createScene().setExtras({ states }); + const targets = new Map(); + + document.createBuffer(); + + if (preset) await extractMaterials(document, sc2, basePath, preset); + + for (const name in customEntities) { + const slot = slots[name]; + const path = items.get(customEntities[name].item); + + if ( + !slot || + !path || + !(await vfs.resolve(`Data/3d/${path}.sc2`)) || + !(await vfs.resolve(`Data/3d/${path}.scg`)) + ) { + continue; + } + + const part = await extractModel(vfs, path, true); + const map = mergeDocuments(document, part); + const partScene = map.get(part.getRoot().listScenes()[0]) as Scene; + const node = document + .createNode(name) + .setTranslation(slot.position) + .setRotation(slot.rotation) + .setScale(slot.scale); + let target = targets.get(slot.target); + + if (!target) { + target = document.createNode(slot.target); + targets.set(slot.target, target); + scene.addChild(target); + } + + for (const child of partScene.listChildren()) { + node.addChild(child as Node); + } + + partScene.dispose(); + target.addChild(node); + } + + applySkinNumber(document, SKIN_NUMBER); + + await document.transform( + dedup({ propertyTypes: [PropertyType.ACCESSOR, PropertyType.TEXTURE] }), + unpartition(), + ); + + return new Response(await nodeIO.writeBinary(document)); +} + +function applySkinNumber(document: Document, number: string) { + const start = Math.ceil((SKIN_NUMBER_SLOTS - number.length) / 2) + 1; + + for (const node of document.getRoot().listNodes()) { + const match = node.getName().match(/_number_\d+_(\d+)$/); + + if (!match) continue; + + const digit = number[Number(match[1]) - start]; + + if (digit === undefined) { + node.dispose(); + continue; + } + + const cell = (Number(digit) + 9) % 10; + const column = cell % DIGIT_COLUMNS; + const row = Math.floor(cell / DIGIT_COLUMNS); + + node.traverse((child) => { + for (const primitive of child.getMesh()?.listPrimitives() ?? []) { + const uvs = primitive.getAttribute("TEXCOORD_0")?.clone(); + + if (!uvs) continue; + + for (let index = 0; index < uvs.getCount(); index++) { + const [u, v] = uvs.getElement(index, [0, 0]) as [number, number]; + + uvs.setElement(index, [ + (column + u) / DIGIT_COLUMNS, + (row + v) / DIGIT_ROWS, + ]); + } + + primitive.setAttribute("TEXCOORD_0", uvs); + } + }); + } +} diff --git a/packages/website/src/stores/tankopedia.ts b/packages/website/src/stores/tankopedia.ts index d6e374a0a..6685a419e 100644 --- a/packages/website/src/stores/tankopedia.ts +++ b/packages/website/src/stores/tankopedia.ts @@ -103,6 +103,7 @@ interface Tankopedia { ); xpMultiplier: (typeof XP_MULTIPLIERS)[number]; customShell?: ShellDefinition; + skin?: number; requestedDisplay: TankopediaDisplay; display: TankopediaDisplay; statSearch?: string; From ffb5a1f9c8cc6046487b181cee828c055f48f8ad Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Tue, 20 Oct 2026 18:41:01 +1300 Subject: [PATCH 2/6] Feature: Animated legendary camouflages Legendary camo add-ons now play their garage animations in the Tankopedia viewer, and a new menu next to the camo column triggers their battle reactions. **Export (`model.glb` / `skins/[skin].glb`)** - Customization parts export skeletons, skin weights and one clip per `.anim` motion file (`core/blitzkit/skinAnimations`) - Sockets bound to skeleton joints follow their joint - Action lists and scenario graphs are carried in glTF extras - Part batches with no LODs (`lodIndex` -1) are now exported, fixing meshes that were missing entirely (e.g. the Tiger II cat body, the BDT-5A engine rings) - Base tank models are unchanged **Viewer** - A runtime plays the clips and drives state switchers, action lists and the game's scenario graphs (a small interpreter for the node types the camos use) - Camo parts render as primitives so their bones can animate - Garage animations play once rather than looping, so the camo settles **Animation menu** - Floats left of the camo column when the selected camo has triggerable animations - Icon buttons use the game's own white icons, served from `/icons/skin-events/[event].webp` - Idle replays the garage animation - Triggers: Shoot, Kill, Take damage, Damage enemy, Ricochet / No penetration, Start moving, Consumable, Reload. Only events that actually animate get a button - Buttons are disabled while a triggered animation plays; triggering cuts any garage animation short All legendary skin files together grow from 1.32 GB to 1.35 GB (+2.4%), almost all of it in the 102 animated camos. --- packages/i18n/strings/en.json | 12 + .../components/SkinAnimationSwitcher.tsx | 131 +++ .../HeroSection/components/Options/index.tsx | 2 + .../TankSandbox/components/TankModel.tsx | 29 + .../core/blitzkit/skinAnimations/extras.ts | 18 + .../core/blitzkit/skinAnimations/motion.ts | 64 ++ .../core/blitzkit/skinAnimations/runtime.ts | 834 ++++++++++++++++++ .../core/blitzkit/skinAnimations/skeleton.ts | 290 ++++++ .../website/src/core/blitzkit/skinTree.tsx | 19 +- packages/website/src/hooks/useSkin.ts | 30 +- .../website/src/hooks/useSkinAnimation.ts | 85 ++ .../icons/skin-events/[event].webp.ts | 67 ++ .../[...chunk_api]/tanks/[id]/model.glb.ts | 179 +++- .../tanks/[id]/skins/[skin].glb.ts | 23 +- packages/website/src/stores/tankopedia.ts | 6 + 15 files changed, 1778 insertions(+), 11 deletions(-) create mode 100644 packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinAnimationSwitcher.tsx create mode 100644 packages/website/src/core/blitzkit/skinAnimations/extras.ts create mode 100644 packages/website/src/core/blitzkit/skinAnimations/motion.ts create mode 100644 packages/website/src/core/blitzkit/skinAnimations/runtime.ts create mode 100644 packages/website/src/core/blitzkit/skinAnimations/skeleton.ts create mode 100644 packages/website/src/hooks/useSkinAnimation.ts create mode 100644 packages/website/src/pages/[...chunk_api]/icons/skin-events/[event].webp.ts 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, })); From 0632899ecf21ae26a83f0e96859b88078a694bfe Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:14:10 -0500 Subject: [PATCH 3/6] disable halloween --- packages/website/src/core/blitzkit/isHalloween.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/website/src/core/blitzkit/isHalloween.ts b/packages/website/src/core/blitzkit/isHalloween.ts index c05deb5f5..c436a6a26 100644 --- a/packages/website/src/core/blitzkit/isHalloween.ts +++ b/packages/website/src/core/blitzkit/isHalloween.ts @@ -1,4 +1,6 @@ export function isHalloween() { + return false; + const data = new Date(); return data.getMonth() === 9 && data.getDate() === 31; } From 682f039c6cab1131852e8eddf05676351845014a Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Sun, 1 Nov 2026 14:44:03 +1300 Subject: [PATCH 4/6] Fix: Hide unused states of legendary camouflage parts (fixes maus) --- .../blitzkit/api/server/2_camouflageDefinitions.ts | 6 ++---- packages/core/src/types/camouflagesYaml.ts | 12 ++++++++++++ .../[id]/skins/[skin].glb.ts | 5 ++--- .../pages/[...chunk_models_tanks]/[id]/model.glb.ts | 5 ++++- 4 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts b/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts index 4462f1010..88386017f 100644 --- a/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts @@ -1,5 +1,6 @@ import { CamouflageDefinitions, + camouflagePreset, Sc2ReadStream, type TankParameters, } from "@blitzkit/core"; @@ -88,10 +89,7 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 { ); const [nation, tankKey] = vehicle.split(":"); - // some unique camos only set the preset for their one tank - const preset = - yamlEntry.preset ?? - yamlEntry.override?.[nation]?.override?.[tankKey]?.preset; + const preset = camouflagePreset(yamlEntry, nation, tankKey); let hasPreset = false; if (preset !== undefined) { diff --git a/packages/core/src/types/camouflagesYaml.ts b/packages/core/src/types/camouflagesYaml.ts index 35d1531ca..5462c945a 100644 --- a/packages/core/src/types/camouflagesYaml.ts +++ b/packages/core/src/types/camouflagesYaml.ts @@ -12,3 +12,15 @@ export type CamouflagesYaml = Record< >; } >; + +// some unique camos only set the preset for their one tank +export function camouflagePreset( + camouflage: CamouflagesYaml[string], + nation: string, + tankKey: string, +) { + return ( + camouflage.preset ?? + camouflage.override?.[nation]?.override?.[tankKey]?.preset + ); +} diff --git a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts index 00ce9a45d..fc7ea0978 100644 --- a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts @@ -1,4 +1,5 @@ import { + camouflagePreset, parseHierarchies, Sc2ReadStream, type CamouflagesXml, @@ -73,9 +74,7 @@ export async function GET({ props }: APIContext<{ skin: number }>) { include as { vehicle: { name: string } } ).vehicle.name.split(":"); const { customEntities } = camouflagesYaml[key]; - const preset = - camouflagesYaml[key].preset ?? - camouflagesYaml[key].override?.[nation]?.override?.[tankKey]?.preset; + const preset = camouflagePreset(camouflagesYaml[key], nation, tankKey); const parameters = await vfs.yaml( `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, ); diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts index 859f988d6..dbd96721e 100644 --- a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts @@ -209,7 +209,10 @@ export async function extractModel( case "StateSwitcherComponent": node.setExtras({ - state: component[`ssc.state${component["ssc.activeState"]}`], + // a part's scenarios can pick its state as it loads + state: + component[`ssc.state${component["ssc.activeState"]}`] ?? + (isPart ? null : undefined), }); break; From fddf407cc0aa8b843fd9fce22aa4d40af795a189 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Thu, 5 Nov 2026 17:26:56 +1300 Subject: [PATCH 5/6] Fix: Address review on animated legendary camouflages - Inline the camouflage preset lookup instead of a one-off `camouflagePreset` helper - Move the `primitive` override into the `jsxTree` mergers, so `skinTree` takes plain mergers and falls back to `` like the mesh and group defaults --- .../blitzkit/api/server/2_camouflageDefinitions.ts | 6 ++++-- packages/core/src/types/camouflagesYaml.ts | 12 ------------ packages/website/src/core/blitzkit/jsxTree.tsx | 1 + packages/website/src/core/blitzkit/skinTree.tsx | 13 +++++++------ .../[id]/skins/[skin].glb.ts | 5 +++-- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts b/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts index 88386017f..4462f1010 100644 --- a/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts @@ -1,6 +1,5 @@ import { CamouflageDefinitions, - camouflagePreset, Sc2ReadStream, type TankParameters, } from "@blitzkit/core"; @@ -89,7 +88,10 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 { ); const [nation, tankKey] = vehicle.split(":"); - const preset = camouflagePreset(yamlEntry, nation, tankKey); + // some unique camos only set the preset for their one tank + const preset = + yamlEntry.preset ?? + yamlEntry.override?.[nation]?.override?.[tankKey]?.preset; let hasPreset = false; if (preset !== undefined) { diff --git a/packages/core/src/types/camouflagesYaml.ts b/packages/core/src/types/camouflagesYaml.ts index 5462c945a..35d1531ca 100644 --- a/packages/core/src/types/camouflagesYaml.ts +++ b/packages/core/src/types/camouflagesYaml.ts @@ -12,15 +12,3 @@ export type CamouflagesYaml = Record< >; } >; - -// some unique camos only set the preset for their one tank -export function camouflagePreset( - camouflage: CamouflagesYaml[string], - nation: string, - tankKey: string, -) { - return ( - camouflage.preset ?? - camouflage.override?.[nation]?.override?.[tankKey]?.preset - ); -} diff --git a/packages/website/src/core/blitzkit/jsxTree.tsx b/packages/website/src/core/blitzkit/jsxTree.tsx index 4a8fd1688..96ab50c03 100644 --- a/packages/website/src/core/blitzkit/jsxTree.tsx +++ b/packages/website/src/core/blitzkit/jsxTree.tsx @@ -30,6 +30,7 @@ export function jsxTree( props: ComponentProps<"group">, key: string, ) => ReactNode; + primitive?: (object3d: Object3D, key: string) => ReactNode; }, ): ReactNode { if (node instanceof Mesh) { diff --git a/packages/website/src/core/blitzkit/skinTree.tsx b/packages/website/src/core/blitzkit/skinTree.tsx index 44d9a7f47..81c5a46d6 100644 --- a/packages/website/src/core/blitzkit/skinTree.tsx +++ b/packages/website/src/core/blitzkit/skinTree.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from "react"; import type { Material, Object3D } from "three"; import type { Skin } from "../../hooks/useSkin"; import { jsxTree } from "./jsxTree"; @@ -18,9 +17,7 @@ export function nameOf(object3d: Object3D): string { export function skinTree( node: Object3D, skins: Skin[], - mergers: Mergers & { - primitive: (object: Object3D, key: string) => ReactNode; - }, + mergers: NonNullable, ) { const materials = new Map(); const states: Record = {}; @@ -48,7 +45,7 @@ export function skinTree( mesh(mesh, props, key) { const material = materials.get((mesh.material as Material).name); - return mergers!.mesh!( + return mergers.mesh!( mesh, material ? { ...props, material } : props, key, @@ -60,7 +57,11 @@ export function skinTree( {switchChildren(object3d) ?? props.children} {skins.flatMap((skin) => (skin.parts.get(object3d.name) ?? []).map((part) => - mergers.primitive(part, part.uuid), + mergers.primitive ? ( + mergers.primitive(part, part.uuid) + ) : ( + + ), ), )} diff --git a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts index fc7ea0978..00ce9a45d 100644 --- a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts @@ -1,5 +1,4 @@ import { - camouflagePreset, parseHierarchies, Sc2ReadStream, type CamouflagesXml, @@ -74,7 +73,9 @@ export async function GET({ props }: APIContext<{ skin: number }>) { include as { vehicle: { name: string } } ).vehicle.name.split(":"); const { customEntities } = camouflagesYaml[key]; - const preset = camouflagePreset(camouflagesYaml[key], nation, tankKey); + const preset = + camouflagesYaml[key].preset ?? + camouflagesYaml[key].override?.[nation]?.override?.[tankKey]?.preset; const parameters = await vfs.yaml( `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, ); From 232b3e77702664de4211feaa7206d392694e828a Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Sat, 7 Nov 2026 15:35:12 +1300 Subject: [PATCH 6/6] Fix: Address karelpak1's review on animated legendary camouflages - Sockets bound to a joint now sit right on the joint, as in game. Only the skeleton's own children are bound. This fixes the WZ-114 gun barrels collapsing. - Clips keep their full duration when it runs past their last key. - Part states are kept per entity and exported on each part's own switchers, so parts with matching names can't overwrite each other. - A finished one-shot animation now stops on whatever it switched to, as in game. It only keeps looping if it changes nothing. - Delayed actions run in delay order. --- .../core/blitzkit/skinAnimations/skeleton.ts | 39 +++++------ .../src/core/blitzkit/switcherStates.ts | 68 +++++++++++++------ packages/website/src/hooks/useSkin.ts | 9 +-- .../[id]/skins/[skin].glb.ts | 11 +-- .../[...chunk_models_tanks]/[id]/model.glb.ts | 28 ++++---- 5 files changed, 83 insertions(+), 72 deletions(-) diff --git a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts index 4f97fef01..c6a006414 100644 --- a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts +++ b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts @@ -146,6 +146,7 @@ export function createMotionClip( ) { const animation = document.createAnimation(clip); const playbackRate = component["motion.playbackRate"]; + const duration = motion.duration / playbackRate; for (const [uid, track] of motion.tracks) { const joint = joints.get(uid); @@ -153,6 +154,20 @@ export function createMotionClip( if (!joint) continue; for (const [type, { times, values }] of track) { + const keyTimes = times.map((time) => time / playbackRate); + const keyValues = + type === MotionChannel.Scale + ? values.flatMap((value) => [value, value, value]) + : [...values]; + + // the game loops over the clip's duration, which can outlast its keys + if (keyTimes[keyTimes.length - 1] < duration) { + const size = keyValues.length / keyTimes.length; + + keyTimes.push(duration); + keyValues.push(...keyValues.slice(-size)); + } + const sampler = document .createAnimationSampler() .setInput( @@ -160,7 +175,7 @@ export function createMotionClip( document, buffer, "SCALAR", - new Float32Array(times.map((time) => time / playbackRate)), + new Float32Array(keyTimes), ), ) .setOutput( @@ -168,11 +183,7 @@ export function createMotionClip( document, buffer, type === MotionChannel.Rotation ? "VEC4" : "VEC3", - new Float32Array( - type === MotionChannel.Scale - ? values.flatMap((value) => [value, value, value]) - : values, - ), + new Float32Array(keyValues), ), ) .setInterpolation("LINEAR"); @@ -191,22 +202,12 @@ export function createMotionClip( } /** - * 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. + * Makes a socket follow a joint. The game replaces the socket's own transform + * with the joint's, so it sits right on the joint. */ export function bindSocket(socket: Node, joint: Node, skin: Skin) { - 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.setMatrix(new Matrix4().toArray() as mat4); socket.traverse((child) => { const childSkin = child.getSkin(); diff --git a/packages/website/src/core/blitzkit/switcherStates.ts b/packages/website/src/core/blitzkit/switcherStates.ts index 394105ebe..f645b9e4a 100644 --- a/packages/website/src/core/blitzkit/switcherStates.ts +++ b/packages/website/src/core/blitzkit/switcherStates.ts @@ -3,7 +3,7 @@ import { type Hierarchy, type Sc2ReadStream, } from "@blitzkit/core"; -import { times } from "lodash-es"; +import { sortBy, times } from "lodash-es"; interface Value { "any.type": string; @@ -103,8 +103,10 @@ function replay( variables: Map; }[] = []; const actions: { owner: Entity; action: Action }[] = []; - const states: Record = {}; + // names can repeat, so states are kept per entity + const states = new Map(); const looping = new Set(); + const finished = new Set(); const pending: (() => void)[] = []; const fired = new Set(); @@ -113,11 +115,11 @@ function replay( if (!names) return; - const previous = states[entity.name]; + const previous = states.get(entity); - states[entity.name] = names[state] ?? null; + states.set(entity, names[state] ?? null); - if (!animate || previous === states[entity.name]) return; + if (!animate || previous === states.get(entity)) return; for (const scenario of scenarios) { if (scenario.owner === entity) { @@ -130,7 +132,7 @@ function replay( for (let child = entity; child.parent; child = child.parent) { if ( stateNames.get(child.parent)?.includes(child.name) && - states[child.parent.name] !== child.name + states.get(child.parent) !== child.name ) { return false; } @@ -145,20 +147,23 @@ function replay( return; } - // some clips restart themselves when they end if (looping.has(entity)) return; looping.add(entity); - if ((repeats ?? entity.repeats) > 0) { + // some clips restart themselves when they end, so each finishes once + if ((repeats ?? entity.repeats) > 0 && !finished.has(entity)) { pending.push(() => finishAnimation(entity)); } } function finishAnimation(entity: Entity) { - const statesBefore = { ...states }; + const statesBefore = new Map(states); const shownBefore = [...looping].filter(isShown); + looping.delete(entity); + finished.add(entity); + for (const { owner, action } of actions) { if ( owner === entity && @@ -183,16 +188,17 @@ function replay( 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 + // as in game, a clip stops on whatever it switched to or revealed; one that + // changes nothing is the only animation there is, so it keeps looping if ( - [...looping].some( - (other) => isShown(other) && !shownBefore.includes(other), + [...states].every( + ([other, state]) => statesBefore.get(other) === state, + ) && + [...looping].every( + (other) => !isShown(other) || shownBefore.includes(other), ) ) { - looping.delete(entity); - } else { - Object.assign(states, statesBefore); + looping.add(entity); } } @@ -249,7 +255,7 @@ function replay( ); stateNames.set(entity, names); - states[entity.name] = names[component["ssc.activeState"]] ?? null; + states.set(entity, names[component["ssc.activeState"]] ?? null); break; } @@ -464,7 +470,12 @@ function replay( fired.add(event); - for (const { owner, action } of actions) { + // the game runs delayed actions once their delay is up, so they go in that + // order rather than the order they're stored in + for (const { owner, action } of sortBy( + actions, + ({ action }) => action["act.delay"], + )) { if ( (animate || action["act.delay"] === 0) && (event === LOAD_EVENTS[0] @@ -484,7 +495,12 @@ function replay( for (let run = pending.shift(); run; run = pending.shift()) run(); return { - states, + states: new Map( + [...states].map(([entity, state]) => [ + entity.hierarchy as Hierarchy, + state, + ]), + ), motions: new Set( [...looping] .filter(isShown) @@ -502,10 +518,20 @@ export function switcherStates( preset: string | undefined, attachments: Map, ) { - return replay(sc2, preset, attachments, false).states; + const states: Record = {}; + + for (const [hierarchy, state] of replay(sc2, preset, attachments, false) + .states) { + states[hierarchy.name] = state; + } + + return states; } -/** where a customization part ends up: its states and the motions left looping */ +/** + * where a customization part ends up: each state switcher's state and the + * motions left looping + */ export function partGarage( sc2: ReturnType, preset: string | undefined, diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts index b9b2250f5..ce4af4325 100644 --- a/packages/website/src/hooks/useSkin.ts +++ b/packages/website/src/hooks/useSkin.ts @@ -47,7 +47,7 @@ async function loadSkin(path: string) { for (const clip of gltf.animations) skin.mixer!.clipAction(clip).play(); for (const parts of skin.parts.values()) { - for (const part of parts) preparePart(part, gltf.scene.userData.states); + for (const part of parts) preparePart(part); } loadedSkins.set(path, skin); @@ -56,14 +56,11 @@ async function loadSkin(path: string) { } // parts render as they are, so what jsxTree and skinTree would do happens here -function preparePart(part: Object3D, states: Record) { +function preparePart(part: Object3D) { part.traverse((object) => { if (object.userData.state !== undefined) { - const name = nameOf(object); - const state = name in states ? states[name] : object.userData.state; - for (const child of object.children) { - child.visible = nameOf(child) === state; + child.visible = nameOf(child) === object.userData.state; } } diff --git a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts index 00ce9a45d..0ac78610b 100644 --- a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts @@ -20,10 +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 { - partGarage, - switcherStates, -} from "../../../../core/blitzkit/switcherStates"; +import { switcherStates } from "../../../../core/blitzkit/switcherStates"; import { vfs } from "../../../../core/blitzkit/vfs"; import { extractMaterials, @@ -176,12 +173,6 @@ export async function GET({ props }: APIContext<{ skin: number }>) { } const part = await extractModel(vfs, path, true, preset); - const partSc2 = new Sc2ReadStream( - (await vfs.file(`Data/3d/${path}.sc2`)).buffer as ArrayBuffer, - ).sc2(); - - Object.assign(states, partGarage(partSc2, preset).states); - const map = mergeDocuments(document, part); const partScene = map.get(part.getRoot().listScenes()[0]) as Scene; const node = document diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts index dbd96721e..fdc63216b 100644 --- a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts @@ -118,7 +118,7 @@ export async function extractModel( skin: Skin, ][] = []; const motions = new Map(); - const garageMotions = isPart ? partGarage(sc2, preset).motions : new Set(); + const garage = isPart ? partGarage(sc2, preset) : undefined; if (isPart) { const loading: Promise[] = []; @@ -173,7 +173,7 @@ export async function extractModel( skeletons.set(hierarchy, created); // only what loops in the garage is exported - if (motion && motionComponent && garageMotions.has(hierarchy)) { + if (motion && motionComponent && garage?.motions.has(hierarchy)) { createMotionClip( document, buffer, @@ -210,9 +210,9 @@ export async function extractModel( case "StateSwitcherComponent": node.setExtras({ // a part's scenarios can pick its state as it loads - state: - component[`ssc.state${component["ssc.activeState"]}`] ?? - (isPart ? null : undefined), + state: garage + ? (garage.states.get(hierarchy) ?? null) + : component[`ssc.state${component["ssc.activeState"]}`], }); break; @@ -348,7 +348,13 @@ export async function extractModel( ); for (const [owner, entity, joint, skin] of jointBindings) { - const socket = findNode(owner, entity); + // the game only binds the skeleton entity's own children + const socket = owner + .listChildren() + .find( + (child) => + child.getName() === entity && !skin.listJoints().includes(child), + ); if (!socket) throw new Error(`Missing socket ${entity}`); @@ -362,16 +368,6 @@ 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,