Fix: Legendary camouflage parts, materials, names and attachments

Fixes a batch of legendary camouflage problems in the Tankopedia viewer, adds three missing unique camouflages and lets attachments be shown together with a camouflage.

## Camouflage parts (state switchers)
- Camouflages now choose which tank pieces show the same way the game does. Each switcher's load-time scenario script runs at export time (checks for attached camouflage items, or compares the camouflage preset), along with its state-switch actions. This replaces the old guess from part names (new `core/blitzkit/switcherStates.ts`).
- Fixes missing roof MGs, fenders and fuel tanks (IS-3 Bear, Obj. 140 Terror, IS-7, IS-4, FV215b 183, AMX 50 B, KV-1S) and default parts mixed into camouflages (Pershing Speedfire, Forest Witch Fullmetal, Waffenträger Goldstreifen).
- Switchers inside camouflage parts now show one state instead of all of them stacked (Obj. Kust double net, Goldstreifen stowage, Tiger II cat).
- States are matched by original node names, because the GLTF loader renames duplicates.
- Camouflage part meshes with no LODs (`lodIndex -1`) are kept, which restores the Tiger II cat body and missing pieces on about 11 other camouflages.

## Materials
- `AlphaBlend` materials export as transparent. `AlphaBlendAdditive` materials export as additive and are applied in `jsxTree`, and unlit `Textured` materials export as unlit (IS-8 Raroh flames, dinosaur holograms, and glass and screens on a few base tanks).
- Camouflage part materials that share a name with the tank's material no longer replace the tank's camouflage material (Bisonte C45 Unbreakable).

## Names
- With a legendary camouflage selected, the title shows the tank's in-battle name ("GuP Type 95 Ha-Go", "KV-1 Warrior", "Shinobi").
- Camouflages without a name in the game files yet show "Unreleased" in orange instead of their raw string key, and no longer crash the tank names list.

## Attachments
- Attachments (`kind: customization`) are listed in their own group under the camouflages and can be turned on together with a camouflage. They load the same way as camouflages and fade with the same transition.

## Other
- Liberté, You Rock! and Ozzie (single-tank camouflages that only set a preset through `override`) are now available.
- Mastery gun mark meshes (`custom_gun_marks_*`) are no longer drawn (Helsing, HWT20 FalconFire and two camouflage guns).

## Not included
- Pixel Wave, Digital Trace and Forest Shadow are tiled pattern camouflages, which the viewer can't render yet.
- FV301 Corbie's drone position and the dinosaur heads' glow depend on camouflage animations and emissive materials, which are separate PRs.

Fixes #417
Fixes #419
Fixes #421
This commit is contained in:
Mark Greene committed 2026-10-29 15:28:11 +13:00
1 parent 091607b0a8
commit 0f299335d6
18 files changed
+559 -113

No files matched your search

@@ -1,5 +1,5 @@
import { alias } from "@blitzkit/core";
import { Flex, Tooltip } from "@radix-ui/themes";
import { Flex, Text, Tooltip } from "@radix-ui/themes";
import { uniqBy } from "lodash-es";
import { SKIN_NUMBER } from "../../../../../../constants/skinNumber";
import { api } from "../../../../../../core/blitzkit/api";
@@ -14,21 +14,30 @@ 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 requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const disturbed = Tankopedia.use((state) => state.disturbed);
const { unwrap } = useLocale();
const { unwrap, strings } = useLocale();
const skins = uniqBy(
Object.values(camouflageDefinitions.camouflages).filter(
(camouflage) => camouflage.tank_id === tank.id,
),
(camouflage) => unwrap(camouflage.name!),
(camouflage) =>
(camouflage.name && unwrap(camouflage.name)) || camouflage.id,
);
// attachments aren't camouflages so they get their own group
const groups = [
skins.filter((camouflage) => !camouflage.attachment),
skins.filter((camouflage) => camouflage.attachment),
].filter((group) => group.length > 0);
if (skins.length === 0) return null;
return (
<Flex
direction="column"
gap="3"
top="50%"
right={
disturbed && requestedDisplay === TankopediaDisplay.Model
@@ -42,42 +51,64 @@ export function SkinSwitcher() {
pointerEvents: "auto",
}}
>
{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";
{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 top = index === 0 ? "var(--radius-full)" : "0";
const bottom =
index === group.length - 1 ? "var(--radius-full)" : "0";
return (
<Tooltip key={camouflage.id} content={name} side="left">
<SidebarIconButton
selected={skin === camouflage.id}
alt={name}
src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)}
size={{ initial: "3", sm: "4" }}
radius="full"
style={{
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
}}
imageStyle={{
width: "70%",
height: "70%",
borderRadius: "50%",
}}
onClick={() => {
Tankopedia.mutate((draft) => {
draft.requestedSkin =
draft.requestedSkin === camouflage.id
? undefined
: camouflage.id;
});
}}
/>
</Tooltip>
);
})}
return (
<Tooltip
key={camouflage.id}
content={
rawName ? (
name
) : (
<Text style={{ color: "var(--orange-9)" }}>{name}</Text>
)
}
side="left"
>
<SidebarIconButton
selected={
(camouflage.attachment ? attachment : skin) ===
camouflage.id
}
alt={name}
src={alias("api", `/icons/camouflages/${camouflage.id}.webp`)}
size={{ initial: "3", sm: "4" }}
radius="full"
style={{
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
}}
imageStyle={{
width: "70%",
height: "70%",
borderRadius: "50%",
}}
onClick={() => {
const slot = camouflage.attachment
? "requestedAttachment"
: "requestedSkin";
Tankopedia.mutate((draft) => {
draft[slot] =
draft[slot] === camouflage.id
? undefined
: camouflage.id;
});
}}
/>
</Tooltip>
);
})}
</Flex>
))}
</Flex>
);
}
@@ -22,9 +22,16 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) {
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;
// legendary camos carry the tank's in-battle name
const name = (
camouflage
? camouflage.tank_name
? unwrap(camouflage.tank_name)
: camouflage.name
? `${tankName} ${unwrap(camouflage.name)}`
: tankName
: tankName
).replace("%(camo_num)", SKIN_NUMBER);
const fontSize = revealed
? disturbed
? "1.5rem"
@@ -48,7 +48,9 @@ export function Lighting({ hasPbr }: Props) {
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const revealed = Tankopedia.use((state) => state.revealed);
const skinPending = Tankopedia.use(
(state) => state.loadedSkin !== state.skin,
(state) =>
state.loadedSkin !== state.skin ||
state.loadedAttachment !== state.attachment,
);
const animationTime = useRef(REVEAL_ANIMATION_TIME);
@@ -245,9 +247,14 @@ function Animator({
});
}
if (x >= 1 && Tankopedia.state.skin !== Tankopedia.state.loadedSkin) {
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;
});
}
});
@@ -30,6 +30,8 @@ export function TankModel() {
const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes);
const skinGltf = useSkin(protagonist.tank.id);
const attachmentGltf = useSkin(protagonist.tank.id, "attachment");
const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
useTankTransform(track, turret, turretContainer, gunContainer);
@@ -94,7 +96,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return skinTree(node, skinGltf, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh
@@ -171,7 +173,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return skinTree(node, skinGltf, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh
@@ -252,7 +254,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return skinTree(node, skinGltf, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh