From 57d77bf2b486ff3ab61782315105310da499173b Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Thu, 8 Oct 2026 20:25:03 +1300 Subject: [PATCH 01/19] Fix: calculate tank dimensions from collision models --- .../blitzkit/api/server/3_modelDefinitions.ts | 141 +++++++++++++++--- packages/core/src/blitzkit/index.ts | 1 + packages/core/src/blitzkit/tankBoundingBox.ts | 32 ++++ packages/core/src/streams/sc2.ts | 22 +++ .../components/TankSearch/components/Card.tsx | 12 +- .../src/components/TankSearch/index.tsx | 22 +-- .../src/core/blitzkit/tankCharacteristics.ts | 7 +- .../tanks/[id]/collision.glb.ts | 24 +-- .../[...chunk_api]/tanks/[id]/model.glb.ts | 22 +-- 9 files changed, 201 insertions(+), 82 deletions(-) create mode 100644 packages/core/src/blitzkit/tankBoundingBox.ts diff --git a/packages/core/src/blitzkit/api/server/3_modelDefinitions.ts b/packages/core/src/blitzkit/api/server/3_modelDefinitions.ts index 3e6f7f590..df5d3b48a 100644 --- a/packages/core/src/blitzkit/api/server/3_modelDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/3_modelDefinitions.ts @@ -4,7 +4,13 @@ import { ChassisDefinitionsList, Crew, GunDefinitionsList, + I_HAT, + J_HAT, + K_HAT, ModelDefinitions, + parseHierarchies, + Sc2ReadStream, + ScgReadStream, TankParameters, toUniqueId, TurretDefinitionsList, @@ -12,9 +18,10 @@ import { VehicleDefinitionArmor, VehicleDefinitionList, VehicleDefinitions, + VertexAttribute, } from "@blitzkit/core"; import { parse as parsePath } from "path"; -import { Vector3Tuple } from "three"; +import { MathUtils, Vector3 as THREEVector3, Vector3Tuple } from "three"; import { Cache } from "./0_base"; import { ServerBlitzKitAPI2 } from "./2_camouflageDefinitions"; @@ -23,6 +30,87 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { return { x: tuple[0], y: tuple[1], z: tuple[2] } satisfies Vector3; } + private async collisionBoundingBoxes( + nation: string, + tankKey: string, + turretRotation?: { yaw: number; pitch: number; roll: number }, + ) { + const boundingBoxes: Record< + string, + { min: Vector3Tuple; max: Vector3Tuple } + > = {}; + const path = `Data/3d/Tanks/CollisionMeshes/${nation}-${tankKey}`; + + if (!(await this.vfs.resolve(`${path}.sc2`))) { + throw new Error(`Missing collision mesh for ${nation}-${tankKey}`); + } + + const sc2 = new Sc2ReadStream( + (await this.vfs.file(`${path}.sc2`)).buffer as ArrayBuffer, + ).sc2(); + const scg = new ScgReadStream( + (await this.vfs.file(`${path}.scg`)).buffer as ArrayBuffer, + ).scg(); + + parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => { + for (const component of components) { + if (component["comp.typename"] !== "RenderComponent") continue; + + const polygonGroup = scg.get( + component["rc.renderObj"]["ro.batches"]["0000"]["rb.datasource"], + ); + + if (!polygonGroup) continue; + + const min: Vector3Tuple = [Infinity, Infinity, Infinity]; + const max: Vector3Tuple = [-Infinity, -Infinity, -Infinity]; + const rotate = + turretRotation && hierarchy.name.toLowerCase().startsWith("turret"); + + polygonGroup.vertices.forEach((vertex) => { + vertex.forEach(({ attribute, value }) => { + if (attribute !== VertexAttribute.VERTEX) return; + + const position = new THREEVector3(...value); + + if (rotate) { + position + .applyAxisAngle( + I_HAT, + -MathUtils.degToRad(turretRotation.pitch), + ) + .applyAxisAngle( + J_HAT, + -MathUtils.degToRad(turretRotation.roll), + ) + .applyAxisAngle( + K_HAT, + -MathUtils.degToRad(turretRotation.yaw), + ); + } + + const coordinates = position.toArray(); + + for (let axis = 0; axis < 3; axis++) { + min[axis] = Math.min(min[axis], coordinates[axis]); + max[axis] = Math.max(max[axis], coordinates[axis]); + } + }); + }); + + boundingBoxes[hierarchy.name.toLowerCase()] = { min, max }; + } + + return null; + }); + + if (!boundingBoxes.hull) { + throw new Error(`Missing hull collision mesh for ${nation}-${tankKey}`); + } + + return boundingBoxes; + } + private assignArmor( raw: VehicleDefinitionArmor[string], id: number, @@ -67,6 +155,12 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { const tankParameters = await this.vfs.yaml( `Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`, ); + const collisionBoundingBoxes = await this.collisionBoundingBoxes( + nation, + tankKey, + tankDefinition.root.hull.turretInitialRotation, + ); + const hullBoundingBox = collisionBoundingBoxes.hull; const turretOrigin = tankDefinition.root.hull.turretPositions.turret .split(" ") .map(Number) as Vector3Tuple; @@ -141,12 +235,8 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { } : undefined, bounding_box: { - min: this.vector3TupleToBlitzkit( - tankParameters.collision.hull.bbox.min, - ), - max: this.vector3TupleToBlitzkit( - tankParameters.collision.hull.bbox.max, - ), + min: this.vector3TupleToBlitzkit(hullBoundingBox.min), + max: this.vector3TupleToBlitzkit(hullBoundingBox.max), }, turrets: {}, tracks: {}, @@ -208,26 +298,35 @@ export abstract class ServerBlitzKitAPI3 extends ServerBlitzKitAPI2 { turret.userString; + const turretCollisionName = parsePath( + turret.hitTester.collisionModel, + ).name.toLowerCase(); + const isFullYaw = -turretYaw[0] + turretYaw[1] === 360; + + if (!collisionBoundingBoxes[turretCollisionName] && isFullYaw) { + throw new Error( + `Missing turret collision mesh for ${nation}-${tankKey} ${turretKey}`, + ); + } + + const turretBoundingBox = collisionBoundingBoxes[ + turretCollisionName + ] ?? { + min: [0, 0, 0] as Vector3Tuple, + max: [0, 0, 0] as Vector3Tuple, + }; + modelDefinitions.models[tankId].turrets[turretId] = { armor: turretArmor, gun_origin: this.vector3TupleToBlitzkit(gunOrigin), model_id: turretModel, - yaw: - -turretYaw[0] + turretYaw[1] === 360 - ? undefined - : { min: turretYaw[0], max: turretYaw[1] }, + yaw: isFullYaw + ? undefined + : { min: turretYaw[0], max: turretYaw[1] }, guns: {}, bounding_box: { - min: this.vector3TupleToBlitzkit( - tankParameters.collision[ - parsePath(turret.hitTester.collisionModel).name.toLowerCase() - ].bbox.min, - ), - max: this.vector3TupleToBlitzkit( - tankParameters.collision[ - parsePath(turret.hitTester.collisionModel).name.toLowerCase() - ].bbox.max, - ), + min: this.vector3TupleToBlitzkit(turretBoundingBox.min), + max: this.vector3TupleToBlitzkit(turretBoundingBox.max), }, }; diff --git a/packages/core/src/blitzkit/index.ts b/packages/core/src/blitzkit/index.ts index 700e57018..20298bcee 100644 --- a/packages/core/src/blitzkit/index.ts +++ b/packages/core/src/blitzkit/index.ts @@ -25,6 +25,7 @@ export * from "./sluggify"; export * from "./staticAsset"; export * from "./sum"; export * from "./tankDefinitions"; +export * from "./tankBoundingBox"; export * from "./tankIcon"; export * from "./tankNames"; export * from "./unionBoundingBox"; diff --git a/packages/core/src/blitzkit/tankBoundingBox.ts b/packages/core/src/blitzkit/tankBoundingBox.ts new file mode 100644 index 000000000..9e3408d74 --- /dev/null +++ b/packages/core/src/blitzkit/tankBoundingBox.ts @@ -0,0 +1,32 @@ +import { ModelDefinition, TurretModelDefinition } from "../protos"; +import { unionBoundingBox } from "./unionBoundingBox"; + +export function tankBoundingBox( + tankModelDefinition: ModelDefinition, + turretModelDefinition: TurretModelDefinition, +) { + const hull = tankModelDefinition.bounding_box!; + const turret = turretModelDefinition.bounding_box!; + const origin = tankModelDefinition.turret_origin!; + + if ( + turret.min!.x === turret.max!.x && + turret.min!.y === turret.max!.y && + turret.min!.z === turret.max!.z + ) { + return unionBoundingBox(hull, hull); + } + + return unionBoundingBox(hull, { + min: { + x: turret.min!.x + origin.x, + y: turret.min!.y + origin.z, + z: turret.min!.z + origin.y, + }, + max: { + x: turret.max!.x + origin.x, + y: turret.max!.y + origin.z, + z: turret.max!.z + origin.y, + }, + }); +} diff --git a/packages/core/src/streams/sc2.ts b/packages/core/src/streams/sc2.ts index 8b4837bab..73e2a2147 100644 --- a/packages/core/src/streams/sc2.ts +++ b/packages/core/src/streams/sc2.ts @@ -90,6 +90,28 @@ export interface Hierarchy { name: string; } +export function parseHierarchies( + hierarchies: Hierarchy[], + parent: Parent, + parseHierarchy: ( + hierarchy: Hierarchy, + components: Component[], + parent: Parent, + ) => Parent | undefined, +) { + for (const hierarchy of hierarchies) { + const components = Array.from( + { length: hierarchy.components.count }, + (_, index) => hierarchy.components[index.toString().padStart(4, "0")], + ); + const child = parseHierarchy(hierarchy, components, parent); + + if (child !== undefined && hierarchy["#hierarchy"]) { + parseHierarchies(hierarchy["#hierarchy"], child, parseHierarchy); + } + } +} + // 0001, 0002, 0003...: Component type Components = { count: number; diff --git a/packages/website/src/components/TankSearch/components/Card.tsx b/packages/website/src/components/TankSearch/components/Card.tsx index 97182487a..b210ef1c6 100644 --- a/packages/website/src/components/TankSearch/components/Card.tsx +++ b/packages/website/src/components/TankSearch/components/Card.tsx @@ -1,7 +1,7 @@ import { normalizeBoundingBox, resolveDpm, - unionBoundingBox, + tankBoundingBox, type TankDefinition, } from "@blitzkit/core"; import { useMemo } from "react"; @@ -112,10 +112,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) { return (tank.camouflage_still * gun.camouflage_loss * 100).toFixed(0); case "survivability.volume": { const dimensions = normalizeBoundingBox( - unionBoundingBox( - tankModelDefinition.bounding_box!, - turretModelDefinition.bounding_box!, - ), + tankBoundingBox(tankModelDefinition, turretModelDefinition), ); return (dimensions.x * dimensions.y * dimensions.z).toFixed(0); @@ -123,10 +120,7 @@ export function TankSearchCard({ tank, onSelect }: TankSearchCardProps) { case "survivability.length": { const bounds = normalizeBoundingBox( - unionBoundingBox( - tankModelDefinition.bounding_box!, - turretModelDefinition.bounding_box!, - ), + tankBoundingBox(tankModelDefinition, turretModelDefinition), ); return Math.max(bounds.x, bounds.y, bounds.z).toFixed(0); diff --git a/packages/website/src/components/TankSearch/index.tsx b/packages/website/src/components/TankSearch/index.tsx index 35321dc2c..eb8ac1ed9 100644 --- a/packages/website/src/components/TankSearch/index.tsx +++ b/packages/website/src/components/TankSearch/index.tsx @@ -3,7 +3,7 @@ import { normalizeBoundingBox, resolveDpm, SEARCH_KEYS, - unionBoundingBox, + tankBoundingBox, type TankDefinition, } from "@blitzkit/core"; import { literals } from "@blitzkit/i18n/src/literals"; @@ -137,16 +137,10 @@ export const TankSearch = memo( const bTurretModelDefinition = bTankModelDefinition.turrets[b.turrets.at(-1)!.id]; const aSize = normalizeBoundingBox( - unionBoundingBox( - aTankModelDefinition.bounding_box!, - aTurretModelDefinition.bounding_box!, - ), + tankBoundingBox(aTankModelDefinition, aTurretModelDefinition), ); const bSize = normalizeBoundingBox( - unionBoundingBox( - bTankModelDefinition.bounding_box!, - bTurretModelDefinition.bounding_box!, - ), + tankBoundingBox(bTankModelDefinition, bTurretModelDefinition), ); const aVolume = aSize.x * aSize.y * aSize.z; const bVolume = bSize.x * bSize.y * bSize.z; @@ -164,16 +158,10 @@ export const TankSearch = memo( const bTurretModelDefinition = bTankModelDefinition.turrets[b.turrets.at(-1)!.id]; const aSize = normalizeBoundingBox( - unionBoundingBox( - aTankModelDefinition.bounding_box!, - aTurretModelDefinition.bounding_box!, - ), + tankBoundingBox(aTankModelDefinition, aTurretModelDefinition), ); const bSize = normalizeBoundingBox( - unionBoundingBox( - bTankModelDefinition.bounding_box!, - bTurretModelDefinition.bounding_box!, - ), + tankBoundingBox(bTankModelDefinition, bTurretModelDefinition), ); const aLength = Math.max(aSize.x, aSize.y, aSize.z); const bLength = Math.max(bSize.x, bSize.y, bSize.z); diff --git a/packages/website/src/core/blitzkit/tankCharacteristics.ts b/packages/website/src/core/blitzkit/tankCharacteristics.ts index 51301190a..251581fc7 100644 --- a/packages/website/src/core/blitzkit/tankCharacteristics.ts +++ b/packages/website/src/core/blitzkit/tankCharacteristics.ts @@ -8,8 +8,8 @@ import { resolvePenetrationCoefficient, ShellType, sum, + tankBoundingBox, TankClass, - unionBoundingBox, type EngineDefinition, type EquipmentDefinitions, type GunDefinition, @@ -335,10 +335,7 @@ export function tankCharacteristics( ); const size = normalizeBoundingBox( - unionBoundingBox( - tankModelDefinition.bounding_box!, - turretModelDefinition.bounding_box!, - ), + tankBoundingBox(tankModelDefinition, turretModelDefinition), ); const weightKg = tank.weight + engine.weight + track.weight + turret.weight + gun.weight; diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts b/packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts index 7bff4a081..dbfd14255 100644 --- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts +++ b/packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts @@ -1,16 +1,15 @@ import { + parseHierarchies, Sc2ReadStream, ScgReadStream, toUniqueId, VertexAttribute, vertexAttributeVectorSizes, - type Hierarchy, type VehicleDefinitionList, } from "@blitzkit/core"; import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; import { Accessor, Document, Node, NodeIO, Scene } from "@gltf-transform/core"; import type { APIContext } from "astro"; -import { times } from "lodash-es"; import { vfs } from "../../../../core/blitzkit/vfs"; export { getStaticPaths } from "./_index"; @@ -56,13 +55,10 @@ async function extractArmor(vfs: AbstractVFS, fileName: string) { const scene = document.createScene(); const buffer = document.createBuffer(); - function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) { - hierarchies.forEach((hierarchy) => { - const components = times( - hierarchy.components.count, - (index) => hierarchy.components[index.toString().padStart(4, "0")], - ); - + parseHierarchies( + sc2["#hierarchy"], + scene, + (hierarchy, components, parent) => { components.forEach((component) => { switch (component["comp.typename"]) { case "TransformComponent": @@ -163,13 +159,9 @@ async function extractArmor(vfs: AbstractVFS, fileName: string) { } }); - if (hierarchy["#hierarchy"]) { - parseHierarchies(hierarchy["#hierarchy"], parent); - } - }); - } - - parseHierarchies(sc2["#hierarchy"], scene); + return parent; + }, + ); return document; } 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 6eae18f85..3b531668c 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 @@ -1,13 +1,13 @@ import { bufferToBigInt, DdsReadStream, + parseHierarchies, PvrReadStream, Sc2ReadStream, ScgReadStream, toUniqueId, VertexAttribute, type ConfigArchive, - type Hierarchy, type TankParameters, type Textures, type VehicleDefinitionList, @@ -240,8 +240,10 @@ async function extractModel(vfs: AbstractVFS, path: string) { materials.set(id, resolvedMaterial); }); - function parseHierarchies(hierarchies: Hierarchy[], parent: Scene | Node) { - hierarchies.forEach((hierarchy) => { + parseHierarchies( + sc2["#hierarchy"], + scene, + (hierarchy, components, parent) => { if ( omitMeshNames.start.some((omit) => hierarchy.name.startsWith(omit)) || omitMeshNames.end.some((omit) => hierarchy.name.endsWith(omit)) @@ -249,10 +251,6 @@ async function extractModel(vfs: AbstractVFS, path: string) { return; const node = document.createNode(hierarchy.name); - const components = times( - hierarchy.components.count, - (index) => hierarchy.components[index.toString().padStart(4, "0")], - ); components.forEach((component) => { switch (component["comp.typename"]) { @@ -361,15 +359,11 @@ async function extractModel(vfs: AbstractVFS, path: string) { } }); - if (hierarchy["#hierarchy"]) { - parseHierarchies(hierarchy["#hierarchy"], node); - } - parent.addChild(node); - }); - } - parseHierarchies(sc2["#hierarchy"], scene); + return node; + }, + ); scene.addChild(document.createNode("test")); From a365fa5e5002b3ce808730815c2f31844d603e0e Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Thu, 8 Oct 2026 20:28:02 +1300 Subject: [PATCH 02/19] Fix: migrate Imgur images to BlitzKit hosting --- .../src/blitzkit/tankDefinitions/constants.ts | 34 +------ packages/core/src/imgur/imgur.ts | 50 ----------- packages/core/src/imgur/index.ts | 1 - packages/core/src/index.ts | 1 - .../website/src/components/ArticlePlug.astro | 38 -------- packages/website/src/components/Giveaway.tsx | 89 ------------------- packages/website/src/components/Markdown.tsx | 11 ++- packages/website/src/components/StupidCat.tsx | 3 - .../website/src/core/blitzkit/docImages.ts | 32 +++++++ .../[...chunk_api]/docs/images/[id].webp.ts | 39 ++++++++ 10 files changed, 81 insertions(+), 217 deletions(-) delete mode 100644 packages/core/src/imgur/imgur.ts delete mode 100644 packages/core/src/imgur/index.ts delete mode 100644 packages/website/src/components/ArticlePlug.astro delete mode 100644 packages/website/src/components/Giveaway.tsx delete mode 100644 packages/website/src/components/StupidCat.tsx create mode 100644 packages/website/src/core/blitzkit/docImages.ts create mode 100644 packages/website/src/pages/[...chunk_api]/docs/images/[id].webp.ts diff --git a/packages/core/src/blitzkit/tankDefinitions/constants.ts b/packages/core/src/blitzkit/tankDefinitions/constants.ts index 9671b938a..a92e56743 100644 --- a/packages/core/src/blitzkit/tankDefinitions/constants.ts +++ b/packages/core/src/blitzkit/tankDefinitions/constants.ts @@ -1,5 +1,5 @@ import { times } from "lodash-es"; -import { CrewType, ShellType, TankClass, TankType } from "../../protos"; +import { CrewType, ShellType } from "../../protos"; export const SHELL_NAMES: Record = { [ShellType.SHELL_TYPE_AP]: "AP", @@ -15,33 +15,6 @@ export const CREW_MEMBER_NAMES = { [CrewType.CREW_TYPE_RADIOMAN]: "radioman", } as const; -/** - * @deprecated use svg - */ -export const TANK_ICONS: Record = { - [TankClass.TANK_CLASS_TANK_DESTROYER]: "https://i.imgur.com/BIHSEH0.png", - [TankClass.TANK_CLASS_LIGHT]: "https://i.imgur.com/CSNha5V.png", - [TankClass.TANK_CLASS_MEDIUM]: "https://i.imgur.com/wvf3ltm.png", - [TankClass.TANK_CLASS_HEAVY]: "https://i.imgur.com/ECeqlZa.png", -}; -/** - * @deprecated use svg - */ -export const TANK_ICONS_PREMIUM: Record = { - [TankClass.TANK_CLASS_TANK_DESTROYER]: "https://i.imgur.com/TCu3EdR.png", - [TankClass.TANK_CLASS_LIGHT]: "https://i.imgur.com/zdkpTRb.png", - [TankClass.TANK_CLASS_MEDIUM]: "https://i.imgur.com/3z7eHX6.png", - [TankClass.TANK_CLASS_HEAVY]: "https://i.imgur.com/P3vbmyA.png", -}; -/** - * @deprecated use svg - */ -export const TANK_ICONS_COLLECTOR: Record = { - [TankClass.TANK_CLASS_TANK_DESTROYER]: "https://i.imgur.com/WTjeirB.png", - [TankClass.TANK_CLASS_LIGHT]: "https://i.imgur.com/EwhtKkU.png", - [TankClass.TANK_CLASS_MEDIUM]: "https://i.imgur.com/u8YDMBh.png", - [TankClass.TANK_CLASS_HEAVY]: "https://i.imgur.com/8xRf3nc.png", -}; export const TIERS = times(10, (index) => index + 1); export const TIER_ROMAN_NUMERALS: Record = { 1: "I", @@ -68,8 +41,3 @@ export const flags: Record = { european: "馃嚜馃嚭", other: "<:other:1218421572243558482>", }; -export const TREE_TYPE_ICONS: Record> = { - [TankType.TANK_TYPE_RESEARCHABLE]: TANK_ICONS, - [TankType.TANK_TYPE_PREMIUM]: TANK_ICONS_PREMIUM, - [TankType.TANK_TYPE_COLLECTOR]: TANK_ICONS_COLLECTOR, -}; diff --git a/packages/core/src/imgur/imgur.ts b/packages/core/src/imgur/imgur.ts deleted file mode 100644 index 8135d678c..000000000 --- a/packages/core/src/imgur/imgur.ts +++ /dev/null @@ -1,50 +0,0 @@ -export enum ImgurSize { - /** - * The default size - */ - Default = '', - /** - * Unknown size but really really small - */ - UltraSmall = 'ss', - /** - * 90x90 - */ - SmallSquare = 's', - /** - * 160x160 - */ - BigSquare = 'b', - /** - * 160x160 - */ - SmallThumbnail = 't', - /** - * 320x320 - */ - MediumThumbnail = 'm', - /** - * 640x640 - */ - LargeThumbnail = 'l', - /** - * 1024x1024 - */ - HugeThumbnail = 'h', -} - -interface ImgurOptions { - format: 'jpeg' | 'png'; - size: ImgurSize; -} - -const defaultImgurOptions: ImgurOptions = { - format: 'png', - size: ImgurSize.Default, -}; - -export function imgur(id: string, imgurOptions?: Partial) { - const options = { ...defaultImgurOptions, ...imgurOptions }; - - return `https://i.imgur.com/${id}${options.size}.${options.format}`; -} diff --git a/packages/core/src/imgur/index.ts b/packages/core/src/imgur/index.ts deleted file mode 100644 index 6daa175fb..000000000 --- a/packages/core/src/imgur/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './imgur'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5550ecc1a..cb8712d9a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,6 +1,5 @@ export * from "./blitz"; export * from "./blitzkit"; -export * from "./imgur"; export * from "./math"; export * from "./patreon"; export * from "./protobuf"; diff --git a/packages/website/src/components/ArticlePlug.astro b/packages/website/src/components/ArticlePlug.astro deleted file mode 100644 index 4ef838462..000000000 --- a/packages/website/src/components/ArticlePlug.astro +++ /dev/null @@ -1,38 +0,0 @@ ---- -import { tankIcon } from "@blitzkit/core"; -import { Flex, Heading, Link, Strong, Text } from "@radix-ui/themes"; ---- - - - - - - - - Christmas 2025 - - Win the GSOR the TANK and more! - - - - - diff --git a/packages/website/src/components/Giveaway.tsx b/packages/website/src/components/Giveaway.tsx deleted file mode 100644 index edd64c67a..000000000 --- a/packages/website/src/components/Giveaway.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { Code, Flex, Heading, Link, Text } from "@radix-ui/themes"; -import { useEffect, useState, type ReactNode } from "react"; -import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps"; -import { InlineSkeleton } from "./InlineSkeleton"; - -const TIME = 1764898200 * 1000; - -export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) { - const [timeLeft, setTimeLeft] = useState(TIME - Date.now()); - - useEffect(() => { - const interval = setInterval(() => { - setTimeLeft(TIME - Date.now()); - }, 1000); - - return () => { - clearInterval(interval); - }; - }, []); - - let string: ReactNode; - - if (timeLeft > 0) { - const hours = Math.floor(timeLeft / (1000 * 60 * 60)); - const minutes = Math.floor((timeLeft / (1000 * 60)) % 60) - .toString() - .padStart(2, "0"); - const seconds = Math.floor((timeLeft / 1000) % 60) - .toString() - .padStart(2, "0"); - - string = ( - <> - in{" "} - - {hours}h {minutes}m {seconds}s - - - ); - } else { - string = "has ended. Come again later!"; - } - - return ( - - - - - - - - Music Box giveaway {!skeleton && string}{" "} - {skeleton && } - - - Join the BlitzKit Discord server event - - - - - - ); -} diff --git a/packages/website/src/components/Markdown.tsx b/packages/website/src/components/Markdown.tsx index cc198c317..c4ba7685f 100644 --- a/packages/website/src/components/Markdown.tsx +++ b/packages/website/src/components/Markdown.tsx @@ -1,13 +1,20 @@ import { Link, Table } from '@radix-ui/themes'; import MarkdownToJSX from 'markdown-to-jsx'; import type { ComponentProps } from 'react'; +import { docImageUrl, isExternalImage } from '../core/blitzkit/docImages'; interface MarkdownProps { children: string; } -function Image(props: ComponentProps<'img'>) { - return ; +function Image({ src, ...props }: ComponentProps<'img'>) { + return ( + + ); } export function Markdown({ children }: MarkdownProps) { diff --git a/packages/website/src/components/StupidCat.tsx b/packages/website/src/components/StupidCat.tsx deleted file mode 100644 index 86c5be34d..000000000 --- a/packages/website/src/components/StupidCat.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export function StupidCat() { - return ; -} diff --git a/packages/website/src/core/blitzkit/docImages.ts b/packages/website/src/core/blitzkit/docImages.ts new file mode 100644 index 000000000..634bc1708 --- /dev/null +++ b/packages/website/src/core/blitzkit/docImages.ts @@ -0,0 +1,32 @@ +import { alias } from "@blitzkit/core"; + +const EXTERNAL_IMAGE = /^https?:\/\//; +const MARKDOWN_IMAGES = [ + /!\[[^\]]*\]\((\S+?)(?:\s+"[^"]*")?\)/g, + /]*\ssrc=["']([^"']+)["']/g, +]; + +export function isExternalImage(src: string) { + return EXTERNAL_IMAGE.test(src); +} + +export function docImageId(url: string) { + let hash = 0x811c9dc5; + + for (let index = 0; index < url.length; index++) { + hash ^= url.charCodeAt(index); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + + return hash.toString(16).padStart(8, "0"); +} + +export function docImageUrl(url: string) { + return alias("api", `/docs/images/${docImageId(url)}.webp`); +} + +export function externalDocImages(markdown: string) { + return MARKDOWN_IMAGES.flatMap((pattern) => + Array.from(markdown.matchAll(pattern), (match) => match[1]), + ).filter(isExternalImage); +} diff --git a/packages/website/src/pages/[...chunk_api]/docs/images/[id].webp.ts b/packages/website/src/pages/[...chunk_api]/docs/images/[id].webp.ts new file mode 100644 index 000000000..0a87357f0 --- /dev/null +++ b/packages/website/src/pages/[...chunk_api]/docs/images/[id].webp.ts @@ -0,0 +1,39 @@ +import type { APIContext, GetStaticPathsItem } from "astro"; +import sharp from "sharp"; +import { + docImageId, + externalDocImages, +} from "../../../../core/blitzkit/docImages"; +import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths"; +import { getStaticPaths as _getStaticPaths } from "../../_index"; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const documents = import.meta.glob( + "../../../../../../../docs/**/*.md", + { eager: true, query: "?raw", import: "default" }, + ); + const urls = new Set(Object.values(documents).flatMap(externalDocImages)); + + return Array.from( + urls, + (url) => + ({ + params: { id: docImageId(url) }, + props: { url }, + }) satisfies GetStaticPathsItem, + ); +}); + +export async function GET({ props }: APIContext<{ url: string }>) { + const response = await fetch(props.url); + + if (!response.ok) { + throw new Error(`Failed to fetch ${props.url}: ${response.status}`); + } + + const image = await sharp(Buffer.from(await response.arrayBuffer())) + .webp() + .toBuffer(); + + return new Response(new Uint8Array(image)); +} From 5ad5c701c99958471dac50fddc2b5127166728ff Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:10:47 -0500 Subject: [PATCH 03/19] update envs for chunk splitting --- environments/beta.json | 25 ++++++++++++++++++++++--- environments/development.json | 16 +++++++++++++++- environments/opentest.json | 25 ++++++++++++++++++++++--- environments/preview.json | 25 ++++++++++++++++++++++--- environments/production.json | 23 +++++++++++++++++++++-- 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/environments/beta.json b/environments/beta.json index d5ff3f5d5..8a5bb04b1 100644 --- a/environments/beta.json +++ b/environments/beta.json @@ -4,14 +4,33 @@ "paths": { "default": null, - "api": null + + "api": null, + + "models_tanks": null, + "models_legendary_tanks": null, + "models_maps": null }, + "nodes": { "default": "blitzkit/deploy-node-beta.blitzkit.app", - "api": "blitzkit/deploy-node-api.beta.blitzkit.app" + + "api": "blitzkit/deploy-node-api.beta.blitzkit.app", + + "models_tanks": "blitzkit/deploy-node-tanks.models.beta.blitzkit.app", + "models_legendary_tanks": "blitzkit/deploy-node-legendary.models.beta.blitzkit.app", + "models_maps": "blitzkit/deploy-node-maps.models.beta.blitzkit.app" }, + "aliases": { + "default": "/", + "api": "https://api.beta.blitzkit.app", - "static": "https://static.beta.blitzkit.app" + + "models_tanks": "https://tanks.models.beta.blitzkit.app", + "models_legendary_tanks": "https://legendary.models.beta.blitzkit.app", + "models_maps": "https://maps.models.beta.blitzkit.app", + + "static": "https://static.blitzkit.app" } } diff --git a/environments/development.json b/environments/development.json index 742768ff4..e6123ad36 100644 --- a/environments/development.json +++ b/environments/development.json @@ -4,11 +4,25 @@ "paths": { "default": null, - "api": "api" + + "api": "api", + + "models_tanks": "models/tanks", + "models_legendary_tanks": "models/legendary", + "models_maps": "models/maps" }, + "nodes": {}, + "aliases": { + "default": "/", + "api": "/api", + + "models_tanks": "/models/tanks", + "models_legendary_tanks": "/models/legendary", + "models_maps": "/models/maps", + "static": "https://static.blitzkit.app" } } diff --git a/environments/opentest.json b/environments/opentest.json index 0c29b792f..89bea1ed4 100644 --- a/environments/opentest.json +++ b/environments/opentest.json @@ -4,14 +4,33 @@ "paths": { "default": null, - "api": null + + "api": null, + + "models_tanks": null, + "models_legendary_tanks": null, + "models_maps": null }, + "nodes": { "default": "blitzkit/deploy-node-opentest.blitzkit.app", - "api": "blitzkit/deploy-node-api.opentest.blitzkit.app" + + "api": "blitzkit/deploy-node-api.opentest.blitzkit.app", + + "models_tanks": "blitzkit/deploy-node-tanks.models.opentest.blitzkit.app", + "models_legendary_tanks": "blitzkit/deploy-node-legendary.models.opentest.blitzkit.app", + "models_maps": "blitzkit/deploy-node-maps.models.opentest.blitzkit.app" }, + "aliases": { + "default": "/", + "api": "https://api.opentest.blitzkit.app", - "static": "https://static.opentest.blitzkit.app" + + "models_tanks": "https://tanks.models.opentest.blitzkit.app", + "models_legendary_tanks": "https://legendary.models.opentest.blitzkit.app", + "models_maps": "https://maps.models.opentest.blitzkit.app", + + "static": "https://static.blitzkit.app" } } diff --git a/environments/preview.json b/environments/preview.json index a3d980559..4bb009e35 100644 --- a/environments/preview.json +++ b/environments/preview.json @@ -4,14 +4,33 @@ "paths": { "default": null, - "api": null + + "api": null, + + "models_tanks": null, + "models_legendary_tanks": null, + "models_maps": null }, + "nodes": { "default": "blitzkit/deploy-node-preview.blitzkit.app", - "api": "blitzkit/deploy-node-api.preview.blitzkit.app" + + "api": "blitzkit/deploy-node-api.preview.blitzkit.app", + + "models_tanks": "blitzkit/deploy-node-tanks.models.preview.blitzkit.app", + "models_legendary_tanks": "blitzkit/deploy-node-legendary.models.preview.blitzkit.app", + "models_maps": "blitzkit/deploy-node-maps.models.preview.blitzkit.app" }, + "aliases": { + "default": "/", + "api": "https://api.preview.blitzkit.app", - "static": "https://static.preview.blitzkit.app" + + "models_tanks": "https://tanks.models.preview.blitzkit.app", + "models_legendary_tanks": "https://legendary.models.preview.blitzkit.app", + "models_maps": "https://maps.models.preview.blitzkit.app", + + "static": "https://static.blitzkit.app" } } diff --git a/environments/production.json b/environments/production.json index 89d8e9a5e..debe96834 100644 --- a/environments/production.json +++ b/environments/production.json @@ -4,14 +4,33 @@ "paths": { "default": null, - "api": null + + "api": null, + + "models_tanks": null, + "models_legendary_tanks": null, + "models_maps": null }, + "nodes": { "default": "blitzkit/deploy-node-blitzkit.app", - "api": "blitzkit/deploy-node-api.blitzkit.app" + + "api": "blitzkit/deploy-node-api.blitzkit.app", + + "models_tanks": "blitzkit/deploy-node-tanks.models.blitzkit.app", + "models_legendary_tanks": "blitzkit/deploy-node-legendary.models.blitzkit.app", + "models_maps": "blitzkit/deploy-node-maps.models.blitzkit.app" }, + "aliases": { + "default": "/", + "api": "https://api.blitzkit.app", + + "models_tanks": "https://tanks.models.blitzkit.app", + "models_legendary_tanks": "https://legendary.models.blitzkit.app", + "models_maps": "https://maps.models.blitzkit.app", + "static": "https://static.blitzkit.app" } } From 12ff9a90ebb19fbb2180b11938c49c23a566f999 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:13:12 -0500 Subject: [PATCH 04/19] update matrix --- .github/workflows/website.yaml | 2 +- environments/beta.json | 6 +++--- environments/development.json | 4 ++-- environments/opentest.json | 6 +++--- environments/preview.json | 6 +++--- environments/production.json | 6 +++--- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml index 5ada3d32f..1e074eac7 100644 --- a/.github/workflows/website.yaml +++ b/.github/workflows/website.yaml @@ -93,7 +93,7 @@ jobs: strategy: fail-fast: true matrix: - chunk: [default, api] + chunk: [default, api, models_tanks, models_legendary, models_maps] steps: - name: Free disk space diff --git a/environments/beta.json b/environments/beta.json index 8a5bb04b1..c9cb9adfd 100644 --- a/environments/beta.json +++ b/environments/beta.json @@ -8,7 +8,7 @@ "api": null, "models_tanks": null, - "models_legendary_tanks": null, + "models_legendary": null, "models_maps": null }, @@ -18,7 +18,7 @@ "api": "blitzkit/deploy-node-api.beta.blitzkit.app", "models_tanks": "blitzkit/deploy-node-tanks.models.beta.blitzkit.app", - "models_legendary_tanks": "blitzkit/deploy-node-legendary.models.beta.blitzkit.app", + "models_legendary": "blitzkit/deploy-node-legendary.models.beta.blitzkit.app", "models_maps": "blitzkit/deploy-node-maps.models.beta.blitzkit.app" }, @@ -28,7 +28,7 @@ "api": "https://api.beta.blitzkit.app", "models_tanks": "https://tanks.models.beta.blitzkit.app", - "models_legendary_tanks": "https://legendary.models.beta.blitzkit.app", + "models_legendary": "https://legendary.models.beta.blitzkit.app", "models_maps": "https://maps.models.beta.blitzkit.app", "static": "https://static.blitzkit.app" diff --git a/environments/development.json b/environments/development.json index e6123ad36..fbeeab959 100644 --- a/environments/development.json +++ b/environments/development.json @@ -8,7 +8,7 @@ "api": "api", "models_tanks": "models/tanks", - "models_legendary_tanks": "models/legendary", + "models_legendary": "models/legendary", "models_maps": "models/maps" }, @@ -20,7 +20,7 @@ "api": "/api", "models_tanks": "/models/tanks", - "models_legendary_tanks": "/models/legendary", + "models_legendary": "/models/legendary", "models_maps": "/models/maps", "static": "https://static.blitzkit.app" diff --git a/environments/opentest.json b/environments/opentest.json index 89bea1ed4..8aa51ea16 100644 --- a/environments/opentest.json +++ b/environments/opentest.json @@ -8,7 +8,7 @@ "api": null, "models_tanks": null, - "models_legendary_tanks": null, + "models_legendary": null, "models_maps": null }, @@ -18,7 +18,7 @@ "api": "blitzkit/deploy-node-api.opentest.blitzkit.app", "models_tanks": "blitzkit/deploy-node-tanks.models.opentest.blitzkit.app", - "models_legendary_tanks": "blitzkit/deploy-node-legendary.models.opentest.blitzkit.app", + "models_legendary": "blitzkit/deploy-node-legendary.models.opentest.blitzkit.app", "models_maps": "blitzkit/deploy-node-maps.models.opentest.blitzkit.app" }, @@ -28,7 +28,7 @@ "api": "https://api.opentest.blitzkit.app", "models_tanks": "https://tanks.models.opentest.blitzkit.app", - "models_legendary_tanks": "https://legendary.models.opentest.blitzkit.app", + "models_legendary": "https://legendary.models.opentest.blitzkit.app", "models_maps": "https://maps.models.opentest.blitzkit.app", "static": "https://static.blitzkit.app" diff --git a/environments/preview.json b/environments/preview.json index 4bb009e35..bcc9b2644 100644 --- a/environments/preview.json +++ b/environments/preview.json @@ -8,7 +8,7 @@ "api": null, "models_tanks": null, - "models_legendary_tanks": null, + "models_legendary": null, "models_maps": null }, @@ -18,7 +18,7 @@ "api": "blitzkit/deploy-node-api.preview.blitzkit.app", "models_tanks": "blitzkit/deploy-node-tanks.models.preview.blitzkit.app", - "models_legendary_tanks": "blitzkit/deploy-node-legendary.models.preview.blitzkit.app", + "models_legendary": "blitzkit/deploy-node-legendary.models.preview.blitzkit.app", "models_maps": "blitzkit/deploy-node-maps.models.preview.blitzkit.app" }, @@ -28,7 +28,7 @@ "api": "https://api.preview.blitzkit.app", "models_tanks": "https://tanks.models.preview.blitzkit.app", - "models_legendary_tanks": "https://legendary.models.preview.blitzkit.app", + "models_legendary": "https://legendary.models.preview.blitzkit.app", "models_maps": "https://maps.models.preview.blitzkit.app", "static": "https://static.blitzkit.app" diff --git a/environments/production.json b/environments/production.json index debe96834..d66b4c7ad 100644 --- a/environments/production.json +++ b/environments/production.json @@ -8,7 +8,7 @@ "api": null, "models_tanks": null, - "models_legendary_tanks": null, + "models_legendary": null, "models_maps": null }, @@ -18,7 +18,7 @@ "api": "blitzkit/deploy-node-api.blitzkit.app", "models_tanks": "blitzkit/deploy-node-tanks.models.blitzkit.app", - "models_legendary_tanks": "blitzkit/deploy-node-legendary.models.blitzkit.app", + "models_legendary": "blitzkit/deploy-node-legendary.models.blitzkit.app", "models_maps": "blitzkit/deploy-node-maps.models.blitzkit.app" }, @@ -28,7 +28,7 @@ "api": "https://api.blitzkit.app", "models_tanks": "https://tanks.models.blitzkit.app", - "models_legendary_tanks": "https://legendary.models.blitzkit.app", + "models_legendary": "https://legendary.models.blitzkit.app", "models_maps": "https://maps.models.blitzkit.app", "static": "https://static.blitzkit.app" From 1258cd1fa2ab5cbcc741dba0eec7d77bdbf05fc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=C3=A8sAbhi?= <43380238+tresabhi@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:59:07 -0500 Subject: [PATCH 05/19] New Crowdin updates (#399) --- packages/i18n/strings/pl.json | 202 +++++++++++++++++----------------- packages/i18n/strings/pt.json | 10 +- 2 files changed, 106 insertions(+), 106 deletions(-) diff --git a/packages/i18n/strings/pl.json b/packages/i18n/strings/pl.json index de8d7a5d0..dc8c72f22 100644 --- a/packages/i18n/strings/pl.json +++ b/packages/i18n/strings/pl.json @@ -220,190 +220,190 @@ "clear": "Clear", "tier": "Tier {tier}", "passive": "{name} (Passive)", - "active": "{name} (Active)", + "active": "{name} (Aktywne)", "no_tanks_found": { - "body": "No tanks found.", + "body": "Nie znaleziono czo艂gu.", "search_again": "", "clear_filters": "Try clearing filters." }, "sort": { - "meta.none": "none", - "survivability.health": "health", - "survivability.viewRange": "view range", - "survivability.camouflageStill": "camouflage still", - "survivability.camouflageMoving": "camouflage moving", - "survivability.camouflageShooting": "camouflage shooting", - "survivability.volume": "volume", - "survivability.length": "length", - "fire.dpm": "standard DPM", - "fire.dpmPremium": "premium DPM", - "fire.reload": "reload", - "fire.caliber": "caliber", - "fire.standardPenetration": "standard penetration", - "fire.premiumPenetration": "premium penetration", + "meta.none": "brak", + "survivability.health": "punkty.偶ycia", + "survivability.viewRange": "zasi臋g widzenia", + "survivability.camouflageStill": "kamufla偶 w bezruchu", + "survivability.camouflageMoving": "kamufla偶 w trakcie ruchu", + "survivability.camouflageShooting": "kamufla偶 podczas strza艂u", + "survivability.volume": "warto艣膰", + "survivability.length": "d艂ugo艣膰", + "fire.dpm": "DPM pocisk贸w podstawowych", + "fire.dpmPremium": "DPM pocisk贸w premium", + "fire.reload": "prze艂adowanie", + "fire.caliber": "kaliber", + "fire.standardPenetration": "Penetracja pocisk贸w standardowych", + "fire.premiumPenetration": "Penetracja pocisk贸w premium", "fire.tertiaryPenetration": "tertiary penetration", - "fire.damage": "damage", - "fire.moduleDamage": "module damage", - "fire.shellVelocity": "shell velocity", - "fire.shellRange": "shell range", - "fire.shellCapacity": "shell capacity", - "fire.aimTime": "aim time", + "fire.damage": "uszkodzenia", + "fire.moduleDamage": "Uszkodzenia modu艂贸w", + "fire.shellVelocity": "Pr臋dko艣膰 pocisku", + "fire.shellRange": "Zasi臋g pocisku", + "fire.shellCapacity": "Maksymalna ilo艣膰 pocisk贸w", + "fire.aimTime": "Czas celowania", "fire.dispersionStill": "dispersion still", "fire.dispersionMoving": "dispersion moving", - "fire.gunDepression": "gun depression", - "fire.gunElevation": "gun elevation", - "maneuverability.forwardsSpeed": "forwards speed", - "maneuverability.backwardsSpeed": "backwards speed", - "maneuverability.power": "power", - "maneuverability.powerToWeight": "power to weight", - "maneuverability.weight": "weight", + "fire.gunDepression": "Depresja dzia艂a", + "fire.gunElevation": "Elewacja dzia艂a", + "maneuverability.forwardsSpeed": "Maksymalna pr臋dko艣膰 do przodu", + "maneuverability.backwardsSpeed": "Maksymalna pr臋dko艣膰 do ty艂u", + "maneuverability.power": "Moc silnika", + "maneuverability.powerToWeight": "Proporcja mocy do masy", + "maneuverability.weight": "Masa czo艂gu", "maneuverability.traverseSpeed": "traverse speed" }, "sort_dropdown": { - "label": "Sort", + "label": "Filtruj", "groups": { "sort": { - "no_sort": "No sort", - "label": "Sort by", - "fire": "Fire", - "maneuverability": "Maneuverability", - "survivability": "Survivability" + "no_sort": "Brak filtr贸w", + "label": "Filtruj wed艂ug", + "fire": "Si艂a ognia", + "maneuverability": "Mobilno艣膰 ", + "survivability": "Przetrwanie" }, "direction": { "label": "Direction", - "ascending": "Ascending", - "descending": "Descending" + "ascending": "Rosn膮co", + "descending": "Malej膮co" } } } }, "warnings": { - "test": "Tanks in testing are subject to change and may not represent the final product." + "test": "Warto艣ci statystyk czo艂g贸w w fazie testowej mog膮 ulec zmianie." } }, "home": { "discord": { - "join_short": "Join cool people!", - "join_long": "Join super duper cool people!", + "join_short": "Do艂膮cz do elity!", + "join_long": "Do艂膮cz do topowej elity!", "action": "Discord" }, "hero": { - "credit": "By {name} 鉂わ笍" + "credit": "Wed艂ug {name} 鈾ワ笍" }, "hot_tanks": { - "title": "Popular tanks", - "subtitle": "Last {days} days" + "title": "Popularne czo艂gi", + "subtitle": "Ostatnie {days} dni" }, "other_projects": { - "title": "Other cool projects" + "title": "Inne epickie projekty" }, - "help_translate": "Please help translate {name} to {language}!", - "seo_description": "Tools for everything World of Tanks Blitz" + "help_translate": "Prosz臋 pom贸偶 przet艂umaczy膰 {name} na {language}!", + "seo_description": "Narz臋dzia do wszystkiego World of Tanks Blitz" }, "docs": { "patches": { - "name": "BlitzKit Changelogs", - "seo_description": "Changelogs for BlitzKit Discord bot and website" + "name": "Zapisy zmian BlitzKit", + "seo_description": "Zapisy zmian BlitzKit: Discord bot i strona internetowa" } }, "tools": { "playlist": { - "reset": "Reset", - "shuffle": "Shuffle", - "remove": "Remove played", - "tracking": "BlitzKit is tracking your battles", + "reset": "Resetuj", + "shuffle": "Wymieszaj", + "remove": "Usu艅 czo艂gi, kt贸rymi gra艂em", + "tracking": "BlitzKit zapisuje twoje bitwy", "sort": { - "button": "Sort by", - "last_played": "Last played", - "least_played": "Least played" + "button": "Filtruj wed艂ug", + "last_played": "Ostatnio u偶ywane czo艂gi", + "least_played": "Najmniej u偶ywane czo艂gi" }, "table": { "index": "#", - "never": "Never", - "tank": "Tank", - "last_played": "Last played", - "battles": "Career battles", + "never": "Nigdy", + "tank": "Czo艂g", + "last_played": "Ostatnio u偶ywane czo艂gi", + "battles": "Wszystkie bitwy na koncie", "unrecorded": "--", "wins": "Zwyci臋stwa" }, "large_warning": { - "title": "That's a lot of tanks!", - "description": "Are you sure you want to create a playlist of {count} tanks? That's {time} hours of gameplay at 50% winrate!", - "cancel": "Cancel", - "proceed": "Proceed" + "title": "To jest mn贸stwo czo艂g贸w!", + "description": "Czy na pewno chcesz stworzy膰 playlist臋 z艂o偶on膮 z {count} czo艂g贸w? To {time} godzin gry przy 50% zwyci臋stw!", + "cancel": "Anuluj", + "proceed": "Zatwierd藕" }, - "generate": "Generate playlist", - "name": "Playlist", - "description": "Tank playlist generator", - "seo_description": "Don't know what to play in World of Tanks Blitz? Use BlitzKit Playlist to get suggestions!", - "button": "Generate", - "pool_size": "{count} possible tanks" + "generate": "Wygeneruj playlist臋", + "name": "Playlista", + "description": "Generator playlisty czo艂g贸w", + "seo_description": "Nie wiesz, kt贸rym czo艂giem gra膰 w World of Tanks Blitz? U偶yj funkcji playlisty BlitzKit, i u艂贸偶 w艂asn膮 list臋 czo艂g贸w do gry!", + "button": "Wygeneruj", + "pool_size": "{count} mo偶liwe czo艂gi" }, "mixer": { - "name": "Mixer", + "name": "Mikser", "button": "Mix and match", "description": "Mix tank modules", "seo_description": "Mix and match hulls, turrets, and guns of tanks in World of Tanks Blitz", - "screenshot": "Screenshot", - "randomize": "Randomize" + "screenshot": "Zrzut ekranu", + "randomize": "Wymieszaj" }, "guess": { - "name": "Guess", + "name": "Zgadnij", "button": "What could it be?", "description": "Silhouette mini-game", "seo_description": "Play a tank guessing game with a silhouette in World of Tanks Blitz", "search": { - "placeholder": "Type to guess...", - "skip": "Skip", - "guess": "Guess", - "next": "Next", + "placeholder": "Wpisz odpowied藕", + "skip": "Pomi艅", + "guess": "Zgaduj臋", + "next": "Nast臋pny", "no_results": "Brak wynik贸w" }, "stats": "{correct} / {total} correct, {streak} streak", "help": { - "title": "Partially reveal tank?", + "title": "Ods艂oni膰 cz臋艣膰 czo艂gu?", "description": "Reveals the tank without showing the name. This round will be considered incorrect and break your streak!", - "cancel": "Cancel", - "reveal": "Reveal" + "cancel": "Anuluj", + "reveal": "Poka偶" }, - "clear": "Clear", - "select_all": "Select all", - "tier": "Tier {tier}" + "clear": "Wyczy艣膰", + "select_all": "Zaznacz wszystko ", + "tier": "Poziom czo艂gu {tier}" }, "tankopedia": { - "search": "Search a stat...", + "search": "Wyszukaj statystyk臋...", "name": "Tankopedia", - "button": "Find tanks", - "description": "Blitz tank encyclopedia", + "button": "Znajd藕 czo艂g", + "description": "Encyklopedia czo艂g贸w w WoTb", "seo_description": "Statistics, armor, and more for all tanks in World of Tanks Blitz", "tank_seo_title": "{name} - Tier {tier} {nationality} {class} | {website} Tankopedia | World of Tanks Blitz (WoTB)", "tank_seo_description": "{name} armor, statistics, equipment, provisions, and more. {dpm} DPM, {alpha}hp alpha, {penetration}mm penetration, {speed}kph top speed, {health}hp health.", "visualizers": { "reload": { - "skip": "Skip" + "skip": "Pomi艅" }, "assault": { "title": "Assault shells" }, "rotator": { - "hull": "Hull", - "turret": "Turret", - "hard": "Hard", + "hull": "Kad艂ub", + "turret": "Wie偶a", + "hard": "Utwardzony", "medium": "艢redni", - "soft": "Soft", - "effective": "Effective" + "soft": "Mi臋kki", + "effective": "Rzeczywisty" }, "ricochet": { - "angle": "Angle", + "angle": "K膮t", "normalization": "Normalization", - "effective_angle": "Effective angle", - "armor": "Armor", - "no_ricochet": "Explosive shells don't ricochet" + "effective_angle": "Rzeczywisty k膮t nachylenia pancerza", + "armor": "Pancerz", + "no_ricochet": "Pociski od艂amkowe nie rykoszetuj膮." }, "view_range": { - "camo": "Camo", - "ghost_factory": "Ghost Factory", - "dead_rail": "Dead Rail" + "camo": "Kamufla偶", + "ghost_factory": "Fabryka duch贸w", + "dead_rail": "Martwy tor" }, "flexibility": { "yaw": "Yaw", @@ -656,10 +656,10 @@ "loader": "Loader" }, "maneuverability": { - "title": "Maneuverability" + "title": "Mobilno艣膰 " }, "survivability": { - "title": "Survivability" + "title": "Przetrwanie" } }, "compare": { diff --git a/packages/i18n/strings/pt.json b/packages/i18n/strings/pt.json index 197d34a38..f11d04508 100644 --- a/packages/i18n/strings/pt.json +++ b/packages/i18n/strings/pt.json @@ -41,7 +41,7 @@ "development": "Development", "opentest": "TesteAberto", "preview": "Preview", - "production": "Production" + "production": "Produ莽茫o" }, "locales": { "en": "Ingl锚s", @@ -187,11 +187,11 @@ }, "website": { "redirect": { - "title": "301: moved permanently", + "title": "301: movido permanentemente", "body": "This page has moved. Redirecting..." }, "settings": { - "title": "Settings", + "title": "Configura莽玫es", "description": "Change BlitzKit settings", "dev_mode": "Modo de desenvolvimento", "auto_load_models": "Auto-load models", @@ -207,9 +207,9 @@ "copied": "Copiado!" }, "tank_search": { - "select_dialog_title": "Search for tanks", + "select_dialog_title": "Procura por tanque", "swap_dialog_title": "Swap tank", - "clear_filters": "Clear filters", + "clear_filters": "Limpar filtros", "login": "Login to use this", "search_bar_hint": "Procurar tanques...", "recent": "Visto recentemente", From b9388cc544e96a23e349ad2cab239db74bc9f3fb Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:08:25 -0500 Subject: [PATCH 06/19] add basic xi compatibility --- .../core/src/blitzkit/api/server/0_base.ts | 21 ++++++++++++++++++ .../blitzkit/api/server/1_tankDefinitions.ts | 22 ++++++++++++++++--- .../api/server/2_camouflageDefinitions.ts | 2 ++ .../api/server/6_consumableDefinitions.ts | 8 ++----- .../api/server/7_provisionDefinitions.ts | 10 +++------ packages/core/src/types/gunDefinitionsList.ts | 12 +++++----- .../core/src/types/shellDefinitionsList.ts | 3 +++ .../src/core/blitzkit/tankToDuelMember.ts | 17 +++++++++++--- .../[...locale]/tanks/[slug]/index.astro | 5 ++--- 9 files changed, 73 insertions(+), 27 deletions(-) diff --git a/packages/core/src/blitzkit/api/server/0_base.ts b/packages/core/src/blitzkit/api/server/0_base.ts index 2efff35ec..fd522a898 100644 --- a/packages/core/src/blitzkit/api/server/0_base.ts +++ b/packages/core/src/blitzkit/api/server/0_base.ts @@ -246,6 +246,27 @@ export abstract class ServerBlitzKitAPI0 extends BlitzKitAPI { return this; } + protected getTankFilterIds( + name: string, + definitionKey: string, + filter: "include" | "exclude", + ) { + const keys = name.trim().split(/\s+/).filter(Boolean); + + return keys.flatMap((key) => { + const id = this.tankStringIdMap[key]; + + if (id === undefined) { + console.warn( + `Skipping unavailable tank "${key}" in ${filter} vehicle filter for "${definitionKey}"`, + ); + return []; + } + + return [id]; + }); + } + protected getString(name: string) { const collection: Record = { [locales.default]: this.stringsI18n[locales.default][name], diff --git a/packages/core/src/blitzkit/api/server/1_tankDefinitions.ts b/packages/core/src/blitzkit/api/server/1_tankDefinitions.ts index 9e211001b..432672ee1 100644 --- a/packages/core/src/blitzkit/api/server/1_tankDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/1_tankDefinitions.ts @@ -595,9 +595,25 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { for (const shellKey in gunListEntry.shots) { const gunShellEntry = gunListEntry.shots[shellKey]; const shell = shellList.root[shellKey]; + + // Newer client data stores shot properties on the shell rather + // than repeating them under every gun's entry. + const speed = + typeof gunShellEntry === "string" + ? shell.speed + : gunShellEntry.speed; + const maxDistance = + typeof gunShellEntry === "string" + ? shell.maxDistance + : gunShellEntry.maxDistance; + const piercingPower = + typeof gunShellEntry === "string" + ? shell.piercingPower + : gunShellEntry.piercingPower; + const shellId = toUniqueId(nation, shell.id); const shellName = this.getString(shell.userString); - const penetrationRaw = gunShellEntry.piercingPower + const penetrationRaw = piercingPower .split(" ") .filter((penetrationString) => penetrationString !== "") .map(Number); @@ -607,7 +623,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { ].shells.push({ id: shellId, name: shellName, - velocity: gunShellEntry.speed, + velocity: speed, armor_damage: shell.damage.armor, module_damage: shell.damage.devices, caliber: shell.caliber, @@ -623,7 +639,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { near: penetrationRaw[0], far: penetrationRaw[1], }, - range: gunShellEntry.maxDistance, + range: maxDistance, }); } diff --git a/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts b/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts index 30ce31d6a..5c107bb9a 100644 --- a/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/2_camouflageDefinitions.ts @@ -10,6 +10,8 @@ export abstract class ServerBlitzKitAPI2 extends ServerBlitzKitAPI1 { for (const camoKey in this.camouflagesXml!.root.camouflages) { const camo = this.camouflagesXml!.root.camouflages[camoKey]; + if (!(camoKey in this.camouflagesYaml!)) continue; + const yamlEntry = this.camouflagesYaml![camoKey]; const fullName = yamlEntry.userString ? this.getString(yamlEntry.userString) diff --git a/packages/core/src/blitzkit/api/server/6_consumableDefinitions.ts b/packages/core/src/blitzkit/api/server/6_consumableDefinitions.ts index f4b333f61..d12b7612d 100644 --- a/packages/core/src/blitzkit/api/server/6_consumableDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/6_consumableDefinitions.ts @@ -44,9 +44,7 @@ export abstract class ServerBlitzKitAPI6 extends ServerBlitzKitAPI5 { filter_type: { $case: "ids", value: { - ids: includeRaw.name.split(/ +/).map((key) => { - return this.tankStringIdMap[key]; - }), + ids: this.getTankFilterIds(includeRaw.name, key, "include"), }, }, }); @@ -72,9 +70,7 @@ export abstract class ServerBlitzKitAPI6 extends ServerBlitzKitAPI5 { filter_type: { $case: "ids", value: { - ids: excludeRaw.name.split(/ +/).map((key) => { - return this.tankStringIdMap[key]; - }), + ids: this.getTankFilterIds(excludeRaw.name, key, "exclude"), }, }, }); diff --git a/packages/core/src/blitzkit/api/server/7_provisionDefinitions.ts b/packages/core/src/blitzkit/api/server/7_provisionDefinitions.ts index 32db2bcc1..d7baf6135 100644 --- a/packages/core/src/blitzkit/api/server/7_provisionDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/7_provisionDefinitions.ts @@ -11,7 +11,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 { async provisionDefinitions() { const provisionDefinitions = ProvisionDefinitions.create(); - Object.entries(this.provisionsCommon).forEach(([, provision]) => { + Object.entries(this.provisionsCommon).forEach(([key, provision]) => { const entry: Provision = { id: provision.id, exclude: [], @@ -42,9 +42,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 { filter_type: { $case: "ids", value: { - ids: includeRaw.name.split(/ +/).map((key) => { - return this.tankStringIdMap[key]; - }), + ids: this.getTankFilterIds(includeRaw.name, key, "include"), }, }, }); @@ -70,9 +68,7 @@ export abstract class ServerBlitzKitAPI7 extends ServerBlitzKitAPI6 { filter_type: { $case: "ids", value: { - ids: excludeRaw.name - .split(/ +/) - .map((key) => this.tankStringIdMap[key]), + ids: this.getTankFilterIds(excludeRaw.name, key, "exclude"), }, }, }); diff --git a/packages/core/src/types/gunDefinitionsList.ts b/packages/core/src/types/gunDefinitionsList.ts index 005238d10..51b2c939d 100644 --- a/packages/core/src/types/gunDefinitionsList.ts +++ b/packages/core/src/types/gunDefinitionsList.ts @@ -20,11 +20,13 @@ export interface GunDefinitionsList { burst?: { count: number; rate: number }; clip?: { count: number; rate: number }; shots: { - [key: string]: { - speed: number; - piercingPower: string; - maxDistance: number; - }; + [key: string]: + | "" + | { + speed: number; + piercingPower: string; + maxDistance: number; + }; }; }; }; diff --git a/packages/core/src/types/shellDefinitionsList.ts b/packages/core/src/types/shellDefinitionsList.ts index d9e5f3e6e..e8ec812c1 100644 --- a/packages/core/src/types/shellDefinitionsList.ts +++ b/packages/core/src/types/shellDefinitionsList.ts @@ -10,6 +10,9 @@ export type ShellDefinitionsList = Record< normalizationAngle?: number; ricochetAngle?: number; explosionRadius?: number; + speed: number; + maxDistance?: number; + piercingPower: string; } > & { icons: Record; diff --git a/packages/website/src/core/blitzkit/tankToDuelMember.ts b/packages/website/src/core/blitzkit/tankToDuelMember.ts index 34f0aec50..b1045042e 100644 --- a/packages/website/src/core/blitzkit/tankToDuelMember.ts +++ b/packages/website/src/core/blitzkit/tankToDuelMember.ts @@ -7,12 +7,23 @@ import { import type { DuelMember } from "../../stores/duel"; import { genericDefaultEquipmentMatrix } from "../../stores/duel/constants"; +export function defaultTankWeapon(tank: TankDefinition) { + for (const turret of [...tank.turrets].reverse()) { + for (const gun of [...turret.guns].reverse()) { + const shell = gun.shells[0]; + + if (shell) return { turret, gun, shell }; + } + } + + throw new Error(`Tank ${tank.id} has no gun with shells`); +} + export function tankToDuelMember( tank: TankDefinition, provisionDefinitions: ProvisionDefinitions, ) { - const turret = tank.turrets.at(-1)!; - const gun = turret.guns.at(-1)!; + const { turret, gun, shell } = defaultTankWeapon(tank); return { camouflage: true, @@ -22,7 +33,7 @@ export function tankToDuelMember( provisions: createDefaultProvisions(tank, gun, provisionDefinitions), engine: tank.engines.at(-1)!, gun, - shell: tank.turrets.at(-1)!.guns.at(-1)!.shells[0], + shell, tank, track: tank.tracks.at(-1)!, turret, diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/index.astro b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/index.astro index 67727b72b..dcfde0699 100644 --- a/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/index.astro +++ b/packages/website/src/pages/[...chunk_default]/[...locale]/tanks/[slug]/index.astro @@ -13,6 +13,7 @@ import { load } from "cheerio"; import { api } from "../../../../../core/blitzkit/api"; import { mixStaticPaths } from "../../../../../core/blitzkit/mixStaticPaths"; import { tankCharacteristics } from "../../../../../core/blitzkit/tankCharacteristics"; +import { defaultTankWeapon } from "../../../../../core/blitzkit/tankToDuelMember"; import { getStrings } from "../../../../../core/i18n/getStrings"; import BaseLayout from "../../../../../layouts/BaseLayout.astro"; import { genericDefaultEquipmentMatrix } from "../../../../../stores/duel/constants"; @@ -53,9 +54,7 @@ const title = literals(strings.website.tools.tankopedia.tank_seo_title, { const url = import.meta.env.SITE + Astro.url.pathname; const icon = alias("api", `/tanks/${id}/icons/big.webp`); -const turret = tank.turrets.at(-1)!; -const gun = turret.guns.at(-1)!; -const shell = gun.shells[0]; +const { turret, gun, shell } = defaultTankWeapon(tank); const characteristics = tankCharacteristics( { tank, From 51900f4c808a4776c0fb28be8469e23b0881e333 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:22:20 -0500 Subject: [PATCH 07/19] countdown --- .../{Giveaway.tsx => Countdown.tsx} | 20 +++++++++---------- .../[...locale]/index.astro | 3 +++ 2 files changed, 13 insertions(+), 10 deletions(-) rename packages/website/src/components/{Giveaway.tsx => Countdown.tsx} (79%) diff --git a/packages/website/src/components/Giveaway.tsx b/packages/website/src/components/Countdown.tsx similarity index 79% rename from packages/website/src/components/Giveaway.tsx rename to packages/website/src/components/Countdown.tsx index edd64c67a..fac933852 100644 --- a/packages/website/src/components/Giveaway.tsx +++ b/packages/website/src/components/Countdown.tsx @@ -3,9 +3,9 @@ import { useEffect, useState, type ReactNode } from "react"; import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps"; import { InlineSkeleton } from "./InlineSkeleton"; -const TIME = 1764898200 * 1000; +const TIME = 1792598400 * 1000; -export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) { +export function Countdown({ skeleton }: MaybeSkeletonComponentProps) { const [timeLeft, setTimeLeft] = useState(TIME - Date.now()); useEffect(() => { @@ -31,14 +31,14 @@ export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) { string = ( <> - in{" "} + revealed in{" "} {hours}h {minutes}m {seconds}s ); } else { - string = "has ended. Come again later!"; + string = "have been revealed."; } return ( @@ -47,7 +47,7 @@ export function Giveaway({ skeleton }: MaybeSkeletonComponentProps) { color="gray" highContrast style={{ display: "content" }} - href="https://discord.gg/njQdBhNS?event=1446208951136813117" + href="/tanks" underline="none" > - Music Box giveaway {!skeleton && string}{" "} + Tier XIs {!skeleton && string}{" "} {skeleton && } - Join the BlitzKit Discord server event + Get ready to go to the Tankopedia! diff --git a/packages/website/src/pages/[...chunk_default]/[...locale]/index.astro b/packages/website/src/pages/[...chunk_default]/[...locale]/index.astro index 21a5a0d8f..48e147252 100644 --- a/packages/website/src/pages/[...chunk_default]/[...locale]/index.astro +++ b/packages/website/src/pages/[...chunk_default]/[...locale]/index.astro @@ -5,6 +5,7 @@ import locales from "@blitzkit/i18n/locales.json"; import { literals } from "@blitzkit/i18n/src/literals"; import { Box, Flex, Link } from "@radix-ui/themes"; import { AesonPlug } from "../../../components/AesonPlug"; +import { Countdown } from "../../../components/Countdown"; import HomeHero from "../../../components/HomeHero.astro"; import { Plugs } from "../../../components/Plugs"; import ToolCard from "../../../components/ToolCard.astro"; @@ -65,6 +66,8 @@ export const getStaticPaths = mixStaticPaths(_getStaticPaths, () => { + + { locale !== locales.default && ( From 7df53d845da49e6cd4e83f32cf899465fa038f37 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:12:03 -0500 Subject: [PATCH 08/19] vfs debug --- package.json | 3 ++- packages/scripts/package.json | 3 ++- packages/scripts/src/vfs/index.ts | 23 +++++++++++++++++++++++ 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 packages/scripts/src/vfs/index.ts diff --git a/package.json b/package.json index d8dc478c8..65b13bde0 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,8 @@ "build-protos": "cd packages/scripts && bun run build-protos", "record-performance": "cd packages/scripts && bun run record-performance", "download-client": "cd packages/scripts && bun run download-client", - "clean-astro": "rm -rf packages/website/.astro packages/website/node_modules/.astro packages/website/node_modules/.vite" + "clean-astro": "rm -rf packages/website/.astro packages/website/node_modules/.astro packages/website/node_modules/.vite", + "vfs": "cd packages/scripts && bun run vfs" }, "type": "module", "workspaces": [ diff --git a/packages/scripts/package.json b/packages/scripts/package.json index 70765e5f0..55077bcb2 100644 --- a/packages/scripts/package.json +++ b/packages/scripts/package.json @@ -5,7 +5,8 @@ "discover-ids": "bun src/discoverIds/index.ts", "build-protos": "bun src/protos.ts", "record-performance": "bun src/recordPerformance/index.ts", - "download-client": "bun src/downloadClient/index.ts" + "download-client": "bun src/downloadClient/index.ts", + "vfs": "bun src/vfs/index.ts" }, "dependencies": { "@blitzkit/closed": "workspace:*", diff --git a/packages/scripts/src/vfs/index.ts b/packages/scripts/src/vfs/index.ts new file mode 100644 index 000000000..dd0358c0d --- /dev/null +++ b/packages/scripts/src/vfs/index.ts @@ -0,0 +1,23 @@ +import { assertSecret } from "@blitzkit/core"; +import { LocalVFS } from "@blitzkit/core/src/blitzkit/vfs/local"; + +export const vfs = new LocalVFS(assertSecret(import.meta.env.CLIENT_DIR)); + +const command = Bun.argv[2]; +const path = Bun.argv[3]; + +if (!path) { + console.error("Gimme a path to assess dumb ass"); + process.exit(1); +} + +if (command === "cat") { + const content = await vfs.file(path); + await Bun.write(Bun.stdout, content); +} else if (command === "ls") { + const files = await vfs.dir(path); + + for (const file of files) { + console.log(file); + } +} From 273fea159929c0bfc7fdbb133b6d3d941f111fbe Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:36:06 -0500 Subject: [PATCH 09/19] promote preview --- packages/website/src/components/Countdown.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/website/src/components/Countdown.tsx b/packages/website/src/components/Countdown.tsx index fac933852..77468ccc8 100644 --- a/packages/website/src/components/Countdown.tsx +++ b/packages/website/src/components/Countdown.tsx @@ -47,7 +47,7 @@ export function Countdown({ skeleton }: MaybeSkeletonComponentProps) { color="gray" highContrast style={{ display: "content" }} - href="/tanks" + href="https://preview.blitzkit.app/tanks" underline="none" > Date: Wed, 21 Oct 2026 09:56:21 -0500 Subject: [PATCH 10/19] make tier 11's test tanks --- packages/core/src/blitzkit/api/server/1_tankDefinitions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/blitzkit/api/server/1_tankDefinitions.ts b/packages/core/src/blitzkit/api/server/1_tankDefinitions.ts index 432672ee1..298e84df4 100644 --- a/packages/core/src/blitzkit/api/server/1_tankDefinitions.ts +++ b/packages/core/src/blitzkit/api/server/1_tankDefinitions.ts @@ -387,7 +387,7 @@ export abstract class ServerBlitzKitAPI1 extends ServerBlitzKitAPI0 { : TankType.TANK_TYPE_RESEARCHABLE, tier: tank.level, class: tankClass, - testing: tankTags.includes("testTank"), + testing: tankTags.includes("testTank") || tank.level === 11, deprecated: tankTags.includes("deprecated"), price: tankPrice, camouflage_still: tankDefinition.root.invisibility.still, From a495a5f208e4eacecd9561a2e4d36c5c12f0f202 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Thu, 22 Oct 2026 16:12:03 +1300 Subject: [PATCH 11/19] 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; From 924c130516bc982942bfd60006a124ef6249577c Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:47:01 -0500 Subject: [PATCH 12/19] git keep --- packages/website/src/pages/[...chunk_models_tanks]/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/website/src/pages/[...chunk_models_tanks]/.gitkeep diff --git a/packages/website/src/pages/[...chunk_models_tanks]/.gitkeep b/packages/website/src/pages/[...chunk_models_tanks]/.gitkeep new file mode 100644 index 000000000..e69de29bb From ab9149a11f5356e67128c657d0d90cea0decde29 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:48:33 -0500 Subject: [PATCH 13/19] git keep --- packages/website/src/pages/[...chunk_models_legendary]/.gitkeep | 0 packages/website/src/pages/[...chunk_models_maps]/.gitkeep | 0 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/website/src/pages/[...chunk_models_legendary]/.gitkeep create mode 100644 packages/website/src/pages/[...chunk_models_maps]/.gitkeep diff --git a/packages/website/src/pages/[...chunk_models_legendary]/.gitkeep b/packages/website/src/pages/[...chunk_models_legendary]/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/website/src/pages/[...chunk_models_maps]/.gitkeep b/packages/website/src/pages/[...chunk_models_maps]/.gitkeep new file mode 100644 index 000000000..e69de29bb From 0ee9c99a28dae45d295bf70e8719027b1690dd4c Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:56:40 -0500 Subject: [PATCH 14/19] move tank models to models_tanks chunk --- packages/website/src/hooks/useArmor.ts | 2 +- packages/website/src/hooks/useModel.ts | 2 +- .../src/pages/[...chunk_models_legendary]/_index.ts | 3 +++ .../src/pages/[...chunk_models_tanks]/[id]/_index.ts | 12 ++++++++++++ .../[id]/collision.glb.ts | 2 +- .../[id]/model.glb.ts | 2 +- .../src/pages/[...chunk_models_tanks]/_index.ts | 3 +++ 7 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 packages/website/src/pages/[...chunk_models_legendary]/_index.ts create mode 100644 packages/website/src/pages/[...chunk_models_tanks]/[id]/_index.ts rename packages/website/src/pages/{[...chunk_api]/tanks => [...chunk_models_tanks]}/[id]/collision.glb.ts (99%) rename packages/website/src/pages/{[...chunk_api]/tanks => [...chunk_models_tanks]}/[id]/model.glb.ts (99%) create mode 100644 packages/website/src/pages/[...chunk_models_tanks]/_index.ts diff --git a/packages/website/src/hooks/useArmor.ts b/packages/website/src/hooks/useArmor.ts index 94656886b..ee522b924 100644 --- a/packages/website/src/hooks/useArmor.ts +++ b/packages/website/src/hooks/useArmor.ts @@ -4,7 +4,7 @@ import { GLTFLoader } from "three-stdlib"; import { useDispose } from "./useDispose"; export function useArmor(id: number) { - const path = alias("api", `/tanks/${id}/collision.glb`); + const path = alias("models_tanks", `/${id}/collision.glb`); const gltf = useLoader(GLTFLoader, path); useDispose(gltf, path); diff --git a/packages/website/src/hooks/useModel.ts b/packages/website/src/hooks/useModel.ts index 96db9fd91..3e1ec5bdc 100644 --- a/packages/website/src/hooks/useModel.ts +++ b/packages/website/src/hooks/useModel.ts @@ -5,7 +5,7 @@ import { GLTFLoader } from "three-stdlib"; import { useDispose } from "./useDispose"; export function useModel(id: number) { - const path = alias("api", `/tanks/${id}/model.glb`); + const path = alias("models_tanks", `/${id}/model.glb`); const gltf = useLoader(GLTFLoader, path); useDispose(gltf, path); diff --git a/packages/website/src/pages/[...chunk_models_legendary]/_index.ts b/packages/website/src/pages/[...chunk_models_legendary]/_index.ts new file mode 100644 index 000000000..58fd25670 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_legendary]/_index.ts @@ -0,0 +1,3 @@ +import { chunkedStaticPaths } from "../../core/blitzkit/chunkedStaticPaths"; + +export const getStaticPaths = chunkedStaticPaths("models_legendary"); diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/_index.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/_index.ts new file mode 100644 index 000000000..15a341f98 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/_index.ts @@ -0,0 +1,12 @@ +import { api } from "../../../core/blitzkit/api"; +import { mixStaticPaths } from "../../../core/blitzkit/mixStaticPaths"; +import { getStaticPaths as _getStaticPaths } from "../_index"; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const tanks = await api.tankDefinitions(); + + return Object.values(tanks.tanks).map((tank) => ({ + params: { id: tank.id }, + props: { id: tank.id }, + })); +}); diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts similarity index 99% rename from packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts rename to packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts index dbfd14255..a6d89abc4 100644 --- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/collision.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/collision.glb.ts @@ -10,7 +10,7 @@ import { import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract"; import { Accessor, Document, Node, NodeIO, Scene } from "@gltf-transform/core"; import type { APIContext } from "astro"; -import { vfs } from "../../../../core/blitzkit/vfs"; +import { vfs } from "../../../core/blitzkit/vfs"; export { getStaticPaths } from "./_index"; diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts similarity index 99% rename from packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts rename to packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts index faf26b002..c4f5a5392 100644 --- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts @@ -20,7 +20,7 @@ import type { APIContext } from "astro"; import { isEqual, times } from "lodash-es"; import { dirname } from "path"; import sharp from "sharp"; -import { vfs } from "../../../../core/blitzkit/vfs"; +import { vfs } from "../../../core/blitzkit/vfs"; import { vertexAttributeGLTFName, vertexAttributeGltfVectorSizes, diff --git a/packages/website/src/pages/[...chunk_models_tanks]/_index.ts b/packages/website/src/pages/[...chunk_models_tanks]/_index.ts new file mode 100644 index 000000000..7523da3c6 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_tanks]/_index.ts @@ -0,0 +1,3 @@ +import { chunkedStaticPaths } from "../../core/blitzkit/chunkedStaticPaths"; + +export const getStaticPaths = chunkedStaticPaths("models_tanks"); From b540ab781419cbaa693597bf89a2f62962d8295f Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:59:12 -0500 Subject: [PATCH 15/19] sk --- packages/website/src/hooks/useSkin.ts | 2 +- .../tanks/[id]/skins/[skin].glb.ts | 10 +- .../[...chunk_models_legendary]/.gitkeep | 0 .../[id]/_index.ts | 12 + .../[id]/skins/[skin].glb.ts | 237 ++++++++++++++++++ .../pages/[...chunk_models_tanks]/.gitkeep | 0 6 files changed, 255 insertions(+), 6 deletions(-) delete mode 100644 packages/website/src/pages/[...chunk_models_legendary]/.gitkeep create mode 100644 packages/website/src/pages/[...chunk_models_legendary]/[id]/_index.ts create mode 100644 packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts delete mode 100644 packages/website/src/pages/[...chunk_models_tanks]/.gitkeep diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts index a18c47a44..6514d2482 100644 --- a/packages/website/src/hooks/useSkin.ts +++ b/packages/website/src/hooks/useSkin.ts @@ -27,7 +27,7 @@ async function loadSkin(path: string) { } function skinPath(id: number, skin: number) { - return alias("api", `/tanks/${id}/skins/${skin}.glb`); + return alias("models_legendary", `/${id}/skins/${skin}.glb`); } export function useSkin(id: number) { 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..d755f5062 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 @@ -16,11 +16,11 @@ 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 { 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 "../../../[...chunk_api]/_index"; import { extractMaterials, extractModel } from "../model.glb"; const SKIN_NUMBER_SLOTS = 5; diff --git a/packages/website/src/pages/[...chunk_models_legendary]/.gitkeep b/packages/website/src/pages/[...chunk_models_legendary]/.gitkeep deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/website/src/pages/[...chunk_models_legendary]/[id]/_index.ts b/packages/website/src/pages/[...chunk_models_legendary]/[id]/_index.ts new file mode 100644 index 000000000..15a341f98 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/_index.ts @@ -0,0 +1,12 @@ +import { api } from "../../../core/blitzkit/api"; +import { mixStaticPaths } from "../../../core/blitzkit/mixStaticPaths"; +import { getStaticPaths as _getStaticPaths } from "../_index"; + +export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => { + const tanks = await api.tankDefinitions(); + + return Object.values(tanks.tanks).map((tank) => ({ + params: { id: tank.id }, + props: { id: tank.id }, + })); +}); 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 new file mode 100644 index 000000000..24e863e69 --- /dev/null +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts @@ -0,0 +1,237 @@ +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 { + extractMaterials, + extractModel, +} from "../../../[...chunk_models_tanks]/[id]/model.glb"; +import { getStaticPaths as _getStaticPaths } from "../_index"; + +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/pages/[...chunk_models_tanks]/.gitkeep b/packages/website/src/pages/[...chunk_models_tanks]/.gitkeep deleted file mode 100644 index e69de29bb..000000000 From 654887dd66e01f8e47d31574da14439fc2f87639 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:06:18 -0500 Subject: [PATCH 16/19] versio --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 65b13bde0..59e8eaf3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "blitzkit", - "version": "1.32.0", + "version": "1.33.0-beta.1", "license": "BlitzKit License", "private": true, "scripts": { From fc9050e7fe4d9eb30fa8d3798f14b85ce0d69b91 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:23:55 -0500 Subject: [PATCH 17/19] fix imports --- .../[...chunk_api]/tanks/[id]/skins/[skin].glb.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 d755f5062..9dde609df 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 @@ -16,12 +16,15 @@ 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 "../../../[...chunk_api]/_index"; -import { extractMaterials, extractModel } from "../model.glb"; +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 { + extractMaterials, + extractModel, +} from "../../../../[...chunk_models_tanks]/[id]/model.glb"; +import { getStaticPaths as _getStaticPaths } from "../_index"; const SKIN_NUMBER_SLOTS = 5; const DIGIT_COLUMNS = 5; From 625d8d159576bc40ae258220ec9629701f20a300 Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:13:57 -0500 Subject: [PATCH 18/19] cleaner logic --- .github/workflows/website.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml index 1e074eac7..dce96868b 100644 --- a/.github/workflows/website.yaml +++ b/.github/workflows/website.yaml @@ -139,13 +139,15 @@ jobs: - name: Trigger deployment - # Deploy if: - # 1. Running cron from main using content from production to target production - # 2. Running manually and targeting non-production + # workflow should trigger if: + # - is by cron and is on main: triggering to production + # - is by push and is on production: triggering to production + # - is by manual and is on anything: triggering to anything if: >- ${{ - github.ref == 'refs/heads/production' || - (inputs.environment != '' && inputs.environment != 'production') + (github.event_name == 'schedule' && github.ref == 'refs/heads/main') + || (github.event_name == 'push' && github.ref == 'refs/heads/production') + || (github.event_name == 'workflow_dispatch') }} env: From 72e358f034c7224a85aacc32574530e51e20845e Mon Sep 17 00:00:00 2001 From: tresabhi <43380238+tresabhi@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:45:04 -0500 Subject: [PATCH 19/19] add pr checks --- .github/workflows/website.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/website.yaml b/.github/workflows/website.yaml index dce96868b..cbd3bf04c 100644 --- a/.github/workflows/website.yaml +++ b/.github/workflows/website.yaml @@ -3,6 +3,8 @@ name: Website on: push: + pull_request: + schedule: - cron: "0 4 * * *" @@ -102,7 +104,8 @@ jobs: - name: Checkout uses: actions/checkout@v6 with: - ref: ${{ inputs.branch || 'production' }} + # the PR's ref, or the manual input, or production (selected when cron) + ref: ${{ github.event.pull_request.head.sha || inputs.branch || 'production' }} submodules: recursive token: ${{ secrets.GH_TOKEN }}