mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-10 11:16:10 +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
@@ -461,6 +461,18 @@
|
|||||||
"sandbox": {
|
"sandbox": {
|
||||||
"load_model": "Load model",
|
"load_model": "Load model",
|
||||||
|
|
||||||
|
"skin_events": {
|
||||||
|
"garage": "Idle",
|
||||||
|
"shoot": "Shoot",
|
||||||
|
"kill": "Kill",
|
||||||
|
"damage": "Take damage",
|
||||||
|
"damage_enemy": "Damage enemy",
|
||||||
|
"no_pierce": "Ricochet / No penetration",
|
||||||
|
"move": "Start moving",
|
||||||
|
"consumable": "Consumable",
|
||||||
|
"reload": "Reload"
|
||||||
|
},
|
||||||
|
|
||||||
"poses": {
|
"poses": {
|
||||||
"hull_down": "Hull down",
|
"hull_down": "Hull down",
|
||||||
"face_hug": "Face hug",
|
"face_hug": "Face hug",
|
||||||
|
|||||||
+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,
|
SidebarIconButton,
|
||||||
SidebarIconGroup,
|
SidebarIconGroup,
|
||||||
} from "./components/SidebarIconButton";
|
} from "./components/SidebarIconButton";
|
||||||
|
import { SkinAnimationSwitcher } from "./components/SkinAnimationSwitcher";
|
||||||
import { SkinSwitcher } from "./components/SkinSwitcher";
|
import { SkinSwitcher } from "./components/SkinSwitcher";
|
||||||
import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher";
|
import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher";
|
||||||
import { Thicknesses } from "./components/Thicknesses";
|
import { Thicknesses } from "./components/Thicknesses";
|
||||||
@@ -93,6 +94,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) {
|
|||||||
<Thicknesses skeleton={skeleton} thicknessRange={thicknessRange} />
|
<Thicknesses skeleton={skeleton} thicknessRange={thicknessRange} />
|
||||||
|
|
||||||
<SkinSwitcher />
|
<SkinSwitcher />
|
||||||
|
<SkinAnimationSwitcher />
|
||||||
|
|
||||||
<Flex
|
<Flex
|
||||||
gap="2"
|
gap="2"
|
||||||
|
|||||||
+29
@@ -8,6 +8,7 @@ import { skinTree } from "../../../../../../core/blitzkit/skinTree";
|
|||||||
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
|
import { controlsEnabledEvent } from "../../../../../../core/controlsEnabled";
|
||||||
import { useModel } from "../../../../../../hooks/useModel";
|
import { useModel } from "../../../../../../hooks/useModel";
|
||||||
import { useSkin } from "../../../../../../hooks/useSkin";
|
import { useSkin } from "../../../../../../hooks/useSkin";
|
||||||
|
import { useSkinAnimation } from "../../../../../../hooks/useSkinAnimation";
|
||||||
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
|
import { useTankModelDefinition } from "../../../../../../hooks/useTankModelDefinition";
|
||||||
import { useTankTransform } from "../../../../../../hooks/useTankTransform";
|
import { useTankTransform } from "../../../../../../hooks/useTankTransform";
|
||||||
import { Duel } from "../../../../../../stores/duel";
|
import { Duel } from "../../../../../../stores/duel";
|
||||||
@@ -33,6 +34,7 @@ export function TankModel() {
|
|||||||
const skinGltf = useSkin(protagonist.tank.id, skin);
|
const skinGltf = useSkin(protagonist.tank.id, skin);
|
||||||
|
|
||||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||||
|
useSkinAnimation(skinGltf);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModelTankWrapper ref={hullContainer}>
|
<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>
|
</group>
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { ReadStream } from "@blitzkit/core";
|
||||||
|
import { times } from "lodash-es";
|
||||||
|
|
||||||
|
const HEADER_SIZE = 16;
|
||||||
|
|
||||||
|
export enum MotionChannel {
|
||||||
|
Translation,
|
||||||
|
Rotation,
|
||||||
|
Scale,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MotionKeys {
|
||||||
|
times: number[];
|
||||||
|
values: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Motion {
|
||||||
|
duration: number;
|
||||||
|
tracks: Map<string, Map<MotionChannel, MotionKeys>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MotionReadStream extends ReadStream {
|
||||||
|
motion(): Motion {
|
||||||
|
this.seek(HEADER_SIZE);
|
||||||
|
|
||||||
|
const duration = this.float32();
|
||||||
|
const tracks: Motion["tracks"] = new Map();
|
||||||
|
|
||||||
|
times(this.uint32(), () => {
|
||||||
|
const uid = this.paddedString();
|
||||||
|
const channels = new Map<MotionChannel, MotionKeys>();
|
||||||
|
|
||||||
|
this.paddedString();
|
||||||
|
this.seek(4);
|
||||||
|
|
||||||
|
times(this.uint32(), () => {
|
||||||
|
const type = this.uint32() & 0xff;
|
||||||
|
|
||||||
|
this.seek(4);
|
||||||
|
|
||||||
|
const dimension = this.uint32() & 0xff;
|
||||||
|
const keys: MotionKeys = { times: [], values: [] };
|
||||||
|
|
||||||
|
times(this.uint32(), () => {
|
||||||
|
keys.times.push(this.float32());
|
||||||
|
times(dimension, () => keys.values.push(this.float32()));
|
||||||
|
});
|
||||||
|
channels.set(type, keys);
|
||||||
|
});
|
||||||
|
tracks.set(uid, channels);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { duration, tracks };
|
||||||
|
}
|
||||||
|
|
||||||
|
paddedString() {
|
||||||
|
const end = new Uint8Array(this.buffer).indexOf(0, this.index);
|
||||||
|
const value = this.utf8(end - this.index);
|
||||||
|
|
||||||
|
this.index = (end + 4) & ~3;
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
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>;
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { VertexAttribute, type Component } from "@blitzkit/core";
|
||||||
|
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
|
||||||
|
import {
|
||||||
|
MathUtils,
|
||||||
|
type Buffer,
|
||||||
|
type Document,
|
||||||
|
type mat4,
|
||||||
|
type Node,
|
||||||
|
type Primitive,
|
||||||
|
type Skin,
|
||||||
|
type TypedArray,
|
||||||
|
type vec3,
|
||||||
|
type vec4,
|
||||||
|
} from "@gltf-transform/core";
|
||||||
|
import { isEqual, times } from "lodash-es";
|
||||||
|
import { dirname, normalize } from "path/posix";
|
||||||
|
import { Matrix4 } from "three";
|
||||||
|
import { MotionChannel, MotionReadStream, type Motion } from "./motion";
|
||||||
|
|
||||||
|
type SkeletonComponent = Extract<
|
||||||
|
Component,
|
||||||
|
{ "comp.typename": "SkeletonComponent" }
|
||||||
|
>;
|
||||||
|
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",
|
||||||
|
[MotionChannel.Rotation]: "rotation",
|
||||||
|
[MotionChannel.Scale]: "scale",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function createAccessor(
|
||||||
|
document: Document,
|
||||||
|
buffer: Buffer,
|
||||||
|
type: AccessorType,
|
||||||
|
array: TypedArray,
|
||||||
|
) {
|
||||||
|
return document
|
||||||
|
.createAccessor()
|
||||||
|
.setType(type)
|
||||||
|
.setArray(array)
|
||||||
|
.setBuffer(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadMotion(
|
||||||
|
vfs: AbstractVFS,
|
||||||
|
path: string,
|
||||||
|
component: MotionComponent,
|
||||||
|
) {
|
||||||
|
const file = await vfs
|
||||||
|
.file(normalize(`Data/3d/${dirname(path)}/${component["motion.filepath"]}`))
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
return new MotionReadStream(
|
||||||
|
file.buffer.slice(
|
||||||
|
file.byteOffset,
|
||||||
|
file.byteOffset + file.byteLength,
|
||||||
|
) as ArrayBuffer,
|
||||||
|
).motion();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSkeleton(
|
||||||
|
document: Document,
|
||||||
|
buffer: Buffer,
|
||||||
|
node: Node,
|
||||||
|
skeleton: SkeletonComponent,
|
||||||
|
motion: Motion | undefined,
|
||||||
|
) {
|
||||||
|
const joints = times(
|
||||||
|
skeleton.jointsCount,
|
||||||
|
(index) => skeleton.joints[index.toString().padStart(4, "0")],
|
||||||
|
);
|
||||||
|
const jointNodes = joints.map((joint) => {
|
||||||
|
const translation: vec3 = [0, 0, 0];
|
||||||
|
const rotation: vec4 = [0, 0, 0, 1];
|
||||||
|
const scale: vec3 = [1, 1, 1];
|
||||||
|
const track = motion?.tracks.get(joint["joint.uid"]);
|
||||||
|
const trackScale = track?.get(MotionChannel.Scale)?.values[0];
|
||||||
|
|
||||||
|
MathUtils.decompose(
|
||||||
|
joint["joint.bindPose"].flat() as mat4,
|
||||||
|
translation,
|
||||||
|
rotation,
|
||||||
|
scale,
|
||||||
|
);
|
||||||
|
|
||||||
|
// rest on the first frame so the static pose matches the animation
|
||||||
|
return document
|
||||||
|
.createNode(joint["joint.name"])
|
||||||
|
.setTranslation(
|
||||||
|
(track?.get(MotionChannel.Translation)?.values.slice(0, 3) as vec3) ??
|
||||||
|
translation,
|
||||||
|
)
|
||||||
|
.setRotation(
|
||||||
|
(track?.get(MotionChannel.Rotation)?.values.slice(0, 4) as vec4) ??
|
||||||
|
rotation,
|
||||||
|
)
|
||||||
|
.setScale(
|
||||||
|
trackScale === undefined ? scale : [trackScale, trackScale, trackScale],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
joints.forEach((joint, index) => {
|
||||||
|
const parent = joint["joint.parentIndex"];
|
||||||
|
|
||||||
|
(parent === JOINT_ROOT ? node : jointNodes[parent]).addChild(
|
||||||
|
jointNodes[index],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const skin = document
|
||||||
|
.createSkin()
|
||||||
|
.setInverseBindMatrices(
|
||||||
|
createAccessor(
|
||||||
|
document,
|
||||||
|
buffer,
|
||||||
|
"MAT4",
|
||||||
|
new Float32Array(
|
||||||
|
joints.flatMap((joint) => joint["joint.invBindPose"].flat()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
jointNodes.forEach((jointNode) => skin.addJoint(jointNode));
|
||||||
|
|
||||||
|
return {
|
||||||
|
skin,
|
||||||
|
joints: new Map(
|
||||||
|
joints.map((joint, index) => [joint["joint.uid"], jointNodes[index]]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMotionClip(
|
||||||
|
document: Document,
|
||||||
|
buffer: Buffer,
|
||||||
|
clip: string,
|
||||||
|
joints: Map<string, Node>,
|
||||||
|
motion: Motion,
|
||||||
|
component: MotionComponent,
|
||||||
|
): SkinMotion {
|
||||||
|
const animation = document.createAnimation(clip);
|
||||||
|
|
||||||
|
for (const [uid, track] of motion.tracks) {
|
||||||
|
const joint = joints.get(uid);
|
||||||
|
|
||||||
|
if (!joint) continue;
|
||||||
|
|
||||||
|
for (const [type, { times, values }] of track) {
|
||||||
|
const sampler = document
|
||||||
|
.createAnimationSampler()
|
||||||
|
.setInput(
|
||||||
|
createAccessor(document, buffer, "SCALAR", new Float32Array(times)),
|
||||||
|
)
|
||||||
|
.setOutput(
|
||||||
|
createAccessor(
|
||||||
|
document,
|
||||||
|
buffer,
|
||||||
|
type === MotionChannel.Rotation ? "VEC4" : "VEC3",
|
||||||
|
new Float32Array(
|
||||||
|
type === MotionChannel.Scale
|
||||||
|
? values.flatMap((value) => [value, value, value])
|
||||||
|
: values,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setInterpolation("LINEAR");
|
||||||
|
|
||||||
|
animation
|
||||||
|
.addSampler(sampler)
|
||||||
|
.addChannel(
|
||||||
|
document
|
||||||
|
.createAnimationChannel()
|
||||||
|
.setTargetNode(joint)
|
||||||
|
.setTargetPath(MOTION_PATHS[type])
|
||||||
|
.setSampler(sampler),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
clip,
|
||||||
|
duration: motion.duration,
|
||||||
|
playbackRate: component["motion.playbackRate"],
|
||||||
|
repeats: component["simpleMotion.repeatsCount"],
|
||||||
|
rewind: component["simpleMotion.rewindOnFinish"] ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes a socket follow a joint. Socket content is authored in the owner's
|
||||||
|
* space, so it follows the joint's movement away from its bind pose.
|
||||||
|
*/
|
||||||
|
export function bindSocket(socket: Node, joint: Node, skin: Skin) {
|
||||||
|
const index = skin.listJoints().indexOf(joint);
|
||||||
|
const inverseBind = skin
|
||||||
|
.getInverseBindMatrices()!
|
||||||
|
.getElement(index, []) as number[];
|
||||||
|
|
||||||
|
joint.addChild(socket);
|
||||||
|
socket.setMatrix(
|
||||||
|
new Matrix4()
|
||||||
|
.fromArray(inverseBind)
|
||||||
|
.multiply(new Matrix4().fromArray(socket.getMatrix()))
|
||||||
|
.toArray() as mat4,
|
||||||
|
);
|
||||||
|
socket.traverse((child) => {
|
||||||
|
const childSkin = child.getSkin();
|
||||||
|
|
||||||
|
// skinned meshes carrying a copy of the owner's skeleton deform with it
|
||||||
|
if (
|
||||||
|
childSkin &&
|
||||||
|
isEqual(
|
||||||
|
childSkin.listJoints().map((joint) => joint.getName()),
|
||||||
|
skin.listJoints().map((joint) => joint.getName()),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
child.setSkin(skin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinPrimitive(
|
||||||
|
document: Document,
|
||||||
|
buffer: Buffer,
|
||||||
|
primitive: Primitive,
|
||||||
|
attributes: Map<VertexAttribute, number[][]>,
|
||||||
|
targetsData: ArrayBuffer | undefined,
|
||||||
|
) {
|
||||||
|
const jointIndices =
|
||||||
|
attributes.get(VertexAttribute.JOINTINDEX) ??
|
||||||
|
attributes.get(VertexAttribute.HARD_JOINTINDEX);
|
||||||
|
|
||||||
|
if (!jointIndices || !targetsData) return false;
|
||||||
|
|
||||||
|
const targets = new Uint32Array(new Uint8Array(targetsData).slice().buffer);
|
||||||
|
const weights =
|
||||||
|
attributes.get(VertexAttribute.JOINTWEIGHT) ?? jointIndices.map(() => [1]);
|
||||||
|
|
||||||
|
primitive
|
||||||
|
.setAttribute(
|
||||||
|
"JOINTS_0",
|
||||||
|
createAccessor(
|
||||||
|
document,
|
||||||
|
buffer,
|
||||||
|
"VEC4",
|
||||||
|
new Uint16Array(
|
||||||
|
jointIndices.flatMap((indices) =>
|
||||||
|
times(4, (index) =>
|
||||||
|
index < indices.length ? targets[indices[index]] ?? 0 : 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.setAttribute(
|
||||||
|
"WEIGHTS_0",
|
||||||
|
createAccessor(
|
||||||
|
document,
|
||||||
|
buffer,
|
||||||
|
"VEC4",
|
||||||
|
new Float32Array(
|
||||||
|
weights.flatMap((vertexWeights) => {
|
||||||
|
const padded = times(4, (index) => vertexWeights[index] ?? 0);
|
||||||
|
// some game weights sum below 1; glTF requires normalized weights
|
||||||
|
const sum = padded.reduce((total, weight) => total + weight, 0);
|
||||||
|
|
||||||
|
return padded.map((weight) => (sum > 0 ? weight / sum : weight));
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
import type { Material, Object3D } from "three";
|
import type { Material, Object3D } from "three";
|
||||||
import type { Skin } from "../../hooks/useSkin";
|
import type { Skin } from "../../hooks/useSkin";
|
||||||
import { jsxTree } from "./jsxTree";
|
import { jsxTree } from "./jsxTree";
|
||||||
|
|
||||||
type Mergers = Parameters<typeof jsxTree>[1];
|
type Mergers = NonNullable<Parameters<typeof jsxTree>[1]> & {
|
||||||
|
/** renders a customization part as is so its animations play */
|
||||||
|
primitive?: (object: Object3D, key: string) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
export function skinTree(
|
export function skinTree(
|
||||||
node: Object3D,
|
node: Object3D,
|
||||||
skin: Skin | undefined,
|
skin: Skin | undefined,
|
||||||
mergers: Mergers,
|
mergers: Mergers,
|
||||||
) {
|
) {
|
||||||
const skinNodes = new Map(
|
|
||||||
skin?.scene.children.map((node) => [node.name, node]),
|
|
||||||
);
|
|
||||||
const skinMergers: Mergers = {
|
const skinMergers: Mergers = {
|
||||||
mesh(mesh, props, key) {
|
mesh(mesh, props, key) {
|
||||||
const material = skin?.materials.get((mesh.material as Material).name);
|
const material = skin?.materials.get((mesh.material as Material).name);
|
||||||
@@ -23,7 +24,7 @@ export function skinTree(
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
group(object3d, props, key) {
|
group(object3d, props, key) {
|
||||||
const skinNode = skinNodes.get(object3d.name);
|
const parts = skin?.parts.get(object3d.name);
|
||||||
const isSwitch = object3d.userData.state !== undefined;
|
const isSwitch = object3d.userData.state !== undefined;
|
||||||
const state = skin
|
const state = skin
|
||||||
? skin.scene.userData.states[object3d.name]
|
? skin.scene.userData.states[object3d.name]
|
||||||
@@ -36,7 +37,13 @@ export function skinTree(
|
|||||||
.filter((child) => child.name === state)
|
.filter((child) => child.name === state)
|
||||||
.map((child) => jsxTree(child, skinMergers))
|
.map((child) => jsxTree(child, skinMergers))
|
||||||
: props.children}
|
: props.children}
|
||||||
{skinNode?.children.map((child) => jsxTree(child, mergers))}
|
{parts?.map((part) =>
|
||||||
|
mergers.primitive ? (
|
||||||
|
mergers.primitive(part, part.uuid)
|
||||||
|
) : (
|
||||||
|
<primitive key={part.uuid} object={part} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { alias } from "@blitzkit/core";
|
import { alias } from "@blitzkit/core";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { Group, Material } from "three";
|
import {
|
||||||
|
Mesh,
|
||||||
|
SkinnedMesh,
|
||||||
|
type AnimationClip,
|
||||||
|
type Group,
|
||||||
|
type Material,
|
||||||
|
type Object3D,
|
||||||
|
} from "three";
|
||||||
import { GLTFLoader } from "three-stdlib";
|
import { GLTFLoader } from "three-stdlib";
|
||||||
import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
|
import { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting";
|
||||||
import { Tankopedia } from "../stores/tankopedia";
|
import { Tankopedia } from "../stores/tankopedia";
|
||||||
@@ -8,6 +15,9 @@ import { Tankopedia } from "../stores/tankopedia";
|
|||||||
export interface Skin {
|
export interface Skin {
|
||||||
scene: Group;
|
scene: Group;
|
||||||
materials: Map<string, Material>;
|
materials: Map<string, Material>;
|
||||||
|
animations: AnimationClip[];
|
||||||
|
/** customization parts by the tank node they attach to */
|
||||||
|
parts: Map<string, Object3D[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const skins = new Map<string, Promise<Skin>>();
|
const skins = new Map<string, Promise<Skin>>();
|
||||||
@@ -19,8 +29,26 @@ async function loadSkin(path: string) {
|
|||||||
const skin = {
|
const skin = {
|
||||||
scene: gltf.scene,
|
scene: gltf.scene,
|
||||||
materials: new Map(materials.map((material) => [material.name, material])),
|
materials: new Map(materials.map((material) => [material.name, material])),
|
||||||
|
animations: gltf.animations,
|
||||||
|
// captured now because rendering moves the parts out of the scene
|
||||||
|
parts: new Map(
|
||||||
|
gltf.scene.children.map((target) => [target.name, [...target.children]]),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
for (const parts of skin.parts.values()) {
|
||||||
|
for (const part of parts) {
|
||||||
|
part.traverse((child) => {
|
||||||
|
if (!(child instanceof Mesh)) return;
|
||||||
|
|
||||||
|
child.castShadow = true;
|
||||||
|
child.receiveShadow = true;
|
||||||
|
// bounds are from the bind pose
|
||||||
|
if (child instanceof SkinnedMesh) child.frustumCulled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadedSkins.set(path, skin);
|
loadedSkins.set(path, skin);
|
||||||
|
|
||||||
return skin;
|
return skin;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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 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;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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()));
|
||||||
|
}
|
||||||
@@ -8,18 +8,38 @@ import {
|
|||||||
toUniqueId,
|
toUniqueId,
|
||||||
VertexAttribute,
|
VertexAttribute,
|
||||||
type ConfigArchive,
|
type ConfigArchive,
|
||||||
|
type Hierarchy,
|
||||||
type TankParameters,
|
type TankParameters,
|
||||||
type Textures,
|
type Textures,
|
||||||
type VehicleDefinitionList,
|
type VehicleDefinitionList,
|
||||||
} from "@blitzkit/core";
|
} from "@blitzkit/core";
|
||||||
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
|
import type { AbstractVFS } from "@blitzkit/core/src/blitzkit/vfs/abstract";
|
||||||
import { Document, Material, Node, NodeIO, Scene } from "@gltf-transform/core";
|
import {
|
||||||
|
Document,
|
||||||
|
Material,
|
||||||
|
Node,
|
||||||
|
NodeIO,
|
||||||
|
Scene,
|
||||||
|
type Skin,
|
||||||
|
} from "@gltf-transform/core";
|
||||||
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
|
||||||
import { dedup, prune } from "@gltf-transform/functions";
|
import { dedup, prune } from "@gltf-transform/functions";
|
||||||
import type { APIContext } from "astro";
|
import type { APIContext } from "astro";
|
||||||
import { isEqual, times } from "lodash-es";
|
import { isEqual, times } from "lodash-es";
|
||||||
import { dirname } from "path";
|
import { dirname } from "path";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
import {
|
||||||
|
addExtras,
|
||||||
|
toExtras,
|
||||||
|
} from "../../../../core/blitzkit/skinAnimations/extras";
|
||||||
|
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";
|
import { vfs } from "../../../../core/blitzkit/vfs";
|
||||||
import {
|
import {
|
||||||
vertexAttributeGLTFName,
|
vertexAttributeGLTFName,
|
||||||
@@ -86,6 +106,42 @@ export async function extractModel(
|
|||||||
const scene = document.createScene();
|
const scene = document.createScene();
|
||||||
const buffer = document.createBuffer();
|
const buffer = document.createBuffer();
|
||||||
const materials = await extractMaterials(document, sc2, path);
|
const materials = await extractMaterials(document, sc2, path);
|
||||||
|
const skeletons = new Map<Hierarchy, ReturnType<typeof createSkeleton>>();
|
||||||
|
const jointBindings: [
|
||||||
|
owner: Node,
|
||||||
|
entity: string,
|
||||||
|
joint: Node,
|
||||||
|
skin: Skin,
|
||||||
|
][] = [];
|
||||||
|
const motions = new Map<Hierarchy, Motion>();
|
||||||
|
|
||||||
|
if (isPart) {
|
||||||
|
const loading: Promise<void>[] = [];
|
||||||
|
|
||||||
|
parseHierarchies(sc2["#hierarchy"], null, (hierarchy, components) => {
|
||||||
|
for (const component of components) {
|
||||||
|
if (component["comp.typename"] !== "MotionComponent") continue;
|
||||||
|
|
||||||
|
loading.push(
|
||||||
|
loadMotion(vfs, path, component).then((motion) => {
|
||||||
|
if (motion) motions.set(hierarchy, motion);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
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>(
|
parseHierarchies<Scene | Node>(
|
||||||
sc2["#hierarchy"],
|
sc2["#hierarchy"],
|
||||||
@@ -99,6 +155,41 @@ export async function extractModel(
|
|||||||
|
|
||||||
const node = document.createNode(hierarchy.name);
|
const node = document.createNode(hierarchy.name);
|
||||||
|
|
||||||
|
if (isPart) {
|
||||||
|
const skeleton = components.find(
|
||||||
|
(component) => component["comp.typename"] === "SkeletonComponent",
|
||||||
|
);
|
||||||
|
const motionComponent = components.find(
|
||||||
|
(component) => component["comp.typename"] === "MotionComponent",
|
||||||
|
);
|
||||||
|
const motion = motionComponent ? motions.get(hierarchy) : undefined;
|
||||||
|
|
||||||
|
if (skeleton) {
|
||||||
|
const created = createSkeleton(
|
||||||
|
document,
|
||||||
|
buffer,
|
||||||
|
node,
|
||||||
|
skeleton,
|
||||||
|
motion,
|
||||||
|
);
|
||||||
|
|
||||||
|
skeletons.set(hierarchy, created);
|
||||||
|
|
||||||
|
if (motion && motionComponent) {
|
||||||
|
addExtras(node, {
|
||||||
|
motion: createMotionClip(
|
||||||
|
document,
|
||||||
|
buffer,
|
||||||
|
`motion${document.getRoot().listAnimations().length}`,
|
||||||
|
created.joints,
|
||||||
|
motion,
|
||||||
|
motionComponent,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
components.forEach((component) => {
|
components.forEach((component) => {
|
||||||
switch (component["comp.typename"]) {
|
switch (component["comp.typename"]) {
|
||||||
case "LodComponent":
|
case "LodComponent":
|
||||||
@@ -121,18 +212,70 @@ export async function extractModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "StateSwitcherComponent":
|
case "StateSwitcherComponent":
|
||||||
node.setExtras({
|
addExtras(node, {
|
||||||
state: component[`ssc.state${component["ssc.activeState"]}`],
|
state: component[`ssc.state${component["ssc.activeState"]}`],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isPart) {
|
||||||
|
addExtras(node, {
|
||||||
|
stateNames: times(
|
||||||
|
component["ssc.statesCount"],
|
||||||
|
(index) => component[`ssc.state${index}`],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
break;
|
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": {
|
||||||
|
const skeleton = skeletons.get(hierarchy);
|
||||||
|
|
||||||
|
if (!skeleton) break;
|
||||||
|
|
||||||
|
times(component.count, (index) => {
|
||||||
|
const binding = component.map[
|
||||||
|
index.toString().padStart(4, "0")
|
||||||
|
] as unknown as { entityUid: string; jointUid: string };
|
||||||
|
const joint = skeleton.joints.get(binding.jointUid);
|
||||||
|
|
||||||
|
if (joint) {
|
||||||
|
jointBindings.push([
|
||||||
|
node,
|
||||||
|
binding.entityUid,
|
||||||
|
joint,
|
||||||
|
skeleton.skin,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "RenderComponent": {
|
case "RenderComponent": {
|
||||||
const renderObject = component["rc.renderObj"];
|
const renderObject = component["rc.renderObj"];
|
||||||
|
|
||||||
times(renderObject["ro.batchCount"], (batchIndex): void => {
|
times(renderObject["ro.batchCount"], (batchIndex): void => {
|
||||||
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
|
const lodIndex = renderObject[`rb${batchIndex}.lodIndex`];
|
||||||
|
|
||||||
if (lodIndex !== 0) return;
|
// -1 means the batch has no lods and is always drawn
|
||||||
|
if (lodIndex !== 0 && !(isPart && lodIndex === -1)) return;
|
||||||
|
|
||||||
const batchKey = batchIndex.toString().padStart(4, "0");
|
const batchKey = batchIndex.toString().padStart(4, "0");
|
||||||
const batch = renderObject["ro.batches"][batchKey];
|
const batch = renderObject["ro.batches"][batchKey];
|
||||||
@@ -193,10 +336,24 @@ export async function extractModel(
|
|||||||
primitive.setAttribute(name, attributeAccessor);
|
primitive.setAttribute(name, attributeAccessor);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const skeleton = skeletons.get(hierarchy);
|
||||||
const mesh = document
|
const mesh = document
|
||||||
.createMesh(batch["##name"])
|
.createMesh(batch["##name"])
|
||||||
.addPrimitive(primitive);
|
.addPrimitive(primitive);
|
||||||
lodNode.setMesh(mesh);
|
lodNode.setMesh(mesh);
|
||||||
|
|
||||||
|
if (
|
||||||
|
skeleton &&
|
||||||
|
skinPrimitive(
|
||||||
|
document,
|
||||||
|
buffer,
|
||||||
|
primitive,
|
||||||
|
attributes,
|
||||||
|
renderObject[`skinnedObject.batch${batchIndex}.targetsData`],
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
lodNode.setSkin(skeleton.skin);
|
||||||
|
}
|
||||||
node.addChild(lodNode);
|
node.addChild(lodNode);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,6 +376,12 @@ export async function extractModel(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
for (const [owner, entity, joint, skin] of jointBindings) {
|
||||||
|
const socket = findNode(owner, entity);
|
||||||
|
|
||||||
|
if (socket) bindSocket(socket, joint, skin);
|
||||||
|
}
|
||||||
|
|
||||||
scene.addChild(document.createNode("test"));
|
scene.addChild(document.createNode("test"));
|
||||||
|
|
||||||
await document.transform(prune({ keepAttributes: true }), dedup());
|
await document.transform(prune({ keepAttributes: true }), dedup());
|
||||||
@@ -226,6 +389,16 @@ export async function extractModel(
|
|||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findNode(node: Node, name: string): Node | undefined {
|
||||||
|
for (const child of node.listChildren()) {
|
||||||
|
if (child.getName() === name) return child;
|
||||||
|
|
||||||
|
const found = findNode(child, name);
|
||||||
|
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function extractMaterials(
|
export async function extractMaterials(
|
||||||
document: Document,
|
document: Document,
|
||||||
sc2: ReturnType<Sc2ReadStream["sc2"]>,
|
sc2: ReturnType<Sc2ReadStream["sc2"]>,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
type TankParameters,
|
type TankParameters,
|
||||||
} from "@blitzkit/core";
|
} from "@blitzkit/core";
|
||||||
import {
|
import {
|
||||||
|
Animation,
|
||||||
Document,
|
Document,
|
||||||
Node,
|
Node,
|
||||||
NodeIO,
|
NodeIO,
|
||||||
@@ -19,6 +20,7 @@ import type { Vector3Tuple, Vector4Tuple } from "three";
|
|||||||
import { SKIN_NUMBER } from "../../../../../constants/skinNumber";
|
import { SKIN_NUMBER } from "../../../../../constants/skinNumber";
|
||||||
import { api } from "../../../../../core/blitzkit/api";
|
import { api } from "../../../../../core/blitzkit/api";
|
||||||
import { mixStaticPaths } from "../../../../../core/blitzkit/mixStaticPaths";
|
import { mixStaticPaths } from "../../../../../core/blitzkit/mixStaticPaths";
|
||||||
|
import type { SkinMotion } from "../../../../../core/blitzkit/skinAnimations/skeleton";
|
||||||
import { vfs } from "../../../../../core/blitzkit/vfs";
|
import { vfs } from "../../../../../core/blitzkit/vfs";
|
||||||
import { getStaticPaths as _getStaticPaths } from "../../../_index";
|
import { getStaticPaths as _getStaticPaths } from "../../../_index";
|
||||||
import { extractMaterials, extractModel } from "../model.glb";
|
import { extractMaterials, extractModel } from "../model.glb";
|
||||||
@@ -166,7 +168,26 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
|||||||
.createNode(name)
|
.createNode(name)
|
||||||
.setTranslation(slot.position)
|
.setTranslation(slot.position)
|
||||||
.setRotation(slot.rotation)
|
.setRotation(slot.rotation)
|
||||||
.setScale(slot.scale);
|
.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}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
let target = targets.get(slot.target);
|
let target = targets.get(slot.target);
|
||||||
|
|
||||||
if (!target) {
|
if (!target) {
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ interface Tankopedia {
|
|||||||
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
|
xpMultiplier: (typeof XP_MULTIPLIERS)[number];
|
||||||
customShell?: ShellDefinition;
|
customShell?: ShellDefinition;
|
||||||
skin?: number;
|
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;
|
requestedDisplay: TankopediaDisplay;
|
||||||
display: TankopediaDisplay;
|
display: TankopediaDisplay;
|
||||||
statSearch?: string;
|
statSearch?: string;
|
||||||
@@ -120,6 +124,8 @@ export const Tankopedia = new Varuna<Tankopedia, ModelDefinition>((model) => ({
|
|||||||
skills: createDefaultSkills(skillDefinitions),
|
skills: createDefaultSkills(skillDefinitions),
|
||||||
model,
|
model,
|
||||||
xpMultiplier: 1,
|
xpMultiplier: 1,
|
||||||
|
skinEvents: [],
|
||||||
|
skinAnimating: false,
|
||||||
requestedDisplay: TankopediaDisplay.Model,
|
requestedDisplay: TankopediaDisplay.Model,
|
||||||
display: TankopediaDisplay.Model,
|
display: TankopediaDisplay.Model,
|
||||||
}));
|
}));
|
||||||
Reference in new issue
Block a user