mirror of
https://github.com/blitzkit/blitzkit.git
synced 2026-11-08 02:10:37 +01:00
Merge pull request #405 from Hitjack007/animated-legendary-camos
This commit is contained in:
12 files changed
+955
-88
No files matched your search
@@ -158,7 +158,11 @@ export type Component =
|
||||
"ro.notShadowOnly": boolean;
|
||||
"ro.sOclCull": boolean;
|
||||
"ro.sOclIndex": number;
|
||||
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number>;
|
||||
} & Record<`rb${number}.${"lodIndex" | "switchIndex"}`, number> &
|
||||
Record<
|
||||
`skinnedObject.batch${number}.targetsData`,
|
||||
ArrayBuffer | undefined
|
||||
>;
|
||||
}
|
||||
| {
|
||||
"comp.typename": "SlotComponent";
|
||||
|
||||
+35
-2
@@ -9,6 +9,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";
|
||||
@@ -36,12 +37,17 @@ export function TankModel() {
|
||||
const skins = [skinGltf, attachmentGltf].filter((skin) => skin !== undefined);
|
||||
|
||||
useTankTransform(track, turret, turretContainer, gunContainer);
|
||||
useSkinAnimation(skins);
|
||||
|
||||
useEffect(() => {
|
||||
const emissiveMaterials = new Set<MeshStandardMaterial>();
|
||||
const roots = [
|
||||
gltf.scene,
|
||||
...skins.flatMap((skin) => [...skin.parts.values()].flat()),
|
||||
];
|
||||
|
||||
for (const scene of [gltf.scene, ...skins.map((skin) => skin.scene)]) {
|
||||
scene.traverse((object) => {
|
||||
for (const root of roots) {
|
||||
root.traverse((object) => {
|
||||
if (
|
||||
object instanceof Mesh &&
|
||||
object.material instanceof MeshStandardMaterial &&
|
||||
@@ -144,6 +150,15 @@ export function TankModel() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
primitive(object, key) {
|
||||
return (
|
||||
<primitive
|
||||
key={key}
|
||||
object={object}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -221,6 +236,15 @@ export function TankModel() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
primitive(object, key) {
|
||||
return (
|
||||
<primitive
|
||||
key={key}
|
||||
object={object}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
})}
|
||||
|
||||
@@ -302,6 +326,15 @@ export function TankModel() {
|
||||
/>
|
||||
);
|
||||
},
|
||||
primitive(object, key) {
|
||||
return (
|
||||
<primitive
|
||||
key={key}
|
||||
object={object}
|
||||
onPointerDown={onPointerDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
})}
|
||||
</group>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export function isHalloween() {
|
||||
return false;
|
||||
|
||||
const data = new Date();
|
||||
return data.getMonth() === 9 && data.getDate() === 31;
|
||||
}
|
||||
@@ -1,5 +1,21 @@
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { AdditiveBlending, 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,
|
||||
@@ -14,6 +30,7 @@ export function jsxTree(
|
||||
props: ComponentProps<"group">,
|
||||
key: string,
|
||||
) => ReactNode;
|
||||
primitive?: (object3d: Object3D, key: string) => ReactNode;
|
||||
},
|
||||
): ReactNode {
|
||||
if (node instanceof Mesh) {
|
||||
@@ -25,13 +42,7 @@ export function jsxTree(
|
||||
scale: node.scale,
|
||||
};
|
||||
|
||||
if (props.material instanceof MeshStandardMaterial) {
|
||||
props.material.depthWrite = true;
|
||||
|
||||
if (props.material.userData.blending === "additive") {
|
||||
props.material.blending = AdditiveBlending;
|
||||
}
|
||||
}
|
||||
prepareMaterial(props.material);
|
||||
|
||||
return mergers?.mesh ? (
|
||||
mergers.mesh(node, props, node.uuid)
|
||||
|
||||
@@ -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,287 @@
|
||||
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" }
|
||||
>;
|
||||
type AccessorType = "SCALAR" | "VEC3" | "VEC4" | "MAT4";
|
||||
|
||||
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"]}`),
|
||||
);
|
||||
|
||||
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,
|
||||
) {
|
||||
const animation = document.createAnimation(clip);
|
||||
const playbackRate = component["motion.playbackRate"];
|
||||
const duration = motion.duration / playbackRate;
|
||||
|
||||
for (const [uid, track] of motion.tracks) {
|
||||
const joint = joints.get(uid);
|
||||
|
||||
if (!joint) continue;
|
||||
|
||||
for (const [type, { times, values }] of track) {
|
||||
const keyTimes = times.map((time) => time / playbackRate);
|
||||
const keyValues =
|
||||
type === MotionChannel.Scale
|
||||
? values.flatMap((value) => [value, value, value])
|
||||
: [...values];
|
||||
|
||||
// the game loops over the clip's duration, which can outlast its keys
|
||||
if (keyTimes[keyTimes.length - 1] < duration) {
|
||||
const size = keyValues.length / keyTimes.length;
|
||||
|
||||
keyTimes.push(duration);
|
||||
keyValues.push(...keyValues.slice(-size));
|
||||
}
|
||||
|
||||
const sampler = document
|
||||
.createAnimationSampler()
|
||||
.setInput(
|
||||
createAccessor(
|
||||
document,
|
||||
buffer,
|
||||
"SCALAR",
|
||||
new Float32Array(keyTimes),
|
||||
),
|
||||
)
|
||||
.setOutput(
|
||||
createAccessor(
|
||||
document,
|
||||
buffer,
|
||||
type === MotionChannel.Rotation ? "VEC4" : "VEC3",
|
||||
new Float32Array(keyValues),
|
||||
),
|
||||
)
|
||||
.setInterpolation("LINEAR");
|
||||
|
||||
animation
|
||||
.addSampler(sampler)
|
||||
.addChannel(
|
||||
document
|
||||
.createAnimationChannel()
|
||||
.setTargetNode(joint)
|
||||
.setTargetPath(MOTION_PATHS[type])
|
||||
.setSampler(sampler),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a socket follow a joint. The game replaces the socket's own transform
|
||||
* with the joint's, so it sits right on the joint.
|
||||
*/
|
||||
export function bindSocket(socket: Node, joint: Node, skin: Skin) {
|
||||
joint.addChild(socket);
|
||||
socket.setMatrix(new Matrix4().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) => {
|
||||
if (index >= indices.length) return 0;
|
||||
|
||||
const target = targets[indices[index]];
|
||||
|
||||
if (target === undefined) {
|
||||
throw new Error(`Missing joint target ${indices[index]}`);
|
||||
}
|
||||
|
||||
return target;
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.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;
|
||||
}
|
||||
@@ -5,24 +5,24 @@ import { jsxTree } from "./jsxTree";
|
||||
type Mergers = Parameters<typeof jsxTree>[1];
|
||||
|
||||
// names get sanitized and made unique on load
|
||||
function nameOf(object3d: Object3D): string {
|
||||
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
|
||||
* camouflage's; `primitive` renders customization parts as they are so their
|
||||
* bones can animate
|
||||
*/
|
||||
export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) {
|
||||
const skinNodes = new Map<string, Object3D[]>();
|
||||
export function skinTree(
|
||||
node: Object3D,
|
||||
skins: Skin[],
|
||||
mergers: NonNullable<Mergers>,
|
||||
) {
|
||||
const materials = new Map<string, Material>();
|
||||
const states: Record<string, string | null> = {};
|
||||
|
||||
for (const skin of skins) {
|
||||
for (const node of skin.scene.children) {
|
||||
skinNodes.set(node.name, [...(skinNodes.get(node.name) ?? []), node]);
|
||||
}
|
||||
|
||||
for (const [name, material] of skin.materials) {
|
||||
materials.set(name, material);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) {
|
||||
Object.assign(states, skin.scene.userData.states);
|
||||
}
|
||||
|
||||
function switchChildren(object3d: Object3D, childMergers: Mergers) {
|
||||
function switchChildren(object3d: Object3D) {
|
||||
if (object3d.userData.state === undefined) return;
|
||||
|
||||
const name = nameOf(object3d);
|
||||
@@ -38,37 +38,31 @@ export function skinTree(node: Object3D, skins: Skin[], mergers: Mergers) {
|
||||
|
||||
return object3d.children
|
||||
.filter((child) => nameOf(child) === state)
|
||||
.map((child) => jsxTree(child, childMergers));
|
||||
.map((child) => jsxTree(child, skinMergers));
|
||||
}
|
||||
|
||||
const partMergers: Mergers = {
|
||||
...mergers,
|
||||
group(object3d, props, key) {
|
||||
return (
|
||||
<group key={key} {...props}>
|
||||
{switchChildren(object3d, partMergers) ?? props.children}
|
||||
</group>
|
||||
);
|
||||
},
|
||||
};
|
||||
const skinMergers: Mergers = {
|
||||
mesh(mesh, props, key) {
|
||||
const material = materials.get((mesh.material as Material).name);
|
||||
|
||||
return mergers!.mesh!(
|
||||
return mergers.mesh!(
|
||||
mesh,
|
||||
material ? { ...props, material } : props,
|
||||
key,
|
||||
);
|
||||
},
|
||||
group(object3d, props, key) {
|
||||
const parts = skinNodes.get(object3d.name) ?? [];
|
||||
|
||||
return (
|
||||
<group key={key} {...props}>
|
||||
{switchChildren(object3d, skinMergers) ?? props.children}
|
||||
{parts.flatMap((part) =>
|
||||
part.children.map((child) => jsxTree(child, partMergers)),
|
||||
{switchChildren(object3d) ?? props.children}
|
||||
{skins.flatMap((skin) =>
|
||||
(skin.parts.get(object3d.name) ?? []).map((part) =>
|
||||
mergers.primitive ? (
|
||||
mergers.primitive(part, part.uuid)
|
||||
) : (
|
||||
<primitive key={part.uuid} object={part} />
|
||||
),
|
||||
),
|
||||
)}
|
||||
</group>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { parseHierarchies, type Sc2ReadStream } from "@blitzkit/core";
|
||||
import { times } from "lodash-es";
|
||||
import {
|
||||
parseHierarchies,
|
||||
type Hierarchy,
|
||||
type Sc2ReadStream,
|
||||
} from "@blitzkit/core";
|
||||
import { sortBy, times } from "lodash-es";
|
||||
|
||||
interface Value {
|
||||
"any.type": string;
|
||||
@@ -17,12 +21,15 @@ interface ScriptNode {
|
||||
|
||||
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;
|
||||
@@ -32,12 +39,17 @@ 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;
|
||||
@@ -72,25 +84,141 @@ function resolveTarget(owner: Entity, entityName: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* replays what picks each state switcher's state as a tank is shown in the
|
||||
* garage, with the camo's preset applied and its items attached; delayed
|
||||
* actions are skipped so states stay as they are when the tank appears
|
||||
* 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
|
||||
*/
|
||||
export function switcherStates(
|
||||
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 }[] = [];
|
||||
const scenarios: {
|
||||
owner: Entity;
|
||||
script: Script;
|
||||
variables: Map<string, unknown>;
|
||||
}[] = [];
|
||||
const actions: { owner: Entity; action: Action }[] = [];
|
||||
const states: Record<string, string | null> = {};
|
||||
// names can repeat, so states are kept per entity
|
||||
const states = new Map<Entity, string | null>();
|
||||
const looping = new Set<Entity>();
|
||||
const finished = 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) states[entity.name] = names[state] ?? null;
|
||||
if (!names) return;
|
||||
|
||||
const previous = states.get(entity);
|
||||
|
||||
states.set(entity, names[state] ?? null);
|
||||
|
||||
if (!animate || previous === states.get(entity)) 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.get(child.parent) !== 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;
|
||||
}
|
||||
|
||||
if (looping.has(entity)) return;
|
||||
|
||||
looping.add(entity);
|
||||
|
||||
// some clips restart themselves when they end, so each finishes once
|
||||
if ((repeats ?? entity.repeats) > 0 && !finished.has(entity)) {
|
||||
pending.push(() => finishAnimation(entity));
|
||||
}
|
||||
}
|
||||
|
||||
function finishAnimation(entity: Entity) {
|
||||
const statesBefore = new Map(states);
|
||||
const shownBefore = [...looping].filter(isShown);
|
||||
|
||||
looping.delete(entity);
|
||||
finished.add(entity);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// as in game, a clip stops on whatever it switched to or revealed; one that
|
||||
// changes nothing is the only animation there is, so it keeps looping
|
||||
if (
|
||||
[...states].every(
|
||||
([other, state]) => statesBefore.get(other) === state,
|
||||
) &&
|
||||
[...looping].every(
|
||||
(other) => !isShown(other) || shownBefore.includes(other),
|
||||
)
|
||||
) {
|
||||
looping.add(entity);
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -103,9 +231,14 @@ export function switcherStates(
|
||||
|
||||
parseHierarchies<Entity>(
|
||||
sc2["#hierarchy"],
|
||||
{ name: "", children: [] },
|
||||
root,
|
||||
(hierarchy, components, parent) => {
|
||||
const entity: Entity = { name: hierarchy.name, parent, children: [] };
|
||||
const entity: Entity = {
|
||||
name: hierarchy.name,
|
||||
parent,
|
||||
children: [],
|
||||
hierarchy,
|
||||
};
|
||||
|
||||
parent.children.push(entity);
|
||||
|
||||
@@ -122,14 +255,24 @@ export function switcherStates(
|
||||
);
|
||||
|
||||
stateNames.set(entity, names);
|
||||
states[entity.name] = names[component["ssc.activeState"]] ?? null;
|
||||
states.set(entity, names[component["ssc.activeState"]] ?? null);
|
||||
break;
|
||||
}
|
||||
|
||||
case "ScenarioComponent": {
|
||||
const script = scripts[component["scenario.scriptId"]];
|
||||
|
||||
if (script) scenarios.push({ owner: entity, script });
|
||||
if (script) {
|
||||
scenarios.push({
|
||||
owner: entity,
|
||||
script,
|
||||
variables: new Map(
|
||||
Object.entries(script.variables ?? {}).map(
|
||||
([name, variable]) => [name, variable["any.value"]],
|
||||
),
|
||||
),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -141,6 +284,12 @@ export function switcherStates(
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
case "MotionComponent":
|
||||
if (animate) {
|
||||
entity.repeats = component["simpleMotion.repeatsCount"];
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,9 +298,11 @@ export function switcherStates(
|
||||
);
|
||||
|
||||
function runScenario(
|
||||
{ owner, script }: (typeof scenarios)[number],
|
||||
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 ?? {});
|
||||
|
||||
@@ -168,31 +319,79 @@ export function switcherStates(
|
||||
|
||||
switch (type) {
|
||||
case "ScenarioSceneInfoNode":
|
||||
return node["scenarioSceneInfoNode.infoKind"] === "GetCurrentEntity"
|
||||
? owner
|
||||
: undefined;
|
||||
switch (node["scenarioSceneInfoNode.infoKind"]) {
|
||||
case "GetCurrentEntity":
|
||||
return owner;
|
||||
case "GetCurrentScene":
|
||||
return root;
|
||||
}
|
||||
return undefined;
|
||||
|
||||
case "ScenarioGetMemberNode":
|
||||
return node["scenarioGetMemberNode.fieldName"] === "parent" &&
|
||||
pin === "parent"
|
||||
? (input(id, "object") as Entity | undefined)?.parent
|
||||
: undefined;
|
||||
case "ScenarioGetVarNode":
|
||||
return variables.get(node["scenarioGetVarNode.varPath"] as string);
|
||||
|
||||
case "ScenarioFunctionNode":
|
||||
switch (
|
||||
`${node["scenarioFunctionNode.className"]}.${node["scenarioFunctionNode.functionName"]}`
|
||||
) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,13 +405,41 @@ export function switcherStates(
|
||||
pin = "event";
|
||||
} else if (type === "ScenarioBranchNode") {
|
||||
pin = input(id, "condition") ? "true" : "false";
|
||||
} else if (
|
||||
node["scenarioFunctionNode.functionName"] === "ActivateState"
|
||||
) {
|
||||
activateState(
|
||||
input(id, "entity") as Entity | undefined,
|
||||
Number(input(id, "state")),
|
||||
} 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)) {
|
||||
@@ -227,29 +454,87 @@ export function switcherStates(
|
||||
? node.defaults?.["Event Name"]?.["any.value"]
|
||||
: node["ScenarioEventNode.eventName"];
|
||||
|
||||
if (type === "ScenarioEventNode" && name === event) execute(id, 0);
|
||||
if (
|
||||
type === "ScenarioEventNode" &&
|
||||
name === event &&
|
||||
(event !== "State Activated" ||
|
||||
Number(node.defaults?.State?.["any.value"] ?? 0) === value)
|
||||
) {
|
||||
execute(id, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const event of LOAD_EVENTS) {
|
||||
for (const { owner, action } of actions) {
|
||||
function fireEvent(event: string) {
|
||||
if (fired.has(event)) return;
|
||||
|
||||
fired.add(event);
|
||||
|
||||
// the game runs delayed actions once their delay is up, so they go in that
|
||||
// order rather than the order they're stored in
|
||||
for (const { owner, action } of sortBy(
|
||||
actions,
|
||||
({ action }) => action["act.delay"],
|
||||
)) {
|
||||
if (
|
||||
action["act.delay"] === 0 &&
|
||||
SWITCH_ACTIONS.includes(action["act.type"]) &&
|
||||
(animate || action["act.delay"] === 0) &&
|
||||
(event === LOAD_EVENTS[0]
|
||||
? action["act.event"] === SCENE_LOADED_EVENT
|
||||
: action["act.event"] === USER_EVENT &&
|
||||
action["act.userEventId"] === event)
|
||||
) {
|
||||
activateState(
|
||||
resolveTarget(owner, action["act.entityName"]),
|
||||
action["act.switchIndex"],
|
||||
);
|
||||
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: new Map(
|
||||
[...states].map(([entity, state]) => [
|
||||
entity.hierarchy as Hierarchy,
|
||||
state,
|
||||
]),
|
||||
),
|
||||
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[]>,
|
||||
) {
|
||||
const states: Record<string, string | null> = {};
|
||||
|
||||
for (const [hierarchy, state] of replay(sc2, preset, attachments, false)
|
||||
.states) {
|
||||
states[hierarchy.name] = state;
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* where a customization part ends up: each state switcher's state and the
|
||||
* motions left looping
|
||||
*/
|
||||
export function partGarage(
|
||||
sc2: ReturnType<Sc2ReadStream["sc2"]>,
|
||||
preset: string | undefined,
|
||||
) {
|
||||
return replay(sc2, preset, new Map(), true);
|
||||
}
|
||||
@@ -1,13 +1,25 @@
|
||||
import { alias } from "@blitzkit/core";
|
||||
import { useEffect } from "react";
|
||||
import type { Group, Material } from "three";
|
||||
import {
|
||||
AnimationMixer,
|
||||
Mesh,
|
||||
SkinnedMesh,
|
||||
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>;
|
||||
/** customization parts by the tank node they attach to */
|
||||
parts: Map<string, Object3D[]>;
|
||||
mixer?: AnimationMixer;
|
||||
}
|
||||
|
||||
const skins = new Map<string, Promise<Skin>>();
|
||||
@@ -23,13 +35,46 @@ async function loadSkin(path: string) {
|
||||
.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,
|
||||
};
|
||||
|
||||
// bound now for the same reason, every exported clip loops in the garage
|
||||
for (const clip of gltf.animations) skin.mixer!.clipAction(clip).play();
|
||||
|
||||
for (const parts of skin.parts.values()) {
|
||||
for (const part of parts) preparePart(part);
|
||||
}
|
||||
|
||||
loadedSkins.set(path, skin);
|
||||
|
||||
return skin;
|
||||
}
|
||||
|
||||
// parts render as they are, so what jsxTree and skinTree would do happens here
|
||||
function preparePart(part: Object3D) {
|
||||
part.traverse((object) => {
|
||||
if (object.userData.state !== undefined) {
|
||||
for (const child of object.children) {
|
||||
child.visible = nameOf(child) === object.userData.state;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(object instanceof Mesh)) return;
|
||||
|
||||
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`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { invalidate, useFrame } from "@react-three/fiber";
|
||||
import type { Skin } from "./useSkin";
|
||||
|
||||
export function useSkinAnimation(skins: Skin[]) {
|
||||
useFrame((_, delta) => {
|
||||
for (const { mixer } of skins) {
|
||||
if (!mixer) continue;
|
||||
|
||||
mixer.update(delta);
|
||||
invalidate();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -172,13 +172,7 @@ export async function GET({ props }: APIContext<{ skin: number }>) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const part = await extractModel(vfs, path, true);
|
||||
const partSc2 = new Sc2ReadStream(
|
||||
(await vfs.file(`Data/3d/${path}.sc2`)).buffer as ArrayBuffer,
|
||||
).sc2();
|
||||
|
||||
Object.assign(states, switcherStates(partSc2, preset, new Map()));
|
||||
|
||||
const part = await extractModel(vfs, path, true, preset);
|
||||
const map = mergeDocuments(document, part);
|
||||
const partScene = map.get(part.getRoot().listScenes()[0]) as Scene;
|
||||
const node = document
|
||||
|
||||
@@ -8,18 +8,35 @@ import {
|
||||
toUniqueId,
|
||||
VertexAttribute,
|
||||
type ConfigArchive,
|
||||
type Hierarchy,
|
||||
type TankParameters,
|
||||
type Textures,
|
||||
type VehicleDefinitionList,
|
||||
} from "@blitzkit/core";
|
||||
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 { dedup, prune } from "@gltf-transform/functions";
|
||||
import type { APIContext } from "astro";
|
||||
import { isEqual, times } from "lodash-es";
|
||||
import { dirname } from "path";
|
||||
import sharp from "sharp";
|
||||
import type { Motion } from "../../../core/blitzkit/skinAnimations/motion";
|
||||
import {
|
||||
bindSocket,
|
||||
createMotionClip,
|
||||
createSkeleton,
|
||||
loadMotion,
|
||||
skinPrimitive,
|
||||
} from "../../../core/blitzkit/skinAnimations/skeleton";
|
||||
import { partGarage } from "../../../core/blitzkit/switcherStates";
|
||||
import { vfs } from "../../../core/blitzkit/vfs";
|
||||
import {
|
||||
vertexAttributeGLTFName,
|
||||
@@ -79,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`;
|
||||
@@ -92,6 +110,35 @@ export async function extractModel(
|
||||
const scene = document.createScene();
|
||||
const buffer = document.createBuffer();
|
||||
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>();
|
||||
const garage = isPart ? partGarage(sc2, preset) : undefined;
|
||||
|
||||
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) => {
|
||||
motions.set(hierarchy, motion);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
await Promise.all(loading);
|
||||
}
|
||||
|
||||
parseHierarchies<Scene | Node>(
|
||||
sc2["#hierarchy"],
|
||||
@@ -105,6 +152,40 @@ export async function extractModel(
|
||||
|
||||
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 = motions.get(hierarchy);
|
||||
|
||||
if (skeleton) {
|
||||
const created = createSkeleton(
|
||||
document,
|
||||
buffer,
|
||||
node,
|
||||
skeleton,
|
||||
motion,
|
||||
);
|
||||
|
||||
skeletons.set(hierarchy, created);
|
||||
|
||||
// only what loops in the garage is exported
|
||||
if (motion && motionComponent && garage?.motions.has(hierarchy)) {
|
||||
createMotionClip(
|
||||
document,
|
||||
buffer,
|
||||
`motion${document.getRoot().listAnimations().length}`,
|
||||
created.joints,
|
||||
motion,
|
||||
motionComponent,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
components.forEach((component) => {
|
||||
switch (component["comp.typename"]) {
|
||||
case "LodComponent":
|
||||
@@ -128,10 +209,36 @@ export async function extractModel(
|
||||
|
||||
case "StateSwitcherComponent":
|
||||
node.setExtras({
|
||||
state: component[`ssc.state${component["ssc.activeState"]}`],
|
||||
// a part's scenarios can pick its state as it loads
|
||||
state: garage
|
||||
? (garage.states.get(hierarchy) ?? null)
|
||||
: component[`ssc.state${component["ssc.activeState"]}`],
|
||||
});
|
||||
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) throw new Error(`Missing joint ${binding.jointUid}`);
|
||||
|
||||
jointBindings.push([
|
||||
node,
|
||||
binding.entityUid,
|
||||
joint,
|
||||
skeleton.skin,
|
||||
]);
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "RenderComponent": {
|
||||
const renderObject = component["rc.renderObj"];
|
||||
|
||||
@@ -200,10 +307,24 @@ export async function extractModel(
|
||||
primitive.setAttribute(name, attributeAccessor);
|
||||
});
|
||||
|
||||
const skeleton = skeletons.get(hierarchy);
|
||||
const mesh = document
|
||||
.createMesh(batch["##name"])
|
||||
.addPrimitive(primitive);
|
||||
lodNode.setMesh(mesh);
|
||||
|
||||
if (
|
||||
skeleton &&
|
||||
skinPrimitive(
|
||||
document,
|
||||
buffer,
|
||||
primitive,
|
||||
attributes,
|
||||
renderObject[`skinnedObject.batch${batchIndex}.targetsData`],
|
||||
)
|
||||
) {
|
||||
lodNode.setSkin(skeleton.skin);
|
||||
}
|
||||
node.addChild(lodNode);
|
||||
});
|
||||
|
||||
@@ -226,6 +347,20 @@ export async function extractModel(
|
||||
},
|
||||
);
|
||||
|
||||
for (const [owner, entity, joint, skin] of jointBindings) {
|
||||
// the game only binds the skeleton entity's own children
|
||||
const socket = owner
|
||||
.listChildren()
|
||||
.find(
|
||||
(child) =>
|
||||
child.getName() === entity && !skin.listJoints().includes(child),
|
||||
);
|
||||
|
||||
if (!socket) throw new Error(`Missing socket ${entity}`);
|
||||
|
||||
bindSocket(socket, joint, skin);
|
||||
}
|
||||
|
||||
scene.addChild(document.createNode("test"));
|
||||
|
||||
await document.transform(prune({ keepAttributes: true }), dedup());
|
||||
|
||||
Reference in new issue
Block a user