From c772d47a07d13572ce372799c0f63c7c2c2c9f51 Mon Sep 17 00:00:00 2001 From: Mark Greene Date: Fri, 30 Oct 2026 07:11:31 +1300 Subject: [PATCH] Fix: Unify Tankopedia model, camouflage and attachment loaders - Tankopedia state uses `loaders: { model, skin, attachment }` with `requested` and `loaded` - useSkin swaps the camouflage halfway through the lighting transition for both cached and downloaded skins - Remove the leftover "Unreleased" camouflage name handling --- .../GunFlexibilityVisualizer/index.tsx | 4 +- .../Options/components/SkinSwitcher.tsx | 46 ++++++++---------- .../HeroSection/components/Options/index.tsx | 4 +- .../components/TankSandbox/Title/index.tsx | 12 ++--- .../TankSandbox/components/Lighting.tsx | 16 ++----- .../Tankopedia/HeroSection/index.tsx | 4 +- packages/website/src/hooks/useSkin.ts | 48 +++++++++---------- packages/website/src/stores/tankopedia.ts | 23 +++++---- 8 files changed, 74 insertions(+), 83 deletions(-) diff --git a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/index.tsx b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/index.tsx index f28050abc..cb975b796 100644 --- a/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/index.tsx +++ b/packages/website/src/components/Tankopedia/CharacteristicsSection/components/Characteristics/components/GunFlexibilityVisualizer/index.tsx @@ -44,7 +44,9 @@ export function GunFlexibilityVisualizer({ const tank = Duel.use((state) => state.protagonist.tank); const turret = Duel.use((state) => state.protagonist.turret); const gun = Duel.use((state) => state.protagonist.gun); - const modelRequested = Tankopedia.use((state) => state.modelRequested); + const modelRequested = Tankopedia.use( + (state) => state.loaders.model.requested, + ); const tankModel = modelDefinition.models[tank.id]; const turretModel = tankModel.turrets[turret.id]; 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 index 6971bad78..0858016c9 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinSwitcher.tsx @@ -1,5 +1,5 @@ import { alias } from "@blitzkit/core"; -import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { Flex, Tooltip } from "@radix-ui/themes"; import { uniqBy } from "lodash-es"; import { SKIN_NUMBER } from "../../../../../../constants/skinNumber"; import { api } from "../../../../../../core/blitzkit/api"; @@ -13,17 +13,18 @@ const camouflageDefinitions = await api.camouflageDefinitions(); export function SkinSwitcher() { const tank = Duel.use((state) => state.protagonist.tank); - const skin = Tankopedia.use((state) => state.requestedSkin); - const attachment = Tankopedia.use((state) => state.requestedAttachment); + const skin = Tankopedia.use((state) => state.loaders.skin.requested); + const attachment = Tankopedia.use( + (state) => state.loaders.attachment.requested, + ); const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); const disturbed = Tankopedia.use((state) => state.disturbed); - const { unwrap, strings } = useLocale(); + const { unwrap } = useLocale(); const skins = uniqBy( Object.values(camouflageDefinitions.camouflages).filter( (camouflage) => camouflage.tank_id === tank.id, ), - (camouflage) => - (camouflage.name && unwrap(camouflage.name)) || camouflage.id, + (camouflage) => unwrap(camouflage.name!), ); // attachments aren't camouflages so they get their own group @@ -54,26 +55,16 @@ export function SkinSwitcher() { {groups.map((group) => ( {group.map((camouflage, index) => { - const rawName = camouflage.name && unwrap(camouflage.name); - const name = rawName - ? rawName.replace("%(camo_num)", SKIN_NUMBER) - : strings.website.tools.tankopedia.sandbox.skins.unreleased; + const name = unwrap(camouflage.name!).replace( + "%(camo_num)", + SKIN_NUMBER, + ); const top = index === 0 ? "var(--radius-full)" : "0"; const bottom = index === group.length - 1 ? "var(--radius-full)" : "0"; return ( - {name} - ) - } - side="left" - > + { - const slot = camouflage.attachment - ? "requestedAttachment" - : "requestedSkin"; - Tankopedia.mutate((draft) => { - draft[slot] = - draft[slot] === camouflage.id + const loader = + draft.loaders[ + camouflage.attachment ? "attachment" : "skin" + ]; + + loader.requested = + loader.requested === 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 725ec6803..695cbc55f 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx @@ -82,7 +82,9 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) { const revealed = Tankopedia.use((state) => state.revealed); const disturbed = Tankopedia.use((state) => state.disturbed); const highGraphics = TankopediaPersistent.use((state) => state.highGraphics); - const modelRequested = Tankopedia.use((state) => state.modelRequested); + const modelRequested = Tankopedia.use( + (state) => state.loaders.model.requested, + ); const equalize = Duel.use((state) => state.equalize); const equalizer = Duel.use((state) => state.antagonist.tank.equalizer); diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/Title/index.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/Title/index.tsx index d3708ad8a..d8ed0dfd6 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/Title/index.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/Title/index.tsx @@ -13,12 +13,14 @@ const camouflageDefinitions = await api.camouflageDefinitions(); export function Title({ skeleton }: MaybeSkeletonComponentProps) { const { unwrap, strings } = useLocale(); - const modelRequested = Tankopedia.use((state) => state.modelRequested); + const modelRequested = Tankopedia.use( + (state) => state.loaders.model.requested, + ); const protagonist = Duel.use((state) => state.protagonist.tank); const revealed = Tankopedia.use((state) => state.revealed); const disturbed = Tankopedia.use((state) => state.disturbed); const hasRevealed = useRef(false); - const skin = Tankopedia.use((state) => state.requestedSkin); + const skin = Tankopedia.use((state) => state.loaders.skin.requested); const camouflage = skin === undefined ? undefined : camouflageDefinitions.camouflages[skin]; const tankName = unwrap(protagonist.name!); @@ -27,9 +29,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) { camouflage ? camouflage.tank_name ? unwrap(camouflage.tank_name) - : camouflage.name - ? `${tankName} ${unwrap(camouflage.name)}` - : tankName + : `${tankName} ${unwrap(camouflage.name!)}` : tankName ).replace("%(camo_num)", SKIN_NUMBER); const fontSize = revealed @@ -109,7 +109,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) { onPointerDown={(event) => event.stopPropagation()} onClick={() => { Tankopedia.mutate((draft) => { - draft.modelRequested = true; + draft.loaders.model.requested = 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 d6293f09b..8ce7701ca 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 @@ -49,8 +49,8 @@ export function Lighting({ hasPbr }: Props) { const revealed = Tankopedia.use((state) => state.revealed); const skinPending = Tankopedia.use( (state) => - state.loadedSkin !== state.skin || - state.loadedAttachment !== state.attachment, + state.loaders.skin.requested !== state.loaders.skin.loaded || + state.loaders.attachment.requested !== state.loaders.attachment.loaded, ); const animationTime = useRef(REVEAL_ANIMATION_TIME); @@ -65,6 +65,7 @@ export function Lighting({ hasPbr }: Props) { useEffect(() => { Tankopedia.mutate((draft) => { draft.revealed = true; + draft.loaders.model.loaded = true; }); }, []); @@ -246,17 +247,6 @@ function Animator({ draft.display = requestedDisplay; }); } - - if ( - x >= 1 && - (Tankopedia.state.skin !== Tankopedia.state.loadedSkin || - Tankopedia.state.attachment !== Tankopedia.state.loadedAttachment) - ) { - Tankopedia.mutate((draft) => { - draft.skin = draft.loadedSkin; - draft.attachment = draft.loadedAttachment; - }); - } }); return null; diff --git a/packages/website/src/components/Tankopedia/HeroSection/index.tsx b/packages/website/src/components/Tankopedia/HeroSection/index.tsx index 51205fb49..0e15e8974 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/index.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/index.tsx @@ -20,7 +20,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) { const canvas = useRef(null!); const isFullScreen = useFullScreen(); const protagonist = Duel.use((state) => state.protagonist.tank); - const modelRequested = Tankopedia.use((state) => state.modelRequested); + const modelRequested = Tankopedia.use( + (state) => state.loaders.model.requested, + ); const thicknessRange = useMemo(() => { const entries = Object.values(tankDefinitions.tanks); const filtered = entries.filter( diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts index 93a6a0811..d7421795c 100644 --- a/packages/website/src/hooks/useSkin.ts +++ b/packages/website/src/hooks/useSkin.ts @@ -35,36 +35,28 @@ function skinPath(id: number, skin: number) { } // attachments load the same way as camouflages and can be shown with one -const slots = { - skin: { requested: "requestedSkin", loaded: "loadedSkin" }, - attachment: { requested: "requestedAttachment", loaded: "loadedAttachment" }, -} as const; - -export function useSkin(id: number, slot: keyof typeof slots = "skin") { - const { requested, loaded } = slots[slot]; - const requestedSkin = Tankopedia.use((state) => state[requested]); - const skin = Tankopedia.use((state) => state[slot]); +export function useSkin(id: number, loader: "skin" | "attachment" = "skin") { + const requested = Tankopedia.use((state) => state.loaders[loader].requested); + const loaded = Tankopedia.use((state) => state.loaders[loader].loaded); useEffect(() => { - const path = - requestedSkin === undefined ? undefined : skinPath(id, requestedSkin); + if (requested === Tankopedia.state.loaders[loader].loaded) return; + + const path = requested === undefined ? undefined : skinPath(id, requested); + const cached = path === undefined || loadedSkins.has(path); let settled = false; - if (path === undefined || loadedSkins.has(path)) { + if (!cached) { Tankopedia.mutate((draft) => { - draft[loaded] = requestedSkin; + draft.revealed = false; }); - return; + + if (!skins.has(path)) skins.set(path, loadSkin(path)); } - Tankopedia.mutate((draft) => { - draft.revealed = false; - }); - - if (!skins.has(path)) skins.set(path, loadSkin(path)); - + // swaps halfway through the lighting transition Promise.all([ - skins.get(path), + path && skins.get(path), new Promise((resolve) => setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3), ), @@ -73,9 +65,8 @@ export function useSkin(id: number, slot: keyof typeof slots = "skin") { settled = true; Tankopedia.mutate((draft) => { - draft[loaded] = requestedSkin; - draft[slot] = requestedSkin; - draft.revealed = true; + draft.loaders[loader].loaded = requested; + if (!cached) draft.revealed = true; }); }); @@ -83,11 +74,16 @@ export function useSkin(id: number, slot: keyof typeof slots = "skin") { if (settled) return; settled = true; + + if (cached) return; + Tankopedia.mutate((draft) => { draft.revealed = true; }); }; - }, [id, slot, requestedSkin]); + }, [id, loader, requested]); - return skin === undefined ? undefined : loadedSkins.get(skinPath(id, skin)); + return loaded === undefined + ? undefined + : loadedSkins.get(skinPath(id, loaded)); } diff --git a/packages/website/src/stores/tankopedia.ts b/packages/website/src/stores/tankopedia.ts index 3beaaffd6..a3723796c 100644 --- a/packages/website/src/stores/tankopedia.ts +++ b/packages/website/src/stores/tankopedia.ts @@ -78,10 +78,19 @@ export enum TankopediaRelativeAgainst { All, } +interface LoaderState { + requested: Type; + loaded: Type; +} + interface Tankopedia { disturbed: boolean; revealed: boolean; - modelRequested: boolean; + loaders: { + model: LoaderState; + skin: LoaderState; + attachment: LoaderState; + }; shot?: Shot; skills: Record; relativeAgainst: TankopediaRelativeAgainst; @@ -103,12 +112,6 @@ interface Tankopedia { ); xpMultiplier: (typeof XP_MULTIPLIERS)[number]; customShell?: ShellDefinition; - requestedSkin?: number; - loadedSkin?: number; - skin?: number; - requestedAttachment?: number; - loadedAttachment?: number; - attachment?: number; requestedDisplay: TankopediaDisplay; display: TankopediaDisplay; statSearch?: string; @@ -119,7 +122,11 @@ const skillDefinitions = await api.skillDefinitions(); export const Tankopedia = new Varuna((model) => ({ disturbed: false, revealed: false, - modelRequested: App.state.autoLoadModels, + loaders: { + model: { requested: App.state.autoLoadModels, loaded: false }, + skin: { requested: undefined, loaded: undefined }, + attachment: { requested: undefined, loaded: undefined }, + }, relativeAgainst: TankopediaRelativeAgainst.Class, editStatic: false, skills: createDefaultSkills(skillDefinitions),