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
This commit is contained in:
Mark Greene committed 2026-10-30 07:11:31 +13:00
1 parent 523fee5010
commit c772d47a07
8 files changed
+74 -83

No files matched your search

@@ -44,7 +44,9 @@ export function GunFlexibilityVisualizer({
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const turret = Duel.use((state) => state.protagonist.turret); const turret = Duel.use((state) => state.protagonist.turret);
const gun = Duel.use((state) => state.protagonist.gun); 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 tankModel = modelDefinition.models[tank.id];
const turretModel = tankModel.turrets[turret.id]; const turretModel = tankModel.turrets[turret.id];
@@ -1,5 +1,5 @@
import { alias } from "@blitzkit/core"; 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 { uniqBy } from "lodash-es";
import { SKIN_NUMBER } from "../../../../../../constants/skinNumber"; import { SKIN_NUMBER } from "../../../../../../constants/skinNumber";
import { api } from "../../../../../../core/blitzkit/api"; import { api } from "../../../../../../core/blitzkit/api";
@@ -13,17 +13,18 @@ const camouflageDefinitions = await api.camouflageDefinitions();
export function SkinSwitcher() { export function SkinSwitcher() {
const tank = Duel.use((state) => state.protagonist.tank); const tank = Duel.use((state) => state.protagonist.tank);
const skin = Tankopedia.use((state) => state.requestedSkin); const skin = Tankopedia.use((state) => state.loaders.skin.requested);
const attachment = Tankopedia.use((state) => state.requestedAttachment); const attachment = Tankopedia.use(
(state) => state.loaders.attachment.requested,
);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay); const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const disturbed = Tankopedia.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
const { unwrap, strings } = useLocale(); const { unwrap } = useLocale();
const skins = uniqBy( const skins = uniqBy(
Object.values(camouflageDefinitions.camouflages).filter( Object.values(camouflageDefinitions.camouflages).filter(
(camouflage) => camouflage.tank_id === tank.id, (camouflage) => camouflage.tank_id === tank.id,
), ),
(camouflage) => (camouflage) => unwrap(camouflage.name!),
(camouflage.name && unwrap(camouflage.name)) || camouflage.id,
); );
// attachments aren't camouflages so they get their own group // attachments aren't camouflages so they get their own group
@@ -54,26 +55,16 @@ export function SkinSwitcher() {
{groups.map((group) => ( {groups.map((group) => (
<Flex key={group[0].id} direction="column"> <Flex key={group[0].id} direction="column">
{group.map((camouflage, index) => { {group.map((camouflage, index) => {
const rawName = camouflage.name && unwrap(camouflage.name); const name = unwrap(camouflage.name!).replace(
const name = rawName "%(camo_num)",
? rawName.replace("%(camo_num)", SKIN_NUMBER) SKIN_NUMBER,
: strings.website.tools.tankopedia.sandbox.skins.unreleased; );
const top = index === 0 ? "var(--radius-full)" : "0"; const top = index === 0 ? "var(--radius-full)" : "0";
const bottom = const bottom =
index === group.length - 1 ? "var(--radius-full)" : "0"; index === group.length - 1 ? "var(--radius-full)" : "0";
return ( return (
<Tooltip <Tooltip key={camouflage.id} content={name} side="left">
key={camouflage.id}
content={
rawName ? (
name
) : (
<Text style={{ color: "var(--orange-9)" }}>{name}</Text>
)
}
side="left"
>
<SidebarIconButton <SidebarIconButton
selected={ selected={
(camouflage.attachment ? attachment : skin) === (camouflage.attachment ? attachment : skin) ===
@@ -92,13 +83,14 @@ export function SkinSwitcher() {
borderRadius: "50%", borderRadius: "50%",
}} }}
onClick={() => { onClick={() => {
const slot = camouflage.attachment
? "requestedAttachment"
: "requestedSkin";
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft[slot] = const loader =
draft[slot] === camouflage.id draft.loaders[
camouflage.attachment ? "attachment" : "skin"
];
loader.requested =
loader.requested === camouflage.id
? undefined ? undefined
: camouflage.id; : camouflage.id;
}); });
@@ -82,7 +82,9 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
const revealed = Tankopedia.use((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
const disturbed = Tankopedia.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics); 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 equalize = Duel.use((state) => state.equalize);
const equalizer = Duel.use((state) => state.antagonist.tank.equalizer); const equalizer = Duel.use((state) => state.antagonist.tank.equalizer);
@@ -13,12 +13,14 @@ const camouflageDefinitions = await api.camouflageDefinitions();
export function Title({ skeleton }: MaybeSkeletonComponentProps) { export function Title({ skeleton }: MaybeSkeletonComponentProps) {
const { unwrap, strings } = useLocale(); 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 protagonist = Duel.use((state) => state.protagonist.tank);
const revealed = Tankopedia.use((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
const disturbed = Tankopedia.use((state) => state.disturbed); const disturbed = Tankopedia.use((state) => state.disturbed);
const hasRevealed = useRef(false); const hasRevealed = useRef(false);
const skin = Tankopedia.use((state) => state.requestedSkin); const skin = Tankopedia.use((state) => state.loaders.skin.requested);
const camouflage = const camouflage =
skin === undefined ? undefined : camouflageDefinitions.camouflages[skin]; skin === undefined ? undefined : camouflageDefinitions.camouflages[skin];
const tankName = unwrap(protagonist.name!); const tankName = unwrap(protagonist.name!);
@@ -27,9 +29,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) {
camouflage camouflage
? camouflage.tank_name ? camouflage.tank_name
? unwrap(camouflage.tank_name) ? unwrap(camouflage.tank_name)
: camouflage.name : `${tankName} ${unwrap(camouflage.name!)}`
? `${tankName} ${unwrap(camouflage.name)}`
: tankName
: tankName : tankName
).replace("%(camo_num)", SKIN_NUMBER); ).replace("%(camo_num)", SKIN_NUMBER);
const fontSize = revealed const fontSize = revealed
@@ -109,7 +109,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) {
onPointerDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}
onClick={() => { onClick={() => {
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.modelRequested = true; draft.loaders.model.requested = true;
}); });
}} }}
> >
@@ -49,8 +49,8 @@ export function Lighting({ hasPbr }: Props) {
const revealed = Tankopedia.use((state) => state.revealed); const revealed = Tankopedia.use((state) => state.revealed);
const skinPending = Tankopedia.use( const skinPending = Tankopedia.use(
(state) => (state) =>
state.loadedSkin !== state.skin || state.loaders.skin.requested !== state.loaders.skin.loaded ||
state.loadedAttachment !== state.attachment, state.loaders.attachment.requested !== state.loaders.attachment.loaded,
); );
const animationTime = useRef(REVEAL_ANIMATION_TIME); const animationTime = useRef(REVEAL_ANIMATION_TIME);
@@ -65,6 +65,7 @@ export function Lighting({ hasPbr }: Props) {
useEffect(() => { useEffect(() => {
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.revealed = true; draft.revealed = true;
draft.loaders.model.loaded = true;
}); });
}, []); }, []);
@@ -246,17 +247,6 @@ function Animator({
draft.display = requestedDisplay; 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; return null;
@@ -20,7 +20,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
const canvas = useRef<HTMLCanvasElement>(null!); const canvas = useRef<HTMLCanvasElement>(null!);
const isFullScreen = useFullScreen(); const isFullScreen = useFullScreen();
const protagonist = Duel.use((state) => state.protagonist.tank); 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 thicknessRange = useMemo(() => {
const entries = Object.values(tankDefinitions.tanks); const entries = Object.values(tankDefinitions.tanks);
const filtered = entries.filter( const filtered = entries.filter(
+22 -26
View File
@@ -35,36 +35,28 @@ function skinPath(id: number, skin: number) {
} }
// attachments load the same way as camouflages and can be shown with one // attachments load the same way as camouflages and can be shown with one
const slots = { export function useSkin(id: number, loader: "skin" | "attachment" = "skin") {
skin: { requested: "requestedSkin", loaded: "loadedSkin" }, const requested = Tankopedia.use((state) => state.loaders[loader].requested);
attachment: { requested: "requestedAttachment", loaded: "loadedAttachment" }, const loaded = Tankopedia.use((state) => state.loaders[loader].loaded);
} 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]);
useEffect(() => { useEffect(() => {
const path = if (requested === Tankopedia.state.loaders[loader].loaded) return;
requestedSkin === undefined ? undefined : skinPath(id, requestedSkin);
const path = requested === undefined ? undefined : skinPath(id, requested);
const cached = path === undefined || loadedSkins.has(path);
let settled = false; let settled = false;
if (path === undefined || loadedSkins.has(path)) { if (!cached) {
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft[loaded] = requestedSkin; draft.revealed = false;
}); });
return;
if (!skins.has(path)) skins.set(path, loadSkin(path));
} }
Tankopedia.mutate((draft) => { // swaps halfway through the lighting transition
draft.revealed = false;
});
if (!skins.has(path)) skins.set(path, loadSkin(path));
Promise.all([ Promise.all([
skins.get(path), path && skins.get(path),
new Promise((resolve) => new Promise((resolve) =>
setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3), setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3),
), ),
@@ -73,9 +65,8 @@ export function useSkin(id: number, slot: keyof typeof slots = "skin") {
settled = true; settled = true;
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft[loaded] = requestedSkin; draft.loaders[loader].loaded = requested;
draft[slot] = requestedSkin; if (!cached) draft.revealed = true;
draft.revealed = true;
}); });
}); });
@@ -83,11 +74,16 @@ export function useSkin(id: number, slot: keyof typeof slots = "skin") {
if (settled) return; if (settled) return;
settled = true; settled = true;
if (cached) return;
Tankopedia.mutate((draft) => { Tankopedia.mutate((draft) => {
draft.revealed = true; 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));
} }
+15 -8
View File
@@ -78,10 +78,19 @@ export enum TankopediaRelativeAgainst {
All, All,
} }
interface LoaderState<Type> {
requested: Type;
loaded: Type;
}
interface Tankopedia { interface Tankopedia {
disturbed: boolean; disturbed: boolean;
revealed: boolean; revealed: boolean;
modelRequested: boolean; loaders: {
model: LoaderState<boolean>;
skin: LoaderState<number | undefined>;
attachment: LoaderState<number | undefined>;
};
shot?: Shot; shot?: Shot;
skills: Record<string, number>; skills: Record<string, number>;
relativeAgainst: TankopediaRelativeAgainst; relativeAgainst: TankopediaRelativeAgainst;
@@ -103,12 +112,6 @@ interface Tankopedia {
); );
xpMultiplier: (typeof XP_MULTIPLIERS)[number]; xpMultiplier: (typeof XP_MULTIPLIERS)[number];
customShell?: ShellDefinition; customShell?: ShellDefinition;
requestedSkin?: number;
loadedSkin?: number;
skin?: number;
requestedAttachment?: number;
loadedAttachment?: number;
attachment?: number;
requestedDisplay: TankopediaDisplay; requestedDisplay: TankopediaDisplay;
display: TankopediaDisplay; display: TankopediaDisplay;
statSearch?: string; statSearch?: string;
@@ -119,7 +122,11 @@ const skillDefinitions = await api.skillDefinitions();
export const Tankopedia = new Varuna<Tankopedia, ModelDefinition>((model) => ({ export const Tankopedia = new Varuna<Tankopedia, ModelDefinition>((model) => ({
disturbed: false, disturbed: false,
revealed: 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, relativeAgainst: TankopediaRelativeAgainst.Class,
editStatic: false, editStatic: false,
skills: createDefaultSkills(skillDefinitions), skills: createDefaultSkills(skillDefinitions),