Feature: Looping garage animations for legendary camouflages

Legendary camo add-ons now loop their garage animation in the Tankopedia viewer. It's always on, with no settings.

- The garage replay now also works out which motions end up looping, and the export writes only those clips
- Customization parts export skeletons, skin weights and sockets
- Parts render as primitives so their bones can animate, with attachments layered on top
- Base tank models are unchanged
This commit is contained in:
Mark Greene committed 2026-10-31 22:15:57 +13:00
commit 8566b2cc2f
94 files changed
+2037 -1861

No files matched your search

@@ -0,0 +1,36 @@
.outer {
padding: 0 var(--space-4);
margin-top: var(--space-3);
img {
width: 1.25em;
height: 1.25em;
border-radius: 50%;
}
.center {
position: relative;
background: linear-gradient(var(--pink-3), var(--purple-2));
box-shadow: var(--shadow-3);
max-width: 40rem;
padding: var(--space-5);
border-radius: var(--radius-4);
.important {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
background-color: inherit;
padding: var(--space-3);
}
}
}
@@ -0,0 +1,48 @@
import { HeartFilledIcon } from "@radix-ui/react-icons";
import { Button, Em, Flex, Strong, Text } from "@radix-ui/themes";
import { donationTiers } from "../../config/donations";
import styles from "./index.module.css";
export function Beg() {
return (
<Flex align="center" gap="4" direction="column" className={styles.outer}>
<Text size="3" className={styles.important}>
<Flex gap="2" align="center">
<img src="https://github.com/tresabhi.png" />
<Em>We need your attention</Em>
</Flex>
</Text>
<Flex direction="column" className={styles.center} gap="4">
<Flex direction="column" gap="3">
<Text color="tomato">
We can no longer keep BlitzKit sustainable for <Strong>free</Strong>
.
</Text>
<Text>
We want to add tank modules, player statistics, rebalancing patch
notes, bring back the Discord bot, and more.
</Text>
<Text>
But paying out of pocket makes that impossible. We are an earnest
non-profit, so consider donating to make the above possible. 100% of
your money goes back into BlitzKit.
</Text>
</Flex>
<Flex gap="3">
{donationTiers.map(({ name, value, link }) => (
<a target="_blank" key={name} href={link}>
<Button size="3" color="crimson">
{value === null && <HeartFilledIcon />}
{value === null ? "Custom" : `$${value}`}
</Button>
</a>
))}
</Flex>
</Flex>
</Flex>
);
}
@@ -0,0 +1,89 @@
import { Code, Flex, Heading, Link, Text } from "@radix-ui/themes";
import { useEffect, useState, type ReactNode } from "react";
import type { MaybeSkeletonComponentProps } from "../types/maybeSkeletonComponentProps";
import { InlineSkeleton } from "./InlineSkeleton";
const TIME = 1792598400 * 1000;
export function Countdown({ skeleton }: MaybeSkeletonComponentProps) {
const [timeLeft, setTimeLeft] = useState<number>(TIME - Date.now());
useEffect(() => {
const interval = setInterval(() => {
setTimeLeft(TIME - Date.now());
}, 1000);
return () => {
clearInterval(interval);
};
}, []);
let string: ReactNode;
if (timeLeft > 0) {
const hours = Math.floor(timeLeft / (1000 * 60 * 60));
const minutes = Math.floor((timeLeft / (1000 * 60)) % 60)
.toString()
.padStart(2, "0");
const seconds = Math.floor((timeLeft / 1000) % 60)
.toString()
.padStart(2, "0");
string = (
<>
revealed in{" "}
<Code highContrast>
{hours}h {minutes}m {seconds}s
</Code>
</>
);
} else {
string = "have been revealed.";
}
return (
<Flex justify="center" px="4">
<Link
color="gray"
highContrast
style={{ display: "content" }}
href="https://preview.blitzkit.app/tanks"
underline="none"
>
<Flex
mt="3"
px="5"
py="4"
style={{
borderRadius: "var(--radius-3)",
backgroundImage: "url(https://i.imgur.com/xgngxoy.png)",
backgroundSize: "cover",
backgroundPosition: "center",
boxSizing: "border-box",
boxShadow: "0 0 var(--space-7) var(--white-a2)",
}}
align="center"
gap="5"
>
<img
src="https://i.imgur.com/kXV3r3P.png"
style={{
height: "min(15vw, 5rem)",
filter: "drop-shadow(0.5rem 0.5rem 1rem #00000080)",
}}
/>
<Flex direction="column">
<Heading size={{ initial: "4", sm: "6" }}>
Tier XIs {!skeleton && string}{" "}
{skeleton && <InlineSkeleton width="10rem" />}
</Heading>
<Text size={{ initial: "2", sm: "3" }}>
Get ready to go to the Tankopedia!
</Text>
</Flex>
</Flex>
</Link>
</Flex>
);
}
@@ -0,0 +1,135 @@
---
import { Grid, Text } from "@radix-ui/themes";
import { credits } from "../config/credits";
import { getStrings } from "../core/i18n/getStrings";
interface Props {
locale: string;
}
const { locale } = Astro.props;
const strings = getStrings(locale);
---
<style>
.credits {
display: flex;
justify-content: center;
padding: var(--space-4);
.inner {
display: flex;
flex-direction: column;
gap: var(--space-2);
width: 100%;
max-width: 80rem;
.list {
gap: var(--space-5);
.link {
display: contents;
.person {
--profile-size: 5rem;
position: relative;
display: flex;
flex-direction: column;
padding: var(--space-4);
padding-top: 0;
margin-top: calc(var(--profile-size) / 2);
gap: var(--space-1);
box-shadow: var(--shadow-3);
border-radius: var(--radius-3);
.contain {
border-radius: inherit;
position: absolute;
top: 0;
right: 0;
width: 100%;
height: 100%;
overflow: hidden;
.hint {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
filter: saturate(20) brightness(0.125) blur(4rem);
}
}
.text {
position: relative;
}
img {
border-radius: 50%;
position: relative;
margin-top: calc(var(--profile-size) / -2);
object-fit: cover;
width: var(--profile-size);
height: var(--profile-size);
margin-bottom: var(--space-2);
}
}
}
}
}
}
</style>
<div class="credits">
<div class="inner">
<Grid
className="list"
py="4"
columns="repeat(auto-fill, minmax(16rem, 1fr))"
flow="dense"
gap="4"
gapY="8"
>
{
credits.map((person) => (
<a target="_blank" href={person.url} class="link">
<div class="person">
<div class="contain">
<div
class="hint"
style={`background-image: url(${person.image})`}
/>
</div>
<img src={person.image} />
<Text weight="medium" className="text" color="gray" highContrast>
{person.name}
</Text>
<Text className="text" color="gray" wrap="pretty">
{person.description}
</Text>
</div>
</a>
))
}
</Grid>
</div>
</div>
+3 -6
View File
@@ -43,9 +43,9 @@ const strings = getStrings(locale);
}}
>
<Heading>BlitzKit</Heading>
<Text color="gray">{strings.website.footer.motto}</Text>
<Text size="1" color="gray">
{strings.website.footer.copyright}
<Text size="2" color="gray">{strings.website.footer.motto}</Text>
<Text size="2" color="gray">
{strings.website.footer.association}
</Text>
<Box mt="2">
@@ -84,9 +84,6 @@ const strings = getStrings(locale);
sm: "start",
}}
>
<LinkI18n {locale} href="/docs/about/credits" color="gray">
{strings.website.footer.about.credits}
</LinkI18n>
<Link
href="https://discord.gg/nDt7AjGJQH"
target="_blank"
+8 -44
View File
@@ -5,7 +5,7 @@ import {
GearIcon,
HamburgerMenuIcon,
} from "@radix-ui/react-icons";
import { Box, Code, Flex, IconButton, Link } from "@radix-ui/themes";
import { Box, Code, Flex, Grid, IconButton, Link } from "@radix-ui/themes";
import { resolveBranchName } from "../core/blitzkit/branches";
import { getStrings } from "../core/i18n/getStrings";
import { Var } from "../core/radix/var";
@@ -20,7 +20,7 @@ export interface Props {
const { integrated = false } = Astro.props;
const toolsFiltered = Object.values(tools).filter(
(tool) => !tool.href && !tool.disabled
(tool) => !tool.href && !tool.disabled,
);
const { locale } = Astro.props;
const branchName = resolveBranchName(locale);
@@ -105,7 +105,7 @@ const strings = getStrings(locale);
<Flex
className="content"
width="100%"
p={{ initial: "4", sm: "3" }}
p="3"
direction="column"
align="center"
>
@@ -133,41 +133,6 @@ const strings = getStrings(locale);
<Box flexGrow="1" />
<Flex
align="center"
gap={{ initial: "3", md: "6" }}
display={{ initial: "none", sm: "flex" }}
justify="center"
className="tools"
>
{
toolsFiltered.map((tool) => {
const selected = Astro.url.pathname.includes(`/${tool.id}`);
const toolStrings =
strings.website.tools[
(tool.strings ??
tool.id) as keyof typeof strings.website.tools
];
return (
<LinkI18n
{locale}
weight={selected ? "medium" : "regular"}
color="gray"
highContrast={selected}
size={{ initial: "1", md: "2" }}
href={`/${tool.id}`}
underline="hover"
>
{toolStrings.name}
</LinkI18n>
);
})
}
</Flex>
<Box flexGrow="1" />
<Flex align="center" gap="4">
<Link
style={{ display: "flex", alignContent: "center" }}
@@ -191,7 +156,7 @@ const strings = getStrings(locale);
<NavbarAccount {locale} client:idle />
<Flex align="center" display={{ initial: "flex", sm: "none" }}>
<Flex align="center">
<IconButton variant="ghost" color="gray" className="hamburger">
<Cross1Icon data-expanded />
<HamburgerMenuIcon data-closed />
@@ -200,11 +165,10 @@ const strings = getStrings(locale);
</Flex>
</Flex>
<Flex
direction="column"
<Grid
data-expanded
columns="repeat(auto-fill, minmax(30rem, 1fr))"
gap="3"
display={{ initial: "flex", sm: "none" }}
>
{
toolsFiltered.map((tool) => {
@@ -229,7 +193,7 @@ const strings = getStrings(locale);
}}
>
<Flex
py="4"
p="6"
justify="end"
flexGrow="1"
style={{
@@ -243,7 +207,7 @@ const strings = getStrings(locale);
);
})
}
</Flex>
</Grid>
</Flex>
</Flex>
@@ -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,131 +0,0 @@
import { alias } from "@blitzkit/core";
import type { BlitzKitStrings } from "@blitzkit/i18n";
import { Flex, Tooltip } from "@radix-ui/themes";
import { GARAGE_EVENT } from "../../../../../../core/blitzkit/skinAnimations/runtime";
import { useLocale } from "../../../../../../hooks/useLocale";
import { skinAnimationEvent } from "../../../../../../hooks/useSkinAnimation";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import { SidebarIconButton } from "./SidebarIconButton";
type SkinEventGroup =
keyof BlitzKitStrings["website"]["tools"]["tankopedia"]["sandbox"]["skin_events"];
// the camo column is one button wide
const SKIN_COLUMN_OFFSET = {
initial: "calc(var(--space-3) + 40px + var(--space-2))",
sm: "calc(var(--space-3) + 48px + var(--space-2))",
};
// icons are served from /icons/skin-events/[group].webp
const SKIN_EVENT_GROUPS: {
group: SkinEventGroup;
events: string[];
}[] = [
{ group: "garage", events: [GARAGE_EVENT] },
{
group: "shoot",
events: ["OnShoot", "OnShootFirstInBurst", "OnShootFirstInBurstSniper"],
},
{
group: "kill",
events: [
"KillEnemy",
"KillEnemyAmmoBayDestroyed",
"KillEnemyRam",
"OnKillEnemyAmmoBayDestroyed",
"OnKillEnemyRam",
],
},
{ group: "damage", events: ["PlayerHasDamageFromEnemy"] },
{ group: "damage_enemy", events: ["AttackerDamagedEnemy"] },
// camos play the same animation for both
{
group: "no_pierce",
events: ["AttackerDidNotPierceEnemy", "AttackerRichochetEmeny"],
},
{ group: "move", events: ["OnStartMovement"] },
{
group: "consumable",
events: [
"ConsumableUsedAfterburning",
"ConsumableUsedFreeAfterburning",
"ConsumableUsedAbilityAfterburning",
"ConsumableUsedImprovedAfterburning",
],
},
{ group: "reload", events: ["ReloadStarted"] },
];
export function SkinAnimationSwitcher() {
const { strings } = useLocale();
const skinEvents = Tankopedia.use((state) => state.skinEvents);
const disturbed = Tankopedia.use((state) => state.disturbed);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
// one animation at a time; overlapping triggers look broken
const disabled = Tankopedia.use((state) => state.skinAnimating);
const groups = SKIN_EVENT_GROUPS.flatMap(({ events, ...group }) => {
// the first event the camo listens to stands in for its group
const event = events.find((event) => skinEvents.includes(event));
return event ? [{ ...group, event }] : [];
});
// replaying the garage animation sits apart from the battle triggers
const sections = [
groups.filter(({ group }) => group === "garage"),
groups.filter(({ group }) => group !== "garage"),
].filter((section) => section.length > 0);
const visible =
disturbed &&
requestedDisplay === TankopediaDisplay.Model &&
groups.length > 0;
return (
<Flex
direction="column"
gap="3"
top="50%"
right={visible ? SKIN_COLUMN_OFFSET : "-4rem"}
style={{
position: "absolute",
transform: "translateY(-50%)",
transitionDuration: "200ms",
pointerEvents: "auto",
}}
>
{sections.map((section) => (
<Flex key={section[0].group} direction="column">
{section.map(({ group, event }, index) => {
const name =
strings.website.tools.tankopedia.sandbox.skin_events[group];
const top = index === 0 ? "var(--radius-full)" : "0";
const bottom =
index === section.length - 1 ? "var(--radius-full)" : "0";
return (
<Tooltip key={group} content={name} side="left">
<SidebarIconButton
selected={false}
alt={name}
src={alias("api", `/icons/skin-events/${group}.webp`)}
size={{ initial: "3", sm: "4" }}
radius="full"
style={{
borderRadius: `${top} ${top} ${bottom} ${bottom}`,
}}
disabled={disabled}
imageStyle={{
objectFit: "contain",
opacity: disabled ? 0.4 : 1,
transition: "opacity 200ms",
}}
onClick={() => skinAnimationEvent.dispatch(event)}
/>
</Tooltip>
);
})}
</Flex>
))}
</Flex>
);
}
@@ -13,7 +13,10 @@ const camouflageDefinitions = await api.camouflageDefinitions();
export function SkinSwitcher() {
const tank = Duel.use((state) => state.protagonist.tank);
const skin = Tankopedia.use((state) => state.skin);
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 } = useLocale();
@@ -24,11 +27,18 @@ export function SkinSwitcher() {
(camouflage) => unwrap(camouflage.name!),
);
// 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,40 +52,55 @@ 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 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={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.skin =
draft.skin === camouflage.id ? undefined : camouflage.id;
});
}}
/>
</Tooltip>
);
})}
return (
<Tooltip key={camouflage.id} content={name} 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={() => {
Tankopedia.mutate((draft) => {
const loader =
draft.loaders[
camouflage.attachment ? "attachment" : "skin"
];
loader.requested =
loader.requested === camouflage.id
? undefined
: camouflage.id;
});
}}
/>
</Tooltip>
);
})}
</Flex>
))}
</Flex>
);
}
@@ -50,7 +50,6 @@ import {
SidebarIconButton,
SidebarIconGroup,
} from "./components/SidebarIconButton";
import { SkinAnimationSwitcher } from "./components/SkinAnimationSwitcher";
import { SkinSwitcher } from "./components/SkinSwitcher";
import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher";
import { Thicknesses } from "./components/Thicknesses";
@@ -83,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);
@@ -94,7 +95,6 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
<Thicknesses skeleton={skeleton} thicknessRange={thicknessRange} />
<SkinSwitcher />
<SkinAnimationSwitcher />
<Flex
gap="2"
@@ -13,18 +13,25 @@ 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.skin);
const skin = Tankopedia.use((state) => state.loaders.skin.requested);
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)
: `${tankName} ${unwrap(camouflage.name!)}`
: tankName
).replace("%(camo_num)", SKIN_NUMBER);
const fontSize = revealed
? disturbed
? "1.5rem"
@@ -102,7 +109,7 @@ export function Title({ skeleton }: MaybeSkeletonComponentProps) {
onPointerDown={(event) => event.stopPropagation()}
onClick={() => {
Tankopedia.mutate((draft) => {
draft.modelRequested = true;
draft.loaders.model.requested = true;
});
}}
>
@@ -47,6 +47,11 @@ export function Lighting({ hasPbr }: Props) {
const highGraphics = TankopediaPersistent.use((state) => state.highGraphics);
const requestedDisplay = Tankopedia.use((state) => state.requestedDisplay);
const revealed = Tankopedia.use((state) => state.revealed);
const skinPending = Tankopedia.use(
(state) =>
state.loaders.skin.requested !== state.loaders.skin.loaded ||
state.loaders.attachment.requested !== state.loaders.attachment.loaded,
);
const animationTime = useRef(REVEAL_ANIMATION_TIME);
const t0 = useRef(performance.now() / 1e3 - animationTime.current);
@@ -60,6 +65,7 @@ export function Lighting({ hasPbr }: Props) {
useEffect(() => {
Tankopedia.mutate((draft) => {
draft.revealed = true;
draft.loaders.model.loaded = true;
});
}, []);
@@ -96,6 +102,15 @@ export function Lighting({ hasPbr }: Props) {
invalidate();
}, [revealed]);
useEffect(() => {
if (!skinPending) return;
animationTime.current = TRANSITION_ANIMATION_TIME;
t0.current = performance.now() / 1e3;
setAnimate(true);
}, [skinPending]);
return (
<>
<ContactShadows
@@ -150,6 +165,7 @@ export function Lighting({ hasPbr }: Props) {
key={index}
position={position}
intensity={intensity}
userData={{ intensity }}
penumbra={1}
castShadow={highGraphics}
decay={1}
@@ -200,6 +216,11 @@ function Animator({
for (const child of wrapper.current.children) {
if (child instanceof SpotLight) {
child.angle = ANGLE * t * factor;
child.intensity = child.userData.intensity;
if (child.angle === 0) {
child.intensity = 0;
}
} else if (child instanceof HemisphereLight) {
child.intensity = HEMISPHERE_INTENSITY * t * factor;
}
@@ -1,5 +1,6 @@
import { invalidate, useThree, type ThreeEvent } from "@react-three/fiber";
import { useRef } from "react";
import type { QuicklimeEvent } from "quicklime";
import { useEffect, useRef } from "react";
import { Group, Mesh, MeshStandardMaterial, Vector2 } from "three";
import { applyPitchYawLimits } from "../../../../../../core/blitz/applyPitchYawLimits";
import { hasEquipment } from "../../../../../../core/blitzkit/hasEquipment";
@@ -15,6 +16,7 @@ import { Duel } from "../../../../../../stores/duel";
import { Tankopedia } from "../../../../../../stores/tankopedia";
import { TankopediaDisplay } from "../../../../../../stores/tankopediaPersistent/constants";
import { ModelTankWrapper } from "../../../../../Armor/components/ModelTankWrapper";
import { transitionEvent } from "./Lighting";
export function TankModel() {
const protagonist = Duel.use((draft) => draft.protagonist);
@@ -30,11 +32,50 @@ export function TankModel() {
const gunModelDefinition = turretModelDefinition.guns[protagonist.gun.id];
const { gltf } = useModel(protagonist.tank.id);
const nodes = Object.values(gltf.nodes);
const skin = Tankopedia.use((state) => state.skin);
const skinGltf = useSkin(protagonist.tank.id, skin);
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);
useSkinAnimation(skinGltf);
useSkinAnimation(skins);
useEffect(() => {
const emissiveMaterials = new Set<MeshStandardMaterial>();
const roots = [
gltf.scene,
...skins.flatMap((skin) => [...skin.parts.values()].flat()),
];
for (const root of roots) {
root.traverse((object) => {
if (
object instanceof Mesh &&
object.material instanceof MeshStandardMaterial &&
object.material.emissiveMap
) {
emissiveMaterials.add(object.material);
}
});
}
// emissive parts fade with the lights instead of glowing in the dark
function applyIntensity(intensity: number) {
for (const material of emissiveMaterials) {
material.emissiveIntensity = intensity;
}
}
function handleTransitionEvent(event: QuicklimeEvent<number>) {
applyIntensity(event.data);
}
applyIntensity(transitionEvent.last!);
transitionEvent.on(handleTransitionEvent);
return () => {
transitionEvent.off(handleTransitionEvent);
};
}, [gltf, skinGltf, attachmentGltf]);
return (
<ModelTankWrapper ref={hullContainer}>
@@ -97,7 +138,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return skinTree(node, skinGltf, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh
@@ -183,7 +224,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return skinTree(node, skinGltf, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh
@@ -273,7 +314,7 @@ export function TankModel() {
window.removeEventListener("pointerup", handlePointerUp);
}
return skinTree(node, skinGltf, {
return skinTree(node, skins, {
mesh(_, props, key) {
return (
<mesh
@@ -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(
+102
View File
@@ -0,0 +1,102 @@
interface CreditPeople {
name: string;
description: string;
url?: string;
image: string;
}
export const credits: CreditPeople[] = [
{
name: "TrèsAbhi",
description: "Founder and lead developer",
url: "https://abhi-deep.com/",
image: "https://github.com/tresabhi.png",
},
{
name: "HitJack",
description: "Developer on website",
url: "https://marksstuff.com",
image: "https://github.com/Hitjack007.png",
},
{
name: "aeson000",
description: "Advisor on game and website",
url: "https://discord.gg/WHdER7ZPAD",
image: "/media/users/aeson000.png",
},
{
name: "GonnaHetzMe",
description: "Developer and advisor on back end",
url: "https://github.com/karelpak1",
image: "https://github.com/karelpak1.png",
},
{
name: "ChickenMan7777",
description: "Moderator and former advisor on game mechanics",
url: "https://discord.gg/rEsUbXCqD9",
image: "/media/users/chickenman7777.webp",
},
{
name: "Avenir",
description: "Moderator and elite Advanced Recon Commando",
image: "/media/users/avenir.webp",
},
{
name: "Pyogenics",
description: "Former advisor on game 3D format",
url: "https://github.com/Pyogenics",
image: "https://github.com/Pyogenics.png",
},
{
name: "Prince_NA",
description: "Former advisor on bot and website",
image: "/media/users/prince-na.webp",
},
{
name: "Vovko",
description: "Former advisor on bot",
url: "https://amth.one/",
image: "https://github.com/cufee.png",
},
{
name: "S0AP",
description: "Former advisor on and advertiser for bot",
url: "https://discord.gg/3KfH93um3Y",
image: "/media/users/s0ap.webp",
},
{
name: "Synx",
description: "Former advisor on bot and website",
url: "https://www.youtube.com/@Synx_Blitz",
image: "/media/users/synx.webp",
},
{
name: "34wotb",
description: "Former advisor on bot",
url: "https://www.twitch.tv/34wotb/",
image: "/media/users/34wotb.jpg",
},
{
name: "HisRoyalFatness",
description: "Former advisor on website",
url: "https://www.youtube.com/@hisroyalfatness",
image: "/media/users/hisroyalfatness.jpg",
},
{
name: "Minitelrose",
description: "Former advisor on game mechanics",
url: "https://wotinspector.com/",
image: "/media/users/minitelrose.webp",
},
{
name: "Maddox",
description: "Game asset decompression",
url: "https://github.com/Maddoxkkm",
image: "https://github.com/Maddoxkkm.png",
},
{
name: "TJ Duff",
description: "Chief executive cheer leader",
image: "/media/users/tj-duff.jpg",
},
];
+23
View File
@@ -0,0 +1,23 @@
// TODO: source this from the open collective api, if even possible
export const donationTiers = [
{
name: "The Backer",
value: 5,
link: "https://opencollective.com/blitzkit/contribute/backers-106312/checkout",
},
{
name: "The Engineer",
value: 10,
link: "https://opencollective.com/blitzkit/contribute/the-engineer-107057/checkout",
},
{
name: "The Patron",
value: 25,
link: "https://opencollective.com/blitzkit/contribute/the-patron-107056/checkout",
},
{
name: "The Sponsor",
value: null,
link: "https://opencollective.com/blitzkit/donate",
},
];
+4
View File
@@ -74,4 +74,8 @@ export const tools: Record<string, Tool> = {
href: "https://discord.gg/nDt7AjGJQH",
button: "plum",
},
credits: {
id: "credits",
button: "bronze",
},
};
+18 -4
View File
@@ -1,5 +1,21 @@
import type { ComponentProps, ReactNode } from "react";
import { Mesh, MeshStandardMaterial, Object3D } from "three";
import {
AdditiveBlending,
Mesh,
MeshStandardMaterial,
Object3D,
type Material,
} from "three";
export function prepareMaterial(material: Material | Material[]) {
if (!(material instanceof MeshStandardMaterial)) return;
material.depthWrite = true;
if (material.userData.blending === "additive") {
material.blending = AdditiveBlending;
}
}
export function jsxTree(
node: Object3D,
@@ -25,9 +41,7 @@ export function jsxTree(
scale: node.scale,
};
if (props.material instanceof MeshStandardMaterial) {
props.material.depthWrite = true;
}
prepareMaterial(props.material);
return mergers?.mesh ? (
mergers.mesh(node, props, node.uuid)
@@ -1,18 +0,0 @@
import type { Node, Scene } from "@gltf-transform/core";
export function addExtras(property: Node | Scene, extras: object) {
property.setExtras({ ...property.getExtras(), ...extras });
}
/** glTF extras must be plain JSON; sc2 data holds bigints */
export function toExtras(value: unknown) {
return JSON.parse(
JSON.stringify(value, (_, item) =>
typeof item === "bigint"
? Number(item)
: item instanceof ArrayBuffer
? undefined
: item,
),
);
}
@@ -1,834 +0,0 @@
import {
AnimationMixer,
LoopRepeat,
Object3D,
Quaternion,
Vector3,
type AnimationAction,
type AnimationClip,
} from "three";
import { degToRad } from "three/src/math/MathUtils.js";
import type { SkinMotion } from "./skeleton";
interface Action {
"act.delay": number;
"act.delayVariation": number;
"act.entityName": string;
"act.event": number;
"act.eventToTrigger": string;
"act.motionSpeed"?: number;
"act.stopAfterNRepeats": number;
"act.switchIndex": number;
"act.type": number;
"act.userEventId": string;
}
interface Value {
"any.type": string;
"any.value": unknown;
}
interface ScriptNode {
type: string;
node: Record<string, unknown> & { defaults?: Record<string, Value> };
connections?: Record<
string,
{ pinIn: string; pinOut: string; target: string }
>;
}
interface Script {
nodes: Record<string, ScriptNode>;
variables?: Record<string, Value>;
}
interface Scenario {
owner: Object3D;
script: Script;
variables: Map<string, unknown>;
}
const SELF_ENTITY = "*** Self ***";
const MAX_DEPTH = 256;
/** events firing events, as a last guard against graphs that loop */
const MAX_NESTING = 32;
const SCENE_LOADED_EVENT = 1;
const USER_EVENT = 2;
const ANIMATION_END_EVENTS = [3, 8];
const START_ANIMATION_ACTION = 6;
const STOP_ANIMATION_ACTION = 7;
const STATE_SWITCH_ACTION = 9;
const TRIGGER_EVENT_ACTION = 10;
const ANIMATING_ACTIONS = [
START_ANIMATION_ACTION,
STOP_ANIMATION_ACTION,
STATE_SWITCH_ACTION,
TRIGGER_EVENT_ACTION,
];
const ANIMATING_FUNCTIONS = [
"StartAnimation",
"StopAnimation",
"ActivateState",
"MotionUpdateSpeed",
"MotionResume",
"SetRotation",
"TriggerCustomEvent",
];
// the pins graph execution leaves a node through
const EXEC_OUTPUTS = ["event", "exit", "true", "false"];
/** fired once the camo is shown, like entering the garage */
export const HANGAR_EVENTS = ["TankAliveInHangar", "TankIsShownInNewScreen"];
/** not a game event; replays the garage animations */
export const GARAGE_EVENT = "Garage";
/** events the game fires around battles, never offered as triggers */
const IGNORED_EVENTS = [
...HANGAR_EVENTS,
"onBattleStart",
"onBattleRestart",
"TankAliveInBattle",
"RestartFX",
];
function nameOf(object: Object3D): string {
return object.userData.name ?? object.name;
}
function findChild(entity: Object3D, name: string): Object3D | undefined {
for (const child of entity.children) {
if (nameOf(child) === name) return child;
const found = findChild(child, name);
if (found) return found;
}
}
function resolveTarget(owner: Object3D, entityName: string, part: Object3D) {
if (entityName === SELF_ENTITY) return owner;
const [first, ...rest] = entityName.split("->");
let start = findChild(owner, first);
// targets may also be an ancestor or one of its other descendants
for (let ancestor = owner; ancestor !== part && !start; ) {
ancestor = ancestor.parent!;
start = nameOf(ancestor) === first ? ancestor : findChild(ancestor, first);
}
return rest.reduce<Object3D | undefined>(
(current, name) => current && findChild(current, name),
start,
);
}
/**
* Plays the animations of camo parts. Parts are passed as their roots because
* they get rendered away from the loaded scene.
*/
export function createSkinRuntime(parts: Object3D[], clips: AnimationClip[]) {
const mixer = new AnimationMixer(new Object3D());
const partOf = new Map<Object3D, Object3D>();
const scenarios: Scenario[] = [];
const actions = new Map<Object3D, Action[]>();
const playing = new Map<AnimationAction, Object3D>();
const timers: [time: number, run: () => void, ambient: boolean][] = [];
// garage animations are ambient: they don't block the menu and a trigger
// stops them, unlike animations a trigger started
const ambientActions = new Set<AnimationAction>();
let ambient = false;
const events = new Set<string>();
const internalEvents = new Set<string>();
const activeStates = new Map<Object3D, number>();
const motionSpeeds = new Map<Object3D, number>();
// reset whenever the menu starts a new session (a trigger or a replay)
const firedAlarms = new Set<string>();
const started = new Set<Object3D>();
let nesting = 0;
let elapsed = 0;
for (const part of parts) {
part.traverse((object) => partOf.set(object, part));
}
partOf.forEach((_, object) => {
const { scenario, actions: objectActions } = object.userData;
if (objectActions) {
actions.set(object, objectActions);
for (const action of objectActions as Action[]) {
if (action["act.event"] === USER_EVENT) {
events.add(action["act.userEventId"]);
}
if (action["act.type"] === TRIGGER_EVENT_ACTION) {
internalEvents.add(action["act.eventToTrigger"]);
}
}
}
if (scenario === undefined) return;
const scripts = partOf.get(object)!.userData.scripts as
| Record<string, Script>
| undefined;
const script = scripts?.[scenario];
if (!script) return;
scenarios.push({ owner: object, script, variables: new Map() });
for (const node of Object.values(script.nodes)) {
if (node.node["ScenarioEventNode.eventName"] === "Custom") {
events.add(node.node.defaults!["Event Name"]["any.value"] as string);
}
if (
node.node["scenarioFunctionNode.functionName"] === "TriggerCustomEvent"
) {
internalEvents.add(
node.node.defaults?.["event name"]?.["any.value"] as string,
);
}
}
});
mixer.addEventListener("finished", ({ action }) => {
const entity = playing.get(action);
if (!entity) return;
playing.delete(action);
within(ambientActions.has(action), () => {
for (const event of ANIMATION_END_EVENTS) fireAction(entity, event);
for (
let ancestor: Object3D | null = entity;
ancestor;
ancestor = ancestor.parent
) {
const owned = scenarios.filter(({ owner }) => owner === ancestor);
if (owned.length === 0) continue;
for (const scenario of owned) {
fireScenario(scenario, "Motion Fragment Stop");
fireScenario(scenario, "Motion Sequence Stop");
}
break;
}
});
});
function schedule(delay: number, run: () => void) {
if (delay <= 0) {
run();
} else {
timers.push([elapsed + delay, run, ambient]);
}
}
// runs follow-up work in the session (ambient or triggered) that caused it
function within(isAmbient: boolean, run: () => void) {
const previous = ambient;
ambient = isAmbient;
try {
run();
} finally {
ambient = previous;
}
}
function startAnimation(entity: Object3D, repeats?: number, speed = 1) {
const motion = entity.userData.motion as SkinMotion | undefined;
if (!motion) {
for (const child of entity.children) {
startAnimation(child, repeats, speed);
}
return;
}
const clip = clips.find((clip) => clip.name === motion.clip);
// some clips restart themselves when they end; once per session keeps
// them from looping forever
if (!clip || started.has(entity)) return;
started.add(entity);
const action = mixer.clipAction(clip, partOf.get(entity));
const count = repeats ?? motion.repeats;
action.reset();
// endless loops play once so garage animations settle and the triggers
// free up; the menu can replay them
action.setLoop(LoopRepeat, count > 0 ? count : 1);
action.clampWhenFinished = !motion.rewind;
action.timeScale =
motion.playbackRate * speed * (motionSpeeds.get(entity) ?? 1);
action.play();
playing.set(action, entity);
if (ambient) {
ambientActions.add(action);
} else {
ambientActions.delete(action);
}
}
function stopAnimation(entity: Object3D) {
for (const [action, owner] of playing) {
if (owner !== entity) continue;
action.stop();
playing.delete(action);
}
}
// graphs may set the speed before starting the motion
function setMotionSpeed(entity: Object3D, speed: number) {
motionSpeeds.set(entity, speed);
for (const [action, owner] of playing) {
if (owner !== entity) continue;
action.timeScale =
(owner.userData.motion as SkinMotion).playbackRate * speed;
}
}
function resumeMotion(entity: Object3D) {
for (const [action, owner] of playing) {
if (owner === entity) action.paused = false;
}
}
function activateState(entity: Object3D, state: number) {
const states = entity.userData.stateNames as string[] | undefined;
// re-activating the current state is a no-op; graphs rely on this
if (!states || activeStates.get(entity) === state) return;
activeStates.set(entity, state);
for (const child of entity.children) {
const index = states.indexOf(nameOf(child));
if (index !== -1) child.visible = index === state;
}
for (const scenario of scenarios) {
if (scenario.owner === entity) {
fireScenario(scenario, "State Activated", state);
}
}
}
function runAction(owner: Object3D, action: Action) {
const target = resolveTarget(
owner,
action["act.entityName"],
partOf.get(owner)!,
);
if (!target) return;
schedule(
action["act.delay"] + Math.random() * action["act.delayVariation"],
() => {
switch (action["act.type"]) {
case START_ANIMATION_ACTION:
startAnimation(
target,
action["act.stopAfterNRepeats"] > 0
? action["act.stopAfterNRepeats"]
: undefined,
action["act.motionSpeed"] || 1,
);
break;
case STOP_ANIMATION_ACTION:
stopAnimation(target);
break;
case STATE_SWITCH_ACTION:
activateState(target, action["act.switchIndex"]);
break;
case TRIGGER_EVENT_ACTION:
trigger(action["act.eventToTrigger"]);
break;
}
},
);
}
function fireAction(entity: Object3D, event: number, name?: string) {
for (const action of actions.get(entity) ?? []) {
if (
action["act.event"] === event &&
(name === undefined || action["act.userEventId"] === name)
) {
runAction(entity, action);
}
}
}
function fireScenario(scenario: Scenario, event: string, value?: unknown) {
if (nesting >= MAX_NESTING) return;
nesting++;
try {
runScenario(scenario, event, value);
} catch (error) {
// an unsupported graph only loses its own animations
console.warn(`Skin scenario failed on "${event}"`, error);
} finally {
nesting--;
}
}
function runScenario(scenario: Scenario, event: string, value?: unknown) {
const { nodes } = scenario.script;
let depth = 0;
function input(id: string, pin: string): unknown {
const node = nodes[id];
const connection = Object.values(node.connections ?? {}).find(
(connection) => connection.pinIn === pin,
);
return connection
? output(connection.target, connection.pinOut)
: node.node.defaults?.[pin]?.["any.value"];
}
function output(id: string, pin: string): unknown {
const { type, node } = nodes[id];
const get = (name: string) => input(id, name);
const number = (name: string) => Number(get(name) ?? 0);
switch (type) {
case "ScenarioSceneInfoNode":
return node["scenarioSceneInfoNode.infoKind"] === "GetCurrentScene"
? partOf.get(scenario.owner)
: scenario.owner;
case "ScenarioGetVarNode":
return (
scenario.variables.get(
node["scenarioGetVarNode.varPath"] as string,
) ?? 0
);
case "ScenarioSetVarNode":
return (
scenario.variables.get(
node["scenarioSetVarNode.varPath"] as string,
) ?? 0
);
case "ScenarioGetMemberNode": {
const object = get("object") as Object3D | number[] | undefined;
const field = node["scenarioGetMemberNode.fieldName"];
if (Array.isArray(object)) return field === "X" ? object[0] : 0;
if (!object) return undefined;
switch (field) {
case "stateSwitcherVisibility":
return object.visible;
case "parent":
return object.parent ?? undefined;
case "name":
return nameOf(object);
}
return undefined;
}
case "ScenarioFunctionNode": {
const name = `${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`;
const entity = get("entity") as Object3D | undefined;
switch (name) {
case "Entity.FindChildByName":
return entity && findChild(entity, get("name") as string);
case "Entity.FindParentByName": {
const target = get("name") as string;
for (
let ancestor = entity?.parent;
ancestor;
ancestor = ancestor.parent
) {
if (nameOf(ancestor) === target) return ancestor;
}
return undefined;
}
case "Entity.GetVisibility":
return (get("arg0") as Object3D | undefined)?.visible ?? false;
case "Random.Random":
return Math.floor(Math.random() * number("n"));
case "Random.RandomFloat":
return Math.random();
case "Vector4.Make":
return [number("x"), number("y"), number("z"), number("w")];
case "Vector4.Add":
case "Vector4.Subtract": {
const vector = (value: unknown) =>
Array.isArray(value) ? (value as number[]) : [0, 0, 0, 0];
const a = vector(get("a"));
const b = vector(get("b"));
const sign = name === "Vector4.Add" ? 1 : -1;
return a.map((value, index) => value + sign * b[index]);
}
case "Quaternion.MakeFastAroundX":
case "Quaternion.MakeFastAroundY":
return new Quaternion().setFromAxisAngle(
name.endsWith("X")
? new Vector3(1, 0, 0)
: new Vector3(0, 1, 0),
degToRad(number("deg")),
);
case "Quaternion.Multiply":
return (get("a") as Quaternion | undefined)
?.clone()
.multiply(
(get("b") as Quaternion | undefined) ?? new Quaternion(),
);
case "String.Merge":
// graphs use Separator as the prefix and Postfix as the number
return [get("Suffix"), get("Separator"), get("Postfix")]
.map((part) => (part === undefined ? "" : String(part)))
.join("");
case "String.Compare":
return get("1") === get("2");
case "bool.Or":
return Boolean(get("a")) || Boolean(get("b"));
case "TankComponent.GetSpeed":
case "TankComponent.GetTurretAngle":
case "TankComponent.GetGunAngle":
return 0;
}
const operator = node["scenarioFunctionNode.functionName"] as string;
const a = number("a");
const b = number("b");
if (operator === "==") return a === b;
if (operator === ">") return a > b;
if (operator === ">=") return a >= b;
if (operator === "<=") return a <= b;
if (operator.startsWith("Add")) return a + b;
if (operator.startsWith("Subtract")) return a - b;
if (operator.startsWith("Multiply")) return a * b;
if (operator.startsWith("Divide")) return b === 0 ? 0 : a / b;
if (operator.startsWith("Lerp")) return a + (b - a) * number("t");
return undefined;
}
}
return undefined;
}
function follow(id: string, pin: string) {
for (const connection of Object.values(nodes[id].connections ?? {})) {
if (connection.pinIn === pin) run(connection.target);
}
}
function run(id: string) {
if (++depth > MAX_DEPTH) return;
const { type, node } = nodes[id];
const get = (name: string) => input(id, name);
switch (type) {
case "ScenarioBranchNode":
follow(id, get("condition") ? "true" : "false");
return;
case "ScenarioSetVarNode":
scenario.variables.set(
node["scenarioSetVarNode.varPath"] as string,
get("set"),
);
follow(id, "exit");
return;
case "ScenarioSetAlarmClockNode": {
const entity =
(get("entity") as Object3D | undefined) ?? scenario.owner;
const alarm = `${scenarios.indexOf(scenario)}:${id}`;
// alarms that re-arm themselves would loop forever
if (firedAlarms.has(alarm)) {
follow(id, "exit");
return;
}
firedAlarms.add(alarm);
schedule(Number(get("delay") ?? 0), () => {
for (const other of scenarios) {
if (other.owner === entity) fireScenario(other, "Alarm Clock");
}
});
follow(id, "exit");
return;
}
case "ScenarioFunctionNode": {
const name = `${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`;
const entity = get("entity") as Object3D | undefined;
switch (name) {
case "Entity.StartAnimation":
if (entity) {
startAnimation(entity, Number(get("repeats") ?? 0));
}
break;
case "Entity.StopAnimation":
if (entity) stopAnimation(entity);
break;
case "Entity.ActivateState":
if (entity) activateState(entity, Number(get("state") ?? 0));
break;
case "Entity.MotionUpdateSpeed":
if (entity) setMotionSpeed(entity, Number(get("speed") ?? 1));
break;
case "Entity.MotionResume":
if (entity) resumeMotion(entity);
break;
case "Entity.SetRotation": {
const self = get("self") as Object3D | undefined;
const value = get("value");
if (self && value instanceof Quaternion) {
self.quaternion.copy(value);
}
break;
}
case "Entity.TriggerCustomEvent":
trigger(get("event name") as string);
break;
}
follow(id, "exit");
return;
}
}
}
for (const [id, { type, node }] of Object.entries(nodes)) {
if (
type !== "ScenarioEventNode" ||
node["ScenarioEventNode.eventName"] !== event
) {
continue;
}
const defaults = node.defaults ?? {};
if (
event === "Custom" &&
defaults["Event Name"]?.["any.value"] !== value
) {
continue;
}
if (
event === "State Activated" &&
Number(defaults.State?.["any.value"] ?? 0) !== value
) {
continue;
}
follow(id, "event");
}
}
function trigger(name: string) {
if (nesting >= MAX_NESTING) return;
nesting++;
try {
for (const scenario of scenarios) fireScenario(scenario, "Custom", name);
for (const entity of actions.keys()) fireAction(entity, USER_EVENT, name);
} finally {
nesting--;
}
}
// start like the garage: initial states, load events, then hangar events
function start() {
partOf.forEach((_, object) => {
const { stateNames: states, state } = object.userData as {
stateNames?: string[];
state?: string;
};
if (!states) return;
// no state name means the switcher starts with nothing active
activeStates.set(
object,
state === undefined ? -1 : states.indexOf(state),
);
for (const child of object.children) {
if (states.includes(nameOf(child))) {
child.visible = nameOf(child) === state;
}
}
});
for (const scenario of scenarios) {
scenario.variables = new Map(
Object.entries(scenario.script.variables ?? {}).map(
([name, variable]) => [name, variable["any.value"]],
),
);
}
for (const entity of actions.keys()) {
fireAction(entity, SCENE_LOADED_EVENT);
}
for (const scenario of scenarios) fireScenario(scenario, "Added");
for (const event of HANGAR_EVENTS) trigger(event);
}
within(true, start);
const hasGarageAnimation = playing.size > 0 || timers.length > 0;
// whether firing the event can move or switch anything; some events only
// drive particles, which would make for buttons that do nothing
function animates(event: string) {
for (const entityActions of actions.values()) {
for (const action of entityActions) {
if (
action["act.event"] === USER_EVENT &&
action["act.userEventId"] === event &&
ANIMATING_ACTIONS.includes(action["act.type"])
) {
return true;
}
}
}
return scenarios.some(({ script: { nodes } }) => {
const visited = new Set<string>();
function reaches(id: string): boolean {
if (visited.has(id)) return false;
visited.add(id);
const { type, node } = nodes[id];
if (
type === "ScenarioSetAlarmClockNode" ||
ANIMATING_FUNCTIONS.includes(
node["scenarioFunctionNode.functionName"] as string,
)
) {
return true;
}
return Object.values(nodes[id].connections ?? {}).some(
(connection) =>
EXEC_OUTPUTS.includes(connection.pinIn) &&
reaches(connection.target),
);
}
return Object.entries(nodes).some(
([id, { node }]) =>
node["ScenarioEventNode.eventName"] === "Custom" &&
node.defaults?.["Event Name"]?.["any.value"] === event &&
reaches(id),
);
});
}
return {
/** events the camo reacts to that can be triggered from the menu */
events: [...events].filter(
(event) =>
!IGNORED_EVENTS.includes(event) &&
!internalEvents.has(event) &&
animates(event),
),
/** fires an event from the menu, cutting ambient animations short */
trigger(name: string) {
for (const action of ambientActions) {
action.stop();
playing.delete(action);
}
ambientActions.clear();
for (let index = timers.length - 1; index >= 0; index--) {
if (timers[index][2]) timers.splice(index, 1);
}
firedAlarms.clear();
started.clear();
within(false, () => trigger(name));
},
/** whether a triggered animation is playing or about to */
busy() {
return (
[...playing.keys()].some((action) => !ambientActions.has(action)) ||
timers.some(([, , isAmbient]) => !isAmbient)
);
},
/** whether the camo animates on its own in the garage */
hasGarageAnimation,
/** plays the garage animations again from the start */
replay() {
mixer.stopAllAction();
playing.clear();
ambientActions.clear();
timers.length = 0;
firedAlarms.clear();
started.clear();
motionSpeeds.clear();
within(true, start);
},
/** returns whether anything is still animating */
update(delta: number) {
elapsed += delta;
for (let index = timers.length - 1; index >= 0; index--) {
if (timers[index][0] > elapsed) continue;
const [[, run, isAmbient]] = timers.splice(index, 1);
within(isAmbient, run);
}
mixer.update(delta);
return playing.size > 0 || timers.length > 0;
},
dispose() {
mixer.stopAllAction();
for (const part of parts) mixer.uncacheRoot(part);
},
};
}
export type SkinRuntime = ReturnType<typeof createSkinRuntime>;
@@ -24,17 +24,9 @@ type SkeletonComponent = Extract<
type MotionComponent = Extract<
Component,
{ "comp.typename": "MotionComponent" }
> & { "simpleMotion.rewindOnFinish"?: boolean };
>;
type AccessorType = "SCALAR" | "VEC3" | "VEC4" | "MAT4";
export interface SkinMotion {
clip: string;
duration: number;
playbackRate: number;
repeats: number;
rewind: boolean;
}
const JOINT_ROOT = 0xffffff;
const MOTION_PATHS = {
[MotionChannel.Translation]: "translation",
@@ -60,11 +52,9 @@ export async function loadMotion(
path: string,
component: MotionComponent,
) {
const file = await vfs
.file(normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`))
.catch(() => undefined);
if (!file) return;
const file = await vfs.file(
normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`),
);
return new MotionReadStream(
file.buffer.slice(
@@ -153,8 +143,9 @@ export function createMotionClip(
joints: Map<string, Node>,
motion: Motion,
component: MotionComponent,
): SkinMotion {
) {
const animation = document.createAnimation(clip);
const playbackRate = component["motion.playbackRate"];
for (const [uid, track] of motion.tracks) {
const joint = joints.get(uid);
@@ -165,7 +156,12 @@ export function createMotionClip(
const sampler = document
.createAnimationSampler()
.setInput(
createAccessor(document, buffer, "SCALAR", new Float32Array(times)),
createAccessor(
document,
buffer,
"SCALAR",
new Float32Array(times.map((time) => time / playbackRate)),
),
)
.setOutput(
createAccessor(
@@ -192,14 +188,6 @@ export function createMotionClip(
);
}
}
return {
clip,
duration: motion.duration,
playbackRate: component["motion.playbackRate"],
repeats: component["simpleMotion.repeatsCount"],
rewind: component["simpleMotion.rewindOnFinish"] ?? false,
};
}
/**
@@ -261,9 +249,17 @@ export function skinPrimitive(
"VEC4",
new Uint16Array(
jointIndices.flatMap((indices) =>
times(4, (index) =>
index < indices.length ? targets[indices[index]] ?? 0 : 0,
),
times(4, (index) => {
if (index >= indices.length) return 0;
const target = targets[indices[index]];
if (target === undefined) {
throw new Error(`Missing joint target ${indices[index]}`);
}
return target;
}),
),
),
),
+42 -23
View File
@@ -3,19 +3,50 @@ import type { Material, Object3D } from "three";
import type { Skin } from "../../hooks/useSkin";
import { jsxTree } from "./jsxTree";
type Mergers = NonNullable<Parameters<typeof jsxTree>[1]> & {
/** renders a customization part as is so its animations play */
primitive?: (object: Object3D, key: string) => ReactNode;
};
type Mergers = Parameters<typeof jsxTree>[1];
// names get sanitized and made unique on load
export function nameOf(object3d: Object3D): string {
return object3d.userData.name ?? object3d.name;
}
/**
* skins are layered in order, so an attachment's states win over the
* camouflage's; `primitive` renders customization parts as they are so their
* bones can animate
*/
export function skinTree(
node: Object3D,
skin: Skin | undefined,
mergers: Mergers,
skins: Skin[],
mergers: Mergers & {
primitive: (object: Object3D, key: string) => ReactNode;
},
) {
const materials = new Map<string, Material>();
const states: Record<string, string | null> = {};
for (const skin of skins) {
for (const [name, material] of skin.materials) {
materials.set(name, material);
}
Object.assign(states, skin.scene.userData.states);
}
function switchChildren(object3d: Object3D) {
if (object3d.userData.state === undefined) return;
const name = nameOf(object3d);
const state = name in states ? states[name] : object3d.userData.state;
return object3d.children
.filter((child) => nameOf(child) === state)
.map((child) => jsxTree(child, skinMergers));
}
const skinMergers: Mergers = {
mesh(mesh, props, key) {
const material = skin?.materials.get((mesh.material as Material).name);
const material = materials.get((mesh.material as Material).name);
return mergers!.mesh!(
mesh,
@@ -24,24 +55,12 @@ export function skinTree(
);
},
group(object3d, props, key) {
const parts = skin?.parts.get(object3d.name);
const isSwitch = object3d.userData.state !== undefined;
const state = skin
? skin.scene.userData.states[object3d.name]
: object3d.userData.state;
return (
<group key={key} {...props}>
{isSwitch
? object3d.children
.filter((child) => child.name === state)
.map((child) => jsxTree(child, skinMergers))
: props.children}
{parts?.map((part) =>
mergers.primitive ? (
mergers.primitive(part, part.uuid)
) : (
<primitive key={part.uuid} object={part} />
{switchChildren(object3d) ?? props.children}
{skins.flatMap((skin) =>
(skin.parts.get(object3d.name) ?? []).map((part) =>
mergers.primitive(part, part.uuid),
),
)}
</group>
@@ -0,0 +1,514 @@
import {
parseHierarchies,
type Hierarchy,
type Sc2ReadStream,
} from "@blitzkit/core";
import { times } from "lodash-es";
interface Value {
"any.type": string;
"any.value": unknown;
}
interface ScriptNode {
type: string;
node: Record<string, unknown> & { defaults?: Record<string, Value> };
connections?: Record<
string,
{ pinIn: string; pinOut: string; target: string }
>;
}
interface Script {
nodes: Record<string, ScriptNode>;
variables?: Record<string, Value>;
}
interface Action {
"act.delay": number;
"act.entityName": string;
"act.event": number;
"act.eventToTrigger": string;
"act.stopAfterNRepeats": number;
"act.switchIndex": number;
"act.type": number;
"act.userEventId": string;
}
interface Entity {
name: string;
parent?: Entity;
children: Entity[];
hierarchy?: Hierarchy;
repeats?: number;
}
const SELF_ENTITY = "*** Self ***";
const SCENE_LOADED_EVENT = 1;
const USER_EVENT = 2;
const ANIMATION_END_EVENTS = [3, 8];
const START_ANIMATION_ACTION = 6;
const SWITCH_ACTIONS = [8, 9];
const TRIGGER_EVENT_ACTION = 10;
// what the game fires as a tank is shown in the garage, in order
const LOAD_EVENTS = ["Added", "TankAliveInHangar", "TankIsShownInNewScreen"];
const MAX_DEPTH = 64;
function findChild(entity: Entity, name: string): Entity | undefined {
for (const child of entity.children) {
if (child.name === name) return child;
const found = findChild(child, name);
if (found) return found;
}
}
function resolveTarget(owner: Entity, entityName: string) {
if (entityName === SELF_ENTITY) return owner;
const [first, ...rest] = entityName.split("->");
let target: Entity | undefined;
for (
let ancestor: Entity | undefined = owner;
ancestor && !target;
ancestor = ancestor.parent
) {
target = ancestor.name === first ? ancestor : findChild(ancestor, first);
}
for (const name of rest) target = target && findChild(target, name);
return target;
}
/**
* replays what the game does as a tank is shown in the garage, with the camo's
* preset applied and its items attached; `animate` follows customization parts
* through their motions, delays and alarms until they settle
*/
function replay(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
attachments: Map<string, string[]>,
animate: boolean,
) {
const scripts: Record<string, Script> = {};
const root: Entity = { name: "", children: [] };
const stateNames = new Map<Entity, string[]>();
const scenarios: {
owner: Entity;
script: Script;
variables: Map<string, unknown>;
}[] = [];
const actions: { owner: Entity; action: Action }[] = [];
const states: Record<string, string | null> = {};
const looping = new Set<Entity>();
const pending: (() => void)[] = [];
const fired = new Set<unknown>();
function activateState(entity: Entity | undefined, state: number) {
const names = entity && stateNames.get(entity);
if (!names) return;
const previous = states[entity.name];
states[entity.name] = names[state] ?? null;
if (!animate || previous === states[entity.name]) return;
for (const scenario of scenarios) {
if (scenario.owner === entity) {
runScenario(scenario, "State Activated", state);
}
}
}
function isShown(entity: Entity) {
for (let child = entity; child.parent; child = child.parent) {
if (
stateNames.get(child.parent)?.includes(child.name) &&
states[child.parent.name] !== child.name
) {
return false;
}
}
return true;
}
function startAnimation(entity: Entity, repeats?: number) {
if (entity.repeats === undefined) {
for (const child of entity.children) startAnimation(child, repeats);
return;
}
// some clips restart themselves when they end
if (looping.has(entity)) return;
looping.add(entity);
if ((repeats ?? entity.repeats) > 0) {
pending.push(() => finishAnimation(entity));
}
}
function finishAnimation(entity: Entity) {
const statesBefore = { ...states };
const shownBefore = [...looping].filter(isShown);
for (const { owner, action } of actions) {
if (
owner === entity &&
ANIMATION_END_EVENTS.includes(action["act.event"])
) {
runAction(owner, action);
}
}
for (
let ancestor: Entity | undefined = entity;
ancestor;
ancestor = ancestor.parent
) {
const owned = scenarios.filter(({ owner }) => owner === ancestor);
for (const scenario of owned) {
runScenario(scenario, "Motion Fragment Stop");
runScenario(scenario, "Motion Sequence Stop");
}
if (owned.length > 0) break;
}
// an intro hands over to whatever it revealed; with nothing to hand over
// to, it's the only animation there is so it keeps looping
if (
[...looping].some(
(other) => isShown(other) && !shownBefore.includes(other),
)
) {
looping.delete(entity);
} else {
Object.assign(states, statesBefore);
}
}
function runAction(owner: Entity, action: Action) {
const target = resolveTarget(owner, action["act.entityName"]);
if (SWITCH_ACTIONS.includes(action["act.type"])) {
// what shows for a while in game stays, as its animation loops here
if (action["act.delay"] > 0 && action["act.switchIndex"] < 0) return;
activateState(target, action["act.switchIndex"]);
} else if (animate && action["act.type"] === START_ANIMATION_ACTION) {
if (target) {
const repeats = action["act.stopAfterNRepeats"];
startAnimation(target, repeats > 0 ? repeats : undefined);
}
} else if (animate && action["act.type"] === TRIGGER_EVENT_ACTION) {
fireEvent(action["act.eventToTrigger"]);
}
}
for (const component of Object.values(
sc2["#sceneComponents"] as unknown as Record<string, unknown>,
) as { "comp.typename"?: string; "ssc.scripts"?: Record<string, Script> }[]) {
if (component?.["comp.typename"] !== "ScenarioSingletonComponent") continue;
Object.assign(scripts, component["ssc.scripts"]);
}
parseHierarchies<Entity>(
sc2["#hierarchy"],
root,
(hierarchy, components, parent) => {
const entity: Entity = {
name: hierarchy.name,
parent,
children: [],
hierarchy,
};
parent.children.push(entity);
for (const item of attachments.get(hierarchy.name) ?? []) {
entity.children.push({ name: item, parent: entity, children: [] });
}
for (const component of components) {
switch (component["comp.typename"]) {
case "StateSwitcherComponent": {
const names = times(
component["ssc.statesCount"],
(index) => component[`ssc.state${index}`],
);
stateNames.set(entity, names);
states[entity.name] = names[component["ssc.activeState"]] ?? null;
break;
}
case "ScenarioComponent": {
const script = scripts[component["scenario.scriptId"]];
if (script) {
scenarios.push({
owner: entity,
script,
variables: new Map(
Object.entries(script.variables ?? {}).map(
([name, variable]) => [name, variable["any.value"]],
),
),
});
}
break;
}
case "ActionComponent":
times(component["ac.actionCount"], (index) => {
actions.push({
owner: entity,
action: component[index.toString().padStart(4, "0")] as Action,
});
});
break;
case "MotionComponent":
if (animate) {
entity.repeats = component["simpleMotion.repeatsCount"];
}
break;
}
}
return entity;
},
);
function runScenario(
scenario: (typeof scenarios)[number],
event: string,
value?: number,
) {
const { owner, script, variables } = scenario;
const { nodes } = script;
const links = (id: string) => Object.values(nodes[id].connections ?? {});
function input(id: string, pin: string): unknown {
const link = links(id).find((link) => link.pinIn === pin);
return link
? output(link.target, link.pinOut)
: nodes[id].node.defaults?.[pin]?.["any.value"];
}
function output(id: string, pin: string): unknown {
const { type, node } = nodes[id];
switch (type) {
case "ScenarioSceneInfoNode":
switch (node["scenarioSceneInfoNode.infoKind"]) {
case "GetCurrentEntity":
return owner;
case "GetCurrentScene":
return root;
}
return undefined;
case "ScenarioGetVarNode":
return variables.get(node["scenarioGetVarNode.varPath"] as string);
case "ScenarioSetVarNode":
return variables.get(node["scenarioSetVarNode.varPath"] as string);
case "ScenarioGetMemberNode": {
const object = input(id, "object") as Entity | undefined;
switch (node["scenarioGetMemberNode.fieldName"]) {
case "parent":
return pin === "parent" ? object?.parent : undefined;
case "name":
return object?.name;
case "stateSwitcherVisibility":
return object && isShown(object);
}
return undefined;
}
case "ScenarioFunctionNode": {
const name = node["scenarioFunctionNode.functionName"] as string;
const number = (pin: string) => Number(input(id, pin) ?? 0);
switch (`${node["scenarioFunctionNode.className"]}.${name}`) {
case "Entity.FindChildByName": {
const entity = input(id, "entity") as Entity | undefined;
return entity && findChild(entity, input(id, "name") as string);
}
case "Entity.GetVisibility": {
const entity = input(id, "arg0") as Entity | undefined;
return entity ? isShown(entity) : false;
}
case "TankComponent.GetAppliedSkinMaterialConfigName":
return preset;
case "String.Compare":
return input(id, "1") === input(id, "2");
case "String.Merge":
// graphs use Separator as the prefix and Postfix as the number
return ["Suffix", "Separator", "Postfix"]
.map((pin) => input(id, pin) ?? "")
.join("");
case "bool.Or":
return Boolean(input(id, "a")) || Boolean(input(id, "b"));
case "Random.Random":
case "Random.RandomFloat":
case "TankComponent.GetSpeed":
case "TankComponent.GetTurretAngle":
case "TankComponent.GetGunAngle":
return 0;
}
if (name === "==") return number("a") === number("b");
if (name === ">") return number("a") > number("b");
if (name === ">=") return number("a") >= number("b");
if (name === "<=") return number("a") <= number("b");
if (name.startsWith("Add")) return number("a") + number("b");
if (name.startsWith("Subtract")) return number("a") - number("b");
}
}
}
function execute(id: string, depth: number) {
if (depth > MAX_DEPTH) return;
const { type, node } = nodes[id];
let pin = "exit";
if (type === "ScenarioEventNode") {
pin = "event";
} else if (type === "ScenarioBranchNode") {
pin = input(id, "condition") ? "true" : "false";
} else if (type === "ScenarioSetVarNode") {
variables.set(
node["scenarioSetVarNode.varPath"] as string,
input(id, "set"),
);
} else if (type === "ScenarioSetAlarmClockNode") {
const entity = (input(id, "entity") as Entity | undefined) ?? owner;
// alarms re-arm themselves
if (animate && !fired.has(node)) {
fired.add(node);
pending.push(() => {
for (const other of scenarios) {
if (other.owner === entity) runScenario(other, "Alarm Clock");
}
});
}
} else {
const entity = input(id, "entity") as Entity | undefined;
switch (node["scenarioFunctionNode.functionName"]) {
case "ActivateState":
activateState(entity, Number(input(id, "state")));
break;
case "StartAnimation":
if (animate && entity) {
startAnimation(entity, Number(input(id, "repeats") ?? 0));
}
break;
case "TriggerCustomEvent":
if (animate) fireEvent(input(id, "event name") as string);
break;
}
}
for (const link of links(id)) {
if (link.pinIn === pin) execute(link.target, depth + 1);
}
}
for (const id in nodes) {
const { type, node } = nodes[id];
const name =
node["ScenarioEventNode.eventName"] === "Custom"
? node.defaults?.["Event Name"]?.["any.value"]
: node["ScenarioEventNode.eventName"];
if (
type === "ScenarioEventNode" &&
name === event &&
(event !== "State Activated" ||
Number(node.defaults?.State?.["any.value"] ?? 0) === value)
) {
execute(id, 0);
}
}
}
function fireEvent(event: string) {
if (fired.has(event)) return;
fired.add(event);
for (const { owner, action } of actions) {
if (
(animate || action["act.delay"] === 0) &&
(event === LOAD_EVENTS[0]
? action["act.event"] === SCENE_LOADED_EVENT
: action["act.event"] === USER_EVENT &&
action["act.userEventId"] === event)
) {
runAction(owner, action);
}
}
for (const scenario of scenarios) runScenario(scenario, event);
}
for (const event of LOAD_EVENTS) fireEvent(event);
for (let run = pending.shift(); run; run = pending.shift()) run();
return {
states,
motions: new Set(
[...looping]
.filter(isShown)
.map((entity) => entity.hierarchy as Hierarchy),
),
};
}
/**
* picks each state switcher's state; delayed actions are skipped so states stay
* as they are when the tank appears
*/
export function switcherStates(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
attachments: Map<string, string[]>,
) {
return replay(sc2, preset, attachments, false).states;
}
/** where a customization part ends up: its states and the motions left looping */
export function partGarage(
sc2: ReturnType<Sc2ReadStream["sc2"]>,
preset: string | undefined,
) {
return replay(sc2, preset, new Map(), true);
}
@@ -7,12 +7,23 @@ import {
import type { DuelMember } from "../../stores/duel";
import { genericDefaultEquipmentMatrix } from "../../stores/duel/constants";
export function defaultTankWeapon(tank: TankDefinition) {
for (const turret of [...tank.turrets].reverse()) {
for (const gun of [...turret.guns].reverse()) {
const shell = gun.shells[0];
if (shell) return { turret, gun, shell };
}
}
throw new Error(`Tank ${tank.id} has no gun with shells`);
}
export function tankToDuelMember(
tank: TankDefinition,
provisionDefinitions: ProvisionDefinitions,
) {
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const { turret, gun, shell } = defaultTankWeapon(tank);
return {
camouflage: true,
@@ -22,7 +33,7 @@ export function tankToDuelMember(
provisions: createDefaultProvisions(tank, gun, provisionDefinitions),
engine: tank.engines.at(-1)!,
gun,
shell: tank.turrets.at(-1)!.guns.at(-1)!.shells[0],
shell,
tank,
track: tank.tracks.at(-1)!,
turret,
+1 -1
View File
@@ -4,7 +4,7 @@ import { GLTFLoader } from "three-stdlib";
import { useDispose } from "./useDispose";
export function useArmor(id: number) {
const path = alias("api", `/tanks/${id}/collision.glb`);
const path = alias("models_tanks", `/${id}/collision.glb`);
const gltf = useLoader(GLTFLoader, path);
useDispose(gltf, path);
+1 -1
View File
@@ -5,7 +5,7 @@ import { GLTFLoader } from "three-stdlib";
import { useDispose } from "./useDispose";
export function useModel(id: number) {
const path = alias("api", `/tanks/${id}/model.glb`);
const path = alias("models_tanks", `/${id}/model.glb`);
const gltf = useLoader(GLTFLoader, path);
useDispose(gltf, path);
+69 -45
View File
@@ -1,23 +1,25 @@
import { alias } from "@blitzkit/core";
import { useEffect, useRef, useState } from "react";
import { useEffect } from "react";
import {
AnimationMixer,
Mesh,
SkinnedMesh,
type AnimationClip,
type Group,
type Material,
type Object3D,
} from "three";
import { GLTFLoader } from "three-stdlib";
import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
import { prepareMaterial } from "../core/blitzkit/jsxTree";
import { nameOf } from "../core/blitzkit/skinTree";
import { Tankopedia } from "../stores/tankopedia";
export interface Skin {
scene: Group;
materials: Map<string, Material>;
animations: AnimationClip[];
/** customization parts by the tank node they attach to */
parts: Map<string, Object3D[]>;
mixer?: AnimationMixer;
}
const skins = new Map<string, Promise<Skin>>();
@@ -28,25 +30,24 @@ async function loadSkin(path: string) {
const materials: Material[] = await gltf.parser.getDependencies("material");
const skin = {
scene: gltf.scene,
materials: new Map(materials.map((material) => [material.name, material])),
animations: gltf.animations,
// captured now because rendering moves the parts out of the scene
materials: new Map(
materials
.filter((material) => material.userData.camouflage)
.map((material) => [material.name, material]),
),
// taken now because rendering moves the parts out of the scene
parts: new Map(
gltf.scene.children.map((target) => [target.name, [...target.children]]),
),
mixer:
gltf.animations.length > 0 ? new AnimationMixer(gltf.scene) : undefined,
};
for (const parts of skin.parts.values()) {
for (const part of parts) {
part.traverse((child) => {
if (!(child instanceof Mesh)) return;
// bound now for the same reason, every exported clip loops in the garage
for (const clip of gltf.animations) skin.mixer!.clipAction(clip).play();
child.castShadow = true;
child.receiveShadow = true;
// bounds are from the bind pose
if (child instanceof SkinnedMesh) child.frustumCulled = false;
});
}
for (const parts of skin.parts.values()) {
for (const part of parts) preparePart(part, gltf.scene.userData.states);
}
loadedSkins.set(path, skin);
@@ -54,48 +55,66 @@ async function loadSkin(path: string) {
return skin;
}
function skinPath(id: number, skin: number) {
return alias("api", `/tanks/${id}/skins/${skin}.glb`);
}
// parts render as they are, so what jsxTree and skinTree would do happens here
function preparePart(part: Object3D, states: Record<string, string | null>) {
part.traverse((object) => {
if (object.userData.state !== undefined) {
const name = nameOf(object);
const state = name in states ? states[name] : object.userData.state;
export function useSkin(id: number, skin?: number) {
const [loaded, setLoaded] = useState<Skin>();
const mounted = useRef(false);
useEffect(() => {
const path = skin === undefined ? undefined : skinPath(id, skin);
const cached = path === undefined ? undefined : loadedSkins.get(path);
let settled = false;
if (!mounted.current) {
mounted.current = true;
if (path === undefined || cached) {
setLoaded(cached);
return;
for (const child of object.children) {
child.visible = nameOf(child) === state;
}
}
Tankopedia.mutate((draft) => {
draft.revealed = false;
});
if (!(object instanceof Mesh)) return;
if (path !== undefined && !skins.has(path)) {
skins.set(path, loadSkin(path));
object.castShadow = true;
object.receiveShadow = true;
// bounds are from the bind pose
if (object instanceof SkinnedMesh) object.frustumCulled = false;
prepareMaterial(object.material);
});
}
function skinPath(id: number, skin: number) {
return alias("models_legendary", `/${id}/skins/${skin}.glb`);
}
// attachments load the same way as camouflages and can be shown with one
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(() => {
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 (!cached) {
Tankopedia.mutate((draft) => {
draft.revealed = false;
});
if (!skins.has(path)) skins.set(path, loadSkin(path));
}
// swaps halfway through the lighting transition
Promise.all([
path === undefined ? undefined : skins.get(path),
path && skins.get(path),
new Promise((resolve) =>
setTimeout(resolve, TRANSITION_ANIMATION_TIME * 1e3),
),
]).then(([loaded]) => {
]).then(() => {
if (settled) return;
settled = true;
setLoaded(loaded);
Tankopedia.mutate((draft) => {
draft.revealed = true;
draft.loaders[loader].loaded = requested;
if (!cached) draft.revealed = true;
});
});
@@ -103,11 +122,16 @@ export function useSkin(id: number, skin?: number) {
if (settled) return;
settled = true;
if (cached) return;
Tankopedia.mutate((draft) => {
draft.revealed = true;
});
};
}, [id, skin]);
}, [id, loader, requested]);
return loaded;
return loaded === undefined
? undefined
: loadedSkins.get(skinPath(id, loaded));
}
+5 -77
View File
@@ -1,85 +1,13 @@
import { invalidate, useFrame } from "@react-three/fiber";
import { Quicklime, type QuicklimeEvent } from "quicklime";
import { useEffect, useRef } from "react";
import {
createSkinRuntime,
GARAGE_EVENT,
type SkinRuntime,
} from "../core/blitzkit/skinAnimations/runtime";
import { Tankopedia } from "../stores/tankopedia";
import type { Skin } from "./useSkin";
// demand rendering can leave long gaps between frames
const MAX_DELTA = 0.1;
export function useSkinAnimation(skins: Skin[]) {
useFrame((_, delta) => {
for (const { mixer } of skins) {
if (!mixer) continue;
export const skinAnimationEvent = new Quicklime<string>();
export function useSkinAnimation(skin?: Skin) {
const runtime = useRef<SkinRuntime>(undefined);
const active = useRef(false);
useEffect(() => {
if (!skin) return;
let created: SkinRuntime;
try {
created = createSkinRuntime(
[...skin.parts.values()].flat(),
skin.animations,
);
} catch (error) {
// the camo still shows, just without animations
console.warn("Skin animations failed to start", error);
return;
}
function handleSkinAnimation(event: QuicklimeEvent<string>) {
if (event.data === GARAGE_EVENT) {
created.replay();
} else {
created.trigger(event.data);
}
active.current = true;
mixer.update(delta);
invalidate();
}
runtime.current = created;
active.current = true;
skinAnimationEvent.on(handleSkinAnimation);
Tankopedia.mutate((draft) => {
draft.skinEvents = created.hasGarageAnimation
? [GARAGE_EVENT, ...created.events]
: created.events;
});
invalidate();
return () => {
skinAnimationEvent.off(handleSkinAnimation);
created.dispose();
runtime.current = undefined;
Tankopedia.mutate((draft) => {
draft.skinEvents = [];
draft.skinAnimating = false;
});
};
}, [skin]);
useFrame((_, delta) => {
if (!runtime.current || !active.current) return;
active.current = runtime.current.update(Math.min(delta, MAX_DELTA));
const busy = runtime.current.busy();
// only on change so the store doesn't update every frame
if (busy !== Tankopedia.state.skinAnimating) {
Tankopedia.mutate((draft) => {
draft.skinAnimating = busy;
});
}
if (active.current) invalidate();
});
}
@@ -1,67 +0,0 @@
import type { APIContext } from "astro";
import sharp from "sharp";
import { extractPackedIcon } from "../../../../core/blitz/extractPackedIcon";
import { parsePackedSpriteRect } from "../../../../core/blitz/parsePackedSpriteRect";
import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths";
import { vfs } from "../../../../core/blitzkit/vfs";
import { getStaticPaths as _getStaticPaths } from "../../_index";
interface Icon {
path: string;
/** sprite inside a texture0 atlas described by a .txt file */
atlas: boolean;
}
// white game icons for the camo animation triggers
const ICONS: Record<string, Icon> = {
garage: { path: "Lobby/icons/platoon/icon_hangar_m", atlas: false },
shoot: { path: "UI/BattleScreenHUD/icon_aim-battle_m", atlas: true },
kill: {
path: "UI/BattleScreenHUD/icon_battle-chat_kill-type_default",
atlas: true,
},
damage: { path: "UI/BattleScreenHUD/icon_damage_shadow_m", atlas: true },
damage_enemy: {
path: "UI/BattleScreenHUD/icon_attack-battle_m",
atlas: true,
},
no_pierce: {
path: "UI/BattleScreenHUD/icon_shooting-fb_shield_m",
atlas: true,
},
move: { path: "Lobby/icons/icon_speed_s", atlas: false },
consumable: { path: "Lobby/icons/icon_consumable_l", atlas: false },
reload: { path: "Lobby/icons/icon_rate-of-fire_s", atlas: false },
};
export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () =>
Object.keys(ICONS).map((event) => ({ params: { event }, props: { event } })),
);
export async function GET({ props }: APIContext<{ event: string }>) {
const { path, atlas } = ICONS[props.event];
let buffer: Buffer;
if (atlas) {
const directory = path.split("/").slice(0, -1).join("/");
const sizes = (await vfs.text(`Data/Gfx/${path}@2x.txt`))
.split("\n")[4]
.trim()
.split(" ")
.map(Number);
buffer = await extractPackedIcon(
sharp(await vfs.file(`Data/Gfx/${directory}/[email protected]`)),
sizes.slice(0, 4),
);
} else {
const packedBuffer = await vfs.file(`Data/Gfx/${path}@2x.packed.webp`);
const sizes = parsePackedSpriteRect(packedBuffer);
buffer = sizes
? await extractPackedIcon(sharp(packedBuffer), sizes)
: packedBuffer;
}
return new Response(new Uint8Array(await sharp(buffer).webp().toBuffer()));
}
@@ -0,0 +1,13 @@
---
import CreditsPromo from "../../../../components/CreditsPromo.astro";
import { tools } from "../../../../constants/tools";
import ToolLayout from "../../../../layouts/ToolLayout.astro";
export { getStaticPaths } from "../index.astro";
const { locale } = Astro.props;
---
<ToolLayout tool={tools.credits} {locale}>
<CreditsPromo {locale} />
</ToolLayout>
@@ -5,6 +5,7 @@ import locales from "@blitzkit/i18n/locales.json";
import { literals } from "@blitzkit/i18n/src/literals";
import { Box, Flex, Link } from "@radix-ui/themes";
import { AesonPlug } from "../../../components/AesonPlug";
import { Beg } from "../../../components/Beg";
import HomeHero from "../../../components/HomeHero.astro";
import { Plugs } from "../../../components/Plugs";
import ToolCard from "../../../components/ToolCard.astro";
@@ -65,6 +66,8 @@ export const getStaticPaths = mixStaticPaths(_getStaticPaths, () => {
<BaseLayout {locale}>
<HomeHero />
<Beg />
{
locale !== locales.default && (
<Flex justify="center" pt="4" my="4">
@@ -13,6 +13,7 @@ import { load } from "cheerio";
import { api } from "../../../../../core/blitzkit/api";
import { mixStaticPaths } from "../../../../../core/blitzkit/mixStaticPaths";
import { tankCharacteristics } from "../../../../../core/blitzkit/tankCharacteristics";
import { defaultTankWeapon } from "../../../../../core/blitzkit/tankToDuelMember";
import { getStrings } from "../../../../../core/i18n/getStrings";
import BaseLayout from "../../../../../layouts/BaseLayout.astro";
import { genericDefaultEquipmentMatrix } from "../../../../../stores/duel/constants";
@@ -53,9 +54,7 @@ const title = literals(strings.website.tools.tankopedia.tank_seo_title, {
const url = import.meta.env.SITE + Astro.url.pathname;
const icon = alias("api", `/tanks/${id}/icons/big.webp`);
const turret = tank.turrets.at(-1)!;
const gun = turret.guns.at(-1)!;
const shell = gun.shells[0];
const { turret, gun, shell } = defaultTankWeapon(tank);
const characteristics = tankCharacteristics(
{
tank,
@@ -6,8 +6,8 @@ import {
type TankParameters,
} from "@blitzkit/core";
import {
Animation,
Document,
Material,
Node,
NodeIO,
PropertyType,
@@ -17,13 +17,19 @@ import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
import { dedup, mergeDocuments, unpartition } from "@gltf-transform/functions";
import type { APIContext } from "astro";
import type { Vector3Tuple, Vector4Tuple } from "three";
import { SKIN_NUMBER } from "../../../../../constants/skinNumber";
import { api } from "../../../../../core/blitzkit/api";
import { mixStaticPaths } from "../../../../../core/blitzkit/mixStaticPaths";
import type { SkinMotion } from "../../../../../core/blitzkit/skinAnimations/skeleton";
import { vfs } from "../../../../../core/blitzkit/vfs";
import { getStaticPaths as _getStaticPaths } from "../../../_index";
import { extractMaterials, extractModel } from "../model.glb";
import { SKIN_NUMBER } from "../../../../constants/skinNumber";
import { api } from "../../../../core/blitzkit/api";
import { mixStaticPaths } from "../../../../core/blitzkit/mixStaticPaths";
import {
partGarage,
switcherStates,
} from "../../../../core/blitzkit/switcherStates";
import { vfs } from "../../../../core/blitzkit/vfs";
import {
extractMaterials,
extractModel,
} from "../../../[...chunk_models_tanks]/[id]/model.glb";
import { getStaticPaths as _getStaticPaths } from "../../_index";
const SKIN_NUMBER_SLOTS = 5;
const DIGIT_COLUMNS = 5;
@@ -66,8 +72,10 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
const [nation, tankKey] = (
include as { vehicle: { name: string } }
).vehicle.name.split(":");
const { preset, customEntities } = camouflagesYaml[key];
const skinNumber = Number(preset?.match(/(\d+)$/)?.[1] ?? 1);
const { customEntities } = camouflagesYaml[key];
const preset =
camouflagesYaml[key].preset ??
camouflagesYaml[key].override?.[nation]?.override?.[tankKey]?.preset;
const parameters = await vfs.yaml<TankParameters>(
`Data/3d/Tanks/Parameters/${nation}/${tankKey}.yaml`,
);
@@ -87,7 +95,6 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
scale: Vector3Tuple;
}
> = {};
const states: Record<string, string | null> = {};
parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => {
for (const component of components) {
@@ -110,43 +117,50 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
scale: component["sc.attachmentScale"] as Vector3Tuple,
};
}
if (component["comp.typename"] !== "StateSwitcherComponent") continue;
const active = component[`ssc.state${component["ssc.activeState"]}`];
// camos without their own variant keep the default piece, but groups
// of elements to hide stay hidden
let state: string | null =
active && !/skin\d*/.test(active) && !active.includes("hide")
? active
: null;
for (let index = 0; index < component["ssc.statesCount"]; index++) {
const name = component[`ssc.state${index}`];
const match = name.match(/skin(\d*)/);
if (
match &&
!name.includes("hide") &&
Number(match[1] || 1) === skinNumber
) {
state = name;
}
}
states[hierarchy.name] = state;
}
return null;
});
const attachments = new Map<string, string[]>();
for (const name in customEntities) {
const slot = slots[name];
if (!slot) continue;
attachments.set(slot.target, [
...(attachments.get(slot.target) ?? []),
customEntities[name].item,
]);
}
const states = switcherStates(sc2, preset, attachments);
// attachments are layered over camouflages, so they only keep what they change
if (camouflagesXml.root.camouflages[key].kind === "customization") {
const baseline = switcherStates(sc2, preset, new Map());
for (const name in states) {
if (states[name] === baseline[name]) delete states[name];
}
}
const document = new Document();
const scene = document.createScene().setExtras({ states });
const scene = document.createScene();
const targets = new Map<string, Node>();
document.createBuffer();
if (preset) await extractMaterials(document, sc2, basePath, preset);
if (preset) {
const materials = await extractMaterials(document, sc2, basePath, preset);
// parts may share these names, so mark the ones that replace the tank's
for (const material of materials.values()) {
if (!(material instanceof Material)) continue;
material.setExtras({ ...material.getExtras(), camouflage: true });
}
}
for (const name in customEntities) {
const slot = slots[name];
@@ -161,33 +175,20 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
continue;
}
const part = await extractModel(vfs, path, true);
const part = await extractModel(vfs, path, true, preset);
const partSc2 = new Sc2ReadStream(
(await vfs.file(`Data/3d/${path}.sc2`)).buffer as ArrayBuffer,
).sc2();
Object.assign(states, partGarage(partSc2, preset).states);
const map = mergeDocuments(document, part);
const partScene = map.get(part.getRoot().listScenes()[0]) as Scene;
const node = document
.createNode(name)
.setTranslation(slot.position)
.setRotation(slot.rotation)
.setScale(slot.scale)
.setExtras(partScene.getExtras());
// clip names repeat across parts
for (const animation of part.getRoot().listAnimations()) {
(map.get(animation) as Animation).setName(
`${name}:${animation.getName()}`,
);
}
partScene.traverse((child) => {
const { motion } = child.getExtras() as { motion?: SkinMotion };
if (motion) {
child.setExtras({
...child.getExtras(),
motion: { ...motion, clip: `${name}:${motion.clip}` },
});
}
});
.setScale(slot.scale);
let target = targets.get(slot.target);
if (!target) {
@@ -204,6 +205,8 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
target.addChild(node);
}
scene.setExtras({ states });
applySkinNumber(document, SKIN_NUMBER);
await document.transform(
@@ -0,0 +1,3 @@
import { chunkedStaticPaths } from "../../core/blitzkit/chunkedStaticPaths";
export const getStaticPaths = chunkedStaticPaths("models_legendary");
Whitespace-only changes.
@@ -0,0 +1,12 @@
import { api } from "../../../core/blitzkit/api";
import { mixStaticPaths } from "../../../core/blitzkit/mixStaticPaths";
import { getStaticPaths as _getStaticPaths } from "../_index";
export const getStaticPaths = mixStaticPaths(_getStaticPaths, async () => {
const tanks = await api.tankDefinitions();
return Object.values(tanks.tanks).map((tank) => ({
params: { id: tank.id },
props: { id: tank.id },
}));
});
@@ -10,7 +10,7 @@ import {
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
import { Accessor, Document, Node, NodeIO, Scene } from "@gltf-transform/core";
import type { APIContext } from "astro";
import { vfs } from "../../../../core/blitzkit/vfs";
import { vfs } from "../../../core/blitzkit/vfs";
export { getStaticPaths } from "./_index";
@@ -28,19 +28,16 @@ import type { APIContext } from "astro";
import { isEqual, times } from "lodash-es";
import { dirname } from "path";
import sharp from "sharp";
import {
addExtras,
toExtras,
} from "../../../../core/blitzkit/skinAnimations/extras";
import type { Motion } from "../../../../core/blitzkit/skinAnimations/motion";
import type { Motion } from "../../../core/blitzkit/skinAnimations/motion";
import {
bindSocket,
createMotionClip,
createSkeleton,
loadMotion,
skinPrimitive,
} from "../../../../core/blitzkit/skinAnimations/skeleton";
import { vfs } from "../../../../core/blitzkit/vfs";
} from "../../../core/blitzkit/skinAnimations/skeleton";
import { partGarage } from "../../../core/blitzkit/switcherStates";
import { vfs } from "../../../core/blitzkit/vfs";
import {
vertexAttributeGLTFName,
vertexAttributeGltfVectorSizes,
@@ -85,7 +82,13 @@ export async function GET({ props }: APIContext<{ id: number }>) {
const ERROR_ON_UNKNOWN_COMPONENT = false;
const omitMeshNames = {
start: ["chassis_chassis_", "chassis_track_crash_", "HP_"],
start: [
"chassis_chassis_",
"chassis_track_crash_",
"HP_",
// mastery marks a new tank doesn't have yet
"custom_gun_marks_",
],
end: ["_POINT"],
};
@@ -93,6 +96,7 @@ export async function extractModel(
vfs: AbstractVFS,
path: string,
isPart = false,
preset?: string,
) {
const sc2Path = `Data/3d/${path}.sc2`;
const scgPath = `Data/3d/${path}.scg`;
@@ -114,6 +118,7 @@ export async function extractModel(
skin: Skin,
][] = [];
const motions = new Map<Hierarchy, Motion>();
const garageMotions = isPart ? partGarage(sc2, preset).motions : new Set();
if (isPart) {
const loading: Promise<void>[] = [];
@@ -124,7 +129,7 @@ export async function extractModel(
loading.push(
loadMotion(vfs, path, component).then((motion) => {
if (motion) motions.set(hierarchy, motion);
motions.set(hierarchy, motion);
}),
);
}
@@ -133,14 +138,6 @@ export async function extractModel(
});
await Promise.all(loading);
for (const component of Object.values(
(sc2["#sceneComponents"] ?? {}) as Record<string, Record<string, any>>,
)) {
if (component["comp.typename"] === "ScenarioSingletonComponent") {
scene.setExtras({ scripts: toExtras(component["ssc.scripts"]) });
}
}
}
parseHierarchies<Scene | Node>(
@@ -162,7 +159,7 @@ export async function extractModel(
const motionComponent = components.find(
(component) => component["comp.typename"] === "MotionComponent",
);
const motion = motionComponent ? motions.get(hierarchy) : undefined;
const motion = motions.get(hierarchy);
if (skeleton) {
const created = createSkeleton(
@@ -175,17 +172,16 @@ export async function extractModel(
skeletons.set(hierarchy, created);
if (motion && motionComponent) {
addExtras(node, {
motion: createMotionClip(
document,
buffer,
`motion${document.getRoot().listAnimations().length}`,
created.joints,
motion,
motionComponent,
),
});
// only what loops in the garage is exported
if (motion && motionComponent && garageMotions.has(hierarchy)) {
createMotionClip(
document,
buffer,
`motion${document.getRoot().listAnimations().length}`,
created.joints,
motion,
motionComponent,
);
}
}
}
@@ -212,37 +208,9 @@ export async function extractModel(
}
case "StateSwitcherComponent":
addExtras(node, {
node.setExtras({
state: component[`ssc.state${component["ssc.activeState"]}`],
});
if (isPart) {
addExtras(node, {
stateNames: times(
component["ssc.statesCount"],
(index) => component[`ssc.state${index}`],
),
});
}
break;
case "ActionComponent":
if (!isPart) break;
addExtras(node, {
actions: toExtras(
times(
component["ac.actionCount"],
(index) => component[index.toString().padStart(4, "0")],
),
),
});
break;
case "ScenarioComponent":
if (!isPart) break;
addExtras(node, { scenario: component["scenario.scriptId"] });
break;
case "SkeletonJointBindingComponent": {
@@ -256,14 +224,14 @@ export async function extractModel(
] as unknown as { entityUid: string; jointUid: string };
const joint = skeleton.joints.get(binding.jointUid);
if (joint) {
jointBindings.push([
node,
binding.entityUid,
joint,
skeleton.skin,
]);
}
if (!joint) throw new Error(`Missing joint ${binding.jointUid}`);
jointBindings.push([
node,
binding.entityUid,
joint,
skeleton.skin,
]);
});
break;
}
@@ -379,7 +347,9 @@ export async function extractModel(
for (const [owner, entity, joint, skin] of jointBindings) {
const socket = findNode(owner, entity);
if (socket) bindSocket(socket, joint, skin);
if (!socket) throw new Error(`Missing socket ${entity}`);
bindSocket(socket, joint, skin);
}
scene.addChild(document.createNode("test"));
@@ -518,6 +488,27 @@ export async function extractMaterials(
}
}
const enabledPresets =
node.enabledPresets ?? defaultConfigArchive?.enabledPresets;
const fxName = node.fxName ?? defaultConfigArchive?.fxName;
if (enabledPresets?.AlphaBlend || enabledPresets?.AlphaBlendAdditive) {
material.setAlphaMode("BLEND");
}
if (enabledPresets?.AlphaBlendAdditive) {
// gltf has no additive blending so the viewer applies it
material.setExtras({ blending: "additive" });
}
if (fxName?.endsWith("/Textured.material")) {
// unlit in game, emissive so the viewer can fade it with the lights
material
.setEmissiveTexture(material.getBaseColorTexture())
.setEmissiveFactor([1, 1, 1])
.setBaseColorFactor([0, 0, 0, 1]);
}
if (textures.miscMap) {
material.setOcclusionTexture(
document
@@ -0,0 +1,3 @@
import { chunkedStaticPaths } from "../../core/blitzkit/chunkedStaticPaths";
export const getStaticPaths = chunkedStaticPaths("models_tanks");
+15 -9
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,11 +112,6 @@ interface Tankopedia {
);
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
customShell?: ShellDefinition;
skin?: number;
/** events the selected camo's animations can be triggered with */
skinEvents: string[];
/** a triggered camo animation is still playing */
skinAnimating: boolean;
requestedDisplay: TankopediaDisplay;
display: TankopediaDisplay;
statSearch?: string;
@@ -118,14 +122,16 @@ 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),
model,
xpMultiplier: 1,
skinEvents: [],
skinAnimating: false,
requestedDisplay: TankopediaDisplay.Model,
display: TankopediaDisplay.Model,
}));