From a495a5f208e4eacecd9561a2e4d36c5c12f0f202 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Thu, 22 Oct 2026 16:12:03 +1300 Subject: [PATCH] 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 | 83 +++++ .../HeroSection/components/Options/index.tsx | 41 +-- .../components/TankSandbox/Title/index.tsx | 20 +- .../TankSandbox/components/Lighting.tsx | 61 +++- .../TankSandbox/components/TankModel.tsx | 10 +- packages/website/src/constants/skinNumber.ts | 1 + .../website/src/core/blitzkit/skinTree.tsx | 46 +++ packages/website/src/hooks/useSkin.ts | 82 +++++ .../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 | 3 + 17 files changed, 915 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 5c107bb9a..c5a63405c 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]; @@ -22,12 +63,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..762ed5ca8 --- /dev/null +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx @@ -0,0 +1,83 @@ +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.requestedSkin); + 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.requestedSkin = + draft.requestedSkin === 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.requestedSkin); + 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..f440de6da 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,17 @@ 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 skinPending = Tankopedia.use( + (state) => state.loadedSkin !== state.skin, + ); 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 +77,37 @@ 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]); + + useEffect(() => { + if (!skinPending) return; + + animationTime.current = TRANSITION_ANIMATION_TIME; + t0.current = performance.now() / 1e3; + + setAnimate(true); + }, [skinPending]); + return ( <> { setAnimate(false); @@ -145,11 +183,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 +222,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 +234,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) { @@ -195,6 +244,12 @@ function Animator({ stop, t0, animationTime, wrapper, hasPbr }: AnimatorProps) { draft.display = requestedDisplay; }); } + + if (x >= 1 && Tankopedia.state.skin !== Tankopedia.state.loadedSkin) { + Tankopedia.mutate((draft) => { + draft.skin = draft.loadedSkin; + }); + } }); return null; 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..22b850f5b 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,7 @@ export function TankModel() { const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id]; const { gltf } = useModel(protagonist.tank.id); const nodes = Object.values(gltf.nodes); + const skinGltf = useSkin(protagonist.tank.id); useTankTransform(track, turret, turretContainer, gunContainer); @@ -92,7 +94,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..a18c47a44 --- /dev/null +++ b/packages/website/src/hooks/useSkin.ts @@ -0,0 +1,82 @@ +import { alias } from "@blitzkit/core"; +import { useEffect } 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) { + const requestedSkin = Tankopedia.use((state) => state.requestedSkin); + const skin = Tankopedia.use((state) => state.skin); + + useEffect(() => { + const path = + requestedSkin === undefined ? undefined : skinPath(id, requestedSkin); + let settled = false; + + if (path === undefined || loadedSkins.has(path)) { + Tankopedia.mutate((draft) => { + draft.loadedSkin = requestedSkin; + }); + return; + } + + Tankopedia.mutate((draft) => { + draft.revealed = false; + }); + + if (!skins.has(path)) skins.set(path, loadSkin(path)); + + Promise.all([ + skins.get(path), + new Promise((resolve) => + setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3), + ), + ]).then(() => { + if (settled) return; + + settled = true; + Tankopedia.mutate((draft) => { + draft.loadedSkin = requestedSkin; + draft.skin = requestedSkin; + draft.revealed = true; + }); + }); + + return () => { + if (settled) return; + + settled = true; + Tankopedia.mutate((draft) => { + draft.revealed = true; + }); + }; + }, [id, requestedSkin]); + + return skin === undefined ? undefined : loadedSkins.get(skinPath(id, skin)); +} 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..04a11ee78 100644 --- a/packages/website/src/stores/tankopedia.ts +++ b/packages/website/src/stores/tankopedia.ts @@ -103,6 +103,9 @@ interface Tankopedia { ); xpMultiplier: (typeof XP_MULTIPLIERS)[number]; customShell?: ShellDefinition; + requestedSkin?: number; + loadedSkin?: number; + skin?: number; requestedDisplay: TankopediaDisplay; display: TankopediaDisplay; statSearch?: string;