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 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];
@@ -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) => (
<Flex key={group[0].id} direction="column">
{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 (
<Tooltip
key={camouflage.id}
content={
rawName ? (
name
) : (
<Text style={{ color: "var(--orange-9)" }}>{name}</Text>
)
}
side="left"
>
<Tooltip key={camouflage.id} content={name} side="left">
<SidebarIconButton
selected={
(camouflage.attachment ? attachment : skin) ===
@@ -92,13 +83,14 @@ export function SkinSwitcher() {
borderRadius: "50%",
}}
onClick={() => {
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;
});
@@ -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);
@@ -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;
});
}}
>
@@ -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;
@@ -20,7 +20,9 @@ export function HeroSection({ skeleton }: MaybeSkeletonComponentProps) {
const canvas = useRef<HTMLCanvasElement>(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(
+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
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));
}
+15 -8
View File
@@ -78,10 +78,19 @@ export enum TankopediaRelativeAgainst {
All,
}
interface LoaderState<Type> {
requested: Type;
loaded: Type;
}
interface Tankopedia {
disturbed: boolean;
revealed: boolean;
modelRequested: boolean;
loaders: {
model: LoaderState<boolean>;
skin: LoaderState<number | undefined>;
attachment: LoaderState<number | undefined>;
};
shot?: Shot;
skills: Record<string, number>;
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<Tankopedia, ModelDefinition>((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),