diff --git a/docs/changelogs/1.30.3.md b/docs/changelogs/1.30.3.md new file mode 100644 index 000000000..69137dcac --- /dev/null +++ b/docs/changelogs/1.30.3.md @@ -0,0 +1,18 @@ +# BlitzKit 1.30.3 + +Thank you to [GonnaHetzMe](https://github.com/karelpak1) for your contributions this update. + +## Features + +- [GonnaHetzMe] Added tooltips to the module selector in Tankopedia + +## Fixes + +- Added aggressive re-rendering for the Guess mini-game's score readout to make inspect-element harder +- Removed tank visual and collision model caching +- Guesser once again breaks streaks if not all tiers are selected +- BlitzKit now properly unloads tank models from memory when visually hidden in Guesser +- [GonnaHetzMe] Tanks with complex transparency like the Herdbreaker once again handle opacity correctly +- [GonnaHetzMe] Pieces of tank models that have transparency now handle culling properly (they're visible from both sides) +- [GonnaHetzMe] Tanks with "cardboard" textures have been fixed, thanks to better PVR file parsing +- [GonnaHetzMe] Tank icons are now scaled correctly so small tanks are now properly zoomed out in contrast to bigger ones diff --git a/packages/core/src/streams/pvr.ts b/packages/core/src/streams/pvr.ts index 979b9c396..8703f5cd7 100644 --- a/packages/core/src/streams/pvr.ts +++ b/packages/core/src/streams/pvr.ts @@ -1,5 +1,5 @@ -import { clamp, isEqual, times } from 'lodash-es'; -import { ReadStream } from './buffer'; +import { clamp, isEqual, times } from "lodash-es"; +import { ReadStream } from "./buffer"; export enum PvrFlags { NoFlag = 0, @@ -96,7 +96,7 @@ export class PvrReadStream extends ReadStream { const header = this.header(); this.metadata(); - if (typeof header.pixelFormat === 'number') { + if (typeof header.pixelFormat === "number") { switch (header.pixelFormat) { default: throw new TypeError( @@ -142,12 +142,12 @@ export class PvrReadStream extends ReadStream { times(pixelCount, (index) => { const buffer = this.consumeUint8Array(2); - const bufferIndex = index * 4; + const bufferIndex = index * 3; const r = ((buffer[1] & 0b11111000) >>> 3) / 31; const g = - ((buffer[1] & 0b111) << 3) | - (((buffer[0] & 0b11100000) >>> 5) / 31); - const b = buffer[0] & 0b11111; + (((buffer[1] & 0b111) << 3) | ((buffer[0] & 0b11100000) >>> 5)) / + 63; + const b = (buffer[0] & 0b11111) / 31; data[bufferIndex] = Math.round(clamp(r * 255, 0, 255)); data[bufferIndex + 1] = Math.round(clamp(g * 255, 0, 255)); @@ -186,7 +186,7 @@ export class PvrReadStream extends ReadStream { metadataSize: this.uint32(), }; - if (header.version !== 'PVR\x03') throw new TypeError('Endian mismatch'); + if (header.version !== "PVR\x03") throw new TypeError("Endian mismatch"); return header; } @@ -205,19 +205,19 @@ export class PvrReadStream extends ReadStream { resolveBitRatePixelFormat( pixelFormat: Exclude, number>, ) { - if (pixelFormat.order === 'rgba') { + if (pixelFormat.order === "rgba") { if (isEqual(pixelFormat.bitRates, [4, 4, 4, 4])) { return ResolvedBitRatePixelFormat.R4G4B4A4; } else throw new TypeError( - `Unhandled rgba bit rate ${pixelFormat.bitRates.join(', ')}`, + `Unhandled rgba bit rate ${pixelFormat.bitRates.join(", ")}`, ); - } else if (pixelFormat.order === 'rgb\0') { + } else if (pixelFormat.order === "rgb\0") { if (isEqual(pixelFormat.bitRates, [5, 6, 5, 0])) { return ResolvedBitRatePixelFormat.R5G6B5A0; } else throw new TypeError( - `Unhandled rgb bit rate ${pixelFormat.bitRates.join(', ')}`, + `Unhandled rgb bit rate ${pixelFormat.bitRates.join(", ")}`, ); } else { throw new TypeError(`Unhandled pixel order ${pixelFormat.order}`); diff --git a/packages/core/src/streams/sc2.ts b/packages/core/src/streams/sc2.ts index eb5ebccd7..8b4837bab 100644 --- a/packages/core/src/streams/sc2.ts +++ b/packages/core/src/streams/sc2.ts @@ -15,6 +15,9 @@ type DataNode = { qualityGroup?: string; fxName?: string; textures?: Textures; + customCullMode?: number; + enabledPresets?: EnabledPresets; + properties?: Properties; } & ( | ({ configCount: number; @@ -24,7 +27,7 @@ type DataNode = { } ); -interface ConfigArchive { +export interface ConfigArchive { configName: string; fxName: string; properties: Properties; diff --git a/packages/i18n/strings/ru.json b/packages/i18n/strings/ru.json index 85014a4fa..5cc4a70a3 100644 --- a/packages/i18n/strings/ru.json +++ b/packages/i18n/strings/ru.json @@ -1018,8 +1018,8 @@ "title": "Характеристики", "compare": { "label": "Сравнивать с", - "class": "Уровень Тип", - "tier": "Все уровни", + "class": "{tier} {class}", + "tier": "Все {tier} уровня", "all": "Все танки" }, "values": { diff --git a/packages/scripts/src/buildAssets/tankIcons.ts b/packages/scripts/src/buildAssets/tankIcons.ts index 3a0fbfc22..fb74388d9 100644 --- a/packages/scripts/src/buildAssets/tankIcons.ts +++ b/packages/scripts/src/buildAssets/tankIcons.ts @@ -1,6 +1,7 @@ import { NATION_IDS } from "@blitzkit/core"; import sharp from "sharp"; import type { Vector3Tuple } from "three"; +import { parsePackedSpriteRect } from "../core/blitz/parsePackedSpriteRect"; import { AssetUploader } from "../core/github/assetUploader"; import { vfs } from "./constants"; import type { VehicleDefinitionList } from "./definitions"; @@ -37,6 +38,45 @@ export interface TankParameters { }; } +async function extractPackedTankIcon(packedBuffer: Buffer, context: string) { + const texture = sharp(packedBuffer); + const spriteRect = parsePackedSpriteRect(packedBuffer); + + if (!spriteRect) { + throw new Error(`Missing RIFF sprite bounds for ${context}`); + } + + const [left, top, width, height] = spriteRect; + const bounds = [left, top, width, height]; + + if (!bounds.every(Number.isInteger)) { + throw new Error( + `Invalid RIFF sprite bounds for ${context}: ${bounds.join(" ")}`, + ); + } + + const metadata = await texture.metadata(); + + if (metadata.width === undefined || metadata.height === undefined) { + throw new Error(`Failed to read image dimensions for ${context}`); + } + + if ( + left < 0 || + top < 0 || + width <= 0 || + height <= 0 || + left + width > metadata.width || + top + height > metadata.height + ) { + throw new Error( + `Out-of-bounds RIFF sprite for ${context}: ${bounds.join(" ")} in ${metadata.width}x${metadata.height}`, + ); + } + + return await texture.extract({ left, top, width, height }).toBuffer(); +} + export async function tankIcons() { console.log("Building tank icons..."); @@ -67,12 +107,14 @@ export async function tankIcons() { const bigPath = `Data/${parameters.resourcesPath.bigIconPath .replace(/~res:\//, "") .replace(/\..+/, "")}.packed.webp`; - const big = await sharp(await vfs.file(bigPath)) - .trim() - .toBuffer(); - const small = await sharp(await vfs.file(smallPath)) - .trim() - .toBuffer(); + const big = await extractPackedTankIcon( + await vfs.file(bigPath), + `tank ${tankKey} big`, + ); + const small = await extractPackedTankIcon( + await vfs.file(smallPath), + `tank ${tankKey} small`, + ); if (big) { await uploader.add({ diff --git a/packages/scripts/src/core/blitz/extractModel/index.ts b/packages/scripts/src/core/blitz/extractModel/index.ts index 846dd80c9..3139a8608 100644 --- a/packages/scripts/src/core/blitz/extractModel/index.ts +++ b/packages/scripts/src/core/blitz/extractModel/index.ts @@ -1,5 +1,6 @@ import { bufferToBigInt, + ConfigArchive, Hierarchy, Sc2ReadStream, ScgReadStream, @@ -75,6 +76,42 @@ export async function extractModel(vfs: AbstractVFS, path: string) { .setImage(baseColor), ); + let defaultConfigArchive: ConfigArchive | undefined = undefined; + + if (node.configCount) { + const names = times( + node.configCount, + (index) => node[`configArchive_${index}`], + ); + + defaultConfigArchive = names.find( + (archive) => archive.configName === "Default", + ); + defaultConfigArchive ??= node.configArchive_0; + } + + const customCullMode = + node.customCullMode ?? defaultConfigArchive?.customCullMode; + + if (customCullMode === 0) { + material.setDoubleSided(true); + } else if (customCullMode !== undefined) { + material.setDoubleSided(false); + } + + if ( + node.enabledPresets?.AlphaTest && + node.properties?.alphatestThreshold + ) { + const view = new DataView(node.properties.alphatestThreshold); + const alphaCutoff = + view.byteLength < 4 + ? 0.5 + : view.getFloat32(view.byteLength - 4, true); + + material.setAlphaMode("MASK").setAlphaCutoff(alphaCutoff); + } + if (node.configCount) { for ( let configIndex = 0; @@ -85,14 +122,17 @@ export async function extractModel(vfs: AbstractVFS, path: string) { if ( archive.configName !== "Default" || - !archive.enabledPresets?.AlphaTest + !archive.enabledPresets?.AlphaTest || + !archive.properties?.alphatestThreshold ) { continue; } const view = new DataView(archive.properties.alphatestThreshold); - const _ = view.getUint8(0); // only god knows what these flags are - const alphaCutoff = view.getFloat32(1, true); + const alphaCutoff = + view.byteLength < 4 + ? 0.5 + : view.getFloat32(view.byteLength - 4, true); material.setAlphaMode("MASK").setAlphaCutoff(alphaCutoff); } @@ -171,8 +211,8 @@ export async function extractModel(vfs: AbstractVFS, path: string) { 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. + // 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, ); @@ -284,4 +324,4 @@ export async function extractModel(vfs: AbstractVFS, path: string) { await document.transform(prune({ keepAttributes: true }), dedup()); return document; -} \ No newline at end of file +} diff --git a/packages/website/src/components/Guesser.tsx b/packages/website/src/components/Guesser.tsx index 62ff27553..27c7915f0 100644 --- a/packages/website/src/components/Guesser.tsx +++ b/packages/website/src/components/Guesser.tsx @@ -58,12 +58,14 @@ export function Guesser() { const [results, setResults] = useState(null); const [selected, setSelected] = useState(null); const tiers = Guess.use((state) => state.tiers); + const status = useRef(null); const requestSearch = useCallback(() => { setSelected(null); setSearching(true); search(); }, []); + const search = useCallback( debounce(() => { if (!input.current) return; @@ -92,6 +94,20 @@ export function Guesser() { if (guessState !== GuessState.NotGuessed) setSelected(null); }, [guessState]); + useEffect(() => { + const interval = setInterval(() => { + if (!status.current) return; + + status.current.innerText = literals(strings.website.tools.guess.stats, { + correct: correctGuesses, + total: totalGuesses, + streak: streak, + }); + }, 1000 / 200); + + return () => clearInterval(interval); + }, [correctGuesses, totalGuesses, streak]); + return ( - - {literals(strings.website.tools.guess.stats, { - correct: correctGuesses, - total: totalGuesses, - streak: streak, - })} - + @@ -312,7 +322,7 @@ export function Guesser() { ? GuessState.Correct : GuessState.Incorrect; draft.totalGuesses++; - draft.correctGuesses += correct ? 1 : 0; + draft.correctGuesses += correct && draft.tiers.length === 10 ? 1 : 0; draft.streak = correct ? draft.streak + 1 : 0; }); } else { diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Equalizer.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Equalizer.tsx index f49200157..1ec26568a 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Equalizer.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Equalizer.tsx @@ -70,37 +70,6 @@ export function Equalizer() { - - - - - - Brought to you by{" "} - {" "} - WoTB News - - - - - - - This feature was brought to you early, ahead of the release of the - game mode, thanks to{" "} - - WoTB News - - . - - - ); } diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Modules.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Modules.tsx index 937c216d3..a290cf245 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Modules.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Modules.tsx @@ -18,6 +18,7 @@ import { Heading, IconButton, Text, + Tooltip, } from "@radix-ui/themes"; import { awaitableTankDefinitions } from "../../../../core/awaitables/tankDefinitions"; import { useLocale } from "../../../../hooks/useLocale"; @@ -55,12 +56,14 @@ function ModuleButton({ selected, unlock, top, + tooltip, onClick, }: { unlock: Unlock; tier: number; selected: boolean; top: boolean; + tooltip?: string; onClick: () => void; }) { const isTank = unlock.type === ModuleType.MODULE_TYPE_VEHICLE; @@ -134,7 +137,7 @@ function ModuleButton({ ); - return isTank ? ( + const node = isTank ? ( {node}; } const tankDefinitions = await awaitableTankDefinitions; @@ -167,7 +174,7 @@ export function Modules() { const topGun = topTurret.guns.at(-1)!; const topEngine = tank.engines.at(-1)!; const topTrack = tank.tracks.at(-1)!; - const { strings } = useLocale(); + const { strings, unwrap } = useLocale(); function setByUnlock(unlock: Unlock) { Duel.mutate((draft) => { @@ -249,6 +256,8 @@ export function Modules() { if (module === undefined) return null; + const tooltip = unwrap(module.name!); + return ( = {}; +import { asset } from "@blitzkit/core"; +import { useLoader } from "@react-three/fiber"; +import { GLTFLoader } from "three-stdlib"; +import { useDispose } from "./useDispose"; export function useArmor(id: number) { - const gltf = useLoader(GLTFLoader, asset(`3d/tanks/armor/${id}.glb`)); + const path = asset(`3d/tanks/armor/${id}.glb`); + const gltf = useLoader(GLTFLoader, path); - if (!cache[id]) { - cache[id] = { - gltf, - hasDynamicArmor: Object.values(gltf.nodes).some( - (node) => - node.name.includes('state_00') || node.name.includes('state_01'), - ), - }; - } - return cache[id]; + useDispose(gltf, path); + + return { + gltf, + hasDynamicArmor: Object.values(gltf.nodes).some( + (node) => + node.name.includes("state_00") || node.name.includes("state_01"), + ), + }; } diff --git a/packages/website/src/hooks/useDispose.ts b/packages/website/src/hooks/useDispose.ts new file mode 100644 index 000000000..2ff4e3685 --- /dev/null +++ b/packages/website/src/hooks/useDispose.ts @@ -0,0 +1,50 @@ +import { useLoader, type ObjectMap } from "@react-three/fiber"; +import { useEffect } from "react"; +import { Cache, Group, Mesh, MeshStandardMaterial, Object3D } from "three"; +import { GLTFLoader, type GLTF } from "three-stdlib"; + +export function useDispose(gltf: GLTF & ObjectMap, path: string) { + useEffect(() => { + return () => { + gltf.scene.traverse((object) => { + if (object instanceof Mesh) { + if (object.material instanceof MeshStandardMaterial) { + const maps = [ + object.material.map, + object.material.aoMap, + object.material.envMap, + object.material.bumpMap, + object.material.alphaMap, + object.material.lightMap, + object.material.normalMap, + object.material.emissiveMap, + object.material.roughnessMap, + object.material.metalnessMap, + object.material.displacementMap, + ]; + + for (const map of maps) { + if (!map) continue; + + map.source.data.close(); + map.dispose(); + } + + object.material.dispose(); + } else { + throw new TypeError( + `Unexpected material type: ${object.material.type}`, + ); + } + } else if (object instanceof Group || object instanceof Object3D) { + // traverse will get children + } else { + throw new TypeError(`Unexpected object type: ${object}`); + } + }); + + useLoader.clear(GLTFLoader, path); + Cache.remove(path); + }; + }, [gltf, path]); +} diff --git a/packages/website/src/hooks/useModel.ts b/packages/website/src/hooks/useModel.ts index 8f6d8a411..c8d532bd5 100644 --- a/packages/website/src/hooks/useModel.ts +++ b/packages/website/src/hooks/useModel.ts @@ -1,30 +1,22 @@ import { asset } from "@blitzkit/core"; -import { type ObjectMap, useLoader } from "@react-three/fiber"; +import { useLoader } from "@react-three/fiber"; import { Mesh, MeshStandardMaterial } from "three"; -import { type GLTF, GLTFLoader } from "three-stdlib"; - -const cache: Record< - number, - { - gltf: GLTF & ObjectMap; - hasPbr: boolean; - } -> = {}; +import { GLTFLoader } from "three-stdlib"; +import { useDispose } from "./useDispose"; export function useModel(id: number) { - const gltf = useLoader(GLTFLoader, asset(`3d/tanks/models/${id}.glb`)); + const path = asset(`3d/tanks/models/${id}.glb`); + const gltf = useLoader(GLTFLoader, path); - if (!cache[id]) { - cache[id] = { - gltf, - hasPbr: Object.values(gltf.nodes).some( - (node) => - node instanceof Mesh && - node.material instanceof MeshStandardMaterial && - node.material.roughnessMap !== null - ), - }; - } + useDispose(gltf, path); - return cache[id]; + return { + gltf, + hasPbr: Object.values(gltf.nodes).some( + (node) => + node instanceof Mesh && + node.material instanceof MeshStandardMaterial && + node.material.roughnessMap !== null, + ), + }; }