mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-14 13:16:11 +01:00
Feature: Animated legendary camouflages
Legendary camo add-ons now play their garage animations in the Tankopedia viewer, and a new menu next to the camo column triggers their battle reactions. **Export (`model.glb` / `skins/[skin].glb`)** - Customization parts export skeletons, skin weights and one clip per `.anim` motion file (`core/blitzkit/skinAnimations`) - Sockets bound to skeleton joints follow their joint - Action lists and scenario graphs are carried in glTF extras - Part batches with no LODs (`lodIndex` -1) are now exported, fixing meshes that were missing entirely (e.g. the Tiger II cat body, the BDT-5A engine rings) - Base tank models are unchanged **Viewer** - A runtime plays the clips and drives state switchers, action lists and the game's scenario graphs (a small interpreter for the node types the camos use) - Camo parts render as primitives so their bones can animate - Garage animations play once rather than looping, so the camo settles **Animation menu** - Floats left of the camo column when the selected camo has triggerable animations - Icon buttons use the game's own white icons, served from `/icons/skin-events/[event].webp` - Idle replays the garage animation - Triggers: Shoot, Kill, Take damage, Damage enemy, Ricochet / No penetration, Start moving, Consumable, Reload. Only events that actually animate get a button - Buttons are disabled while a triggered animation plays; triggering cuts any garage animation short All legendary skin files together grow from 1.32 GB to 1.35 GB (+2.4%), almost all of it in the 102 animated camos.
This commit is contained in:
1 parent
85e03d3b42
commit
ffb5a1f9c8
15 files changed
+1778
-11
No files matched your search
+131
@@ -0,0 +1,131 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -50,6 +50,7 @@ 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";
|
||||
@@ -93,6 +94,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
|
||||
<Thicknesses skeleton={skeleton} thicknessRange={thicknessRange} />
|
||||
|
||||
<SkinSwitcher />
|
||||
<SkinAnimationSwitcher />
|
||||
|
||||
<Flex
|
||||
gap="2"
|
||||
|
||||
+29
@@ -8,6 +8,7 @@ import { skinTree } from "../../../../../../core/blitzkit/skinTree";
|
||||
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
|
||||
import { useModel } from "../../../../../../hooks/useModel";
|
||||
import { useSkin } from "../../../../../../hooks/useSkin";
|
||||
import { useSkinAnimation } from "../../../../../../hooks/useSkinAnimation";
|
||||
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
|
||||
import { useTankTransform } from "../../../../../../hooks/useTankTransform";
|
||||
import { Duel } from "../../../../../../stores/duel";
|
||||
@@ -33,6 +34,7 @@ export function TankModel() {
|
||||
const skinGltf = useSkin(protagonist.tank.id, skin);
|
||||
|
||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||
useSkinAnimation(skinGltf);
|
||||
|
||||
return (
|
||||
<ModelTankWrapper ref={hullContainer}>
|
||||
@@ -107,6 +109,15 @@ export function TankModel() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
primitive(object, key) {
|
||||
return (
|
||||
<primitive
|
||||
key={key}
|
||||
object={object}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -184,6 +195,15 @@ export function TankModel() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
primitive(object, key) {
|
||||
return (
|
||||
<primitive
|
||||
key={key}
|
||||
object={object}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -265,6 +285,15 @@ export function TankModel() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
primitive(object, key) {
|
||||
return (
|
||||
<primitive
|
||||
key={key}
|
||||
object={object}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
})}
|
||||
</group>
|
||||
|
||||
Reference in new issue
Block a user