diff --git a/packages/core/src/streams/sc2.ts b/packages/core/src/streams/sc2.ts index db490d602..712fe37b2 100644 --- a/packages/core/src/streams/sc2.ts +++ b/packages/core/src/streams/sc2.ts @@ -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"; diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel.tsx index 718ca7bbd..41bd2e33b 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/TankSandbox/components/TankModel.tsx @@ -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(); + 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 ( + + ); + }, }); })} @@ -221,6 +236,15 @@ export function TankModel() { /> ); }, + primitive(object, key) { + return ( + + ); + }, }); })} @@ -302,6 +326,15 @@ export function TankModel() { /> ); }, + primitive(object, key) { + return ( + + ); + }, }); })} diff --git a/packages/website/src/core/blitzkit/isHalloween.ts b/packages/website/src/core/blitzkit/isHalloween.ts index c05deb5f5..c436a6a26 100644 --- a/packages/website/src/core/blitzkit/isHalloween.ts +++ b/packages/website/src/core/blitzkit/isHalloween.ts @@ -1,4 +1,6 @@ export function isHalloween() { + return false; + const data = new Date(); return data.getMonth() === 9 && data.getDate() === 31; } diff --git a/packages/website/src/core/blitzkit/jsxTree.tsx b/packages/website/src/core/blitzkit/jsxTree.tsx index 9cf52a79b..96ab50c03 100644 --- a/packages/website/src/core/blitzkit/jsxTree.tsx +++ b/packages/website/src/core/blitzkit/jsxTree.tsx @@ -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) diff --git a/packages/website/src/core/blitzkit/skinAnimations/motion.ts b/packages/website/src/core/blitzkit/skinAnimations/motion.ts new file mode 100644 index 000000000..71b2afab3 --- /dev/null +++ b/packages/website/src/core/blitzkit/skinAnimations/motion.ts @@ -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>; +} + +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(); + + 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; + } +} diff --git a/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts new file mode 100644 index 000000000..c6a006414 --- /dev/null +++ b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts @@ -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, + 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, + 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; +} diff --git a/packages/website/src/core/blitzkit/skinTree.tsx b/packages/website/src/core/blitzkit/skinTree.tsx index 5a2020be4..81c5a46d6 100644 --- a/packages/website/src/core/blitzkit/skinTree.tsx +++ b/packages/website/src/core/blitzkit/skinTree.tsx @@ -5,24 +5,24 @@ import { jsxTree } from "./jsxTree"; type Mergers = Parameters[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(); +export function skinTree( + node: Object3D, + skins: Skin[], + mergers: NonNullable, +) { const materials = new Map(); const states: Record = {}; 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 ( - - {switchChildren(object3d, partMergers) ?? props.children} - - ); - }, - }; 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 ( - {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) + ) : ( + + ), + ), )} ); diff --git a/packages/website/src/core/blitzkit/switcherStates.ts b/packages/website/src/core/blitzkit/switcherStates.ts index 568675946..f645b9e4a 100644 --- a/packages/website/src/core/blitzkit/switcherStates.ts +++ b/packages/website/src/core/blitzkit/switcherStates.ts @@ -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; + variables?: Record; } 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, preset: string | undefined, attachments: Map, + animate: boolean, ) { const scripts: Record = {}; + const root: Entity = { name: "", children: [] }; const stateNames = new Map(); - const scenarios: { owner: Entity; script: Script }[] = []; + const scenarios: { + owner: Entity; + script: Script; + variables: Map; + }[] = []; const actions: { owner: Entity; action: Action }[] = []; - const states: Record = {}; + // names can repeat, so states are kept per entity + const states = new Map(); + const looping = new Set(); + const finished = new Set(); + const pending: (() => void)[] = []; + const fired = new Set(); 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( 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, + preset: string | undefined, + attachments: Map, +) { + const states: Record = {}; + + 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, + preset: string | undefined, +) { + return replay(sc2, preset, new Map(), true); +} diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts index d7421795c..ce4af4325 100644 --- a/packages/website/src/hooks/useSkin.ts +++ b/packages/website/src/hooks/useSkin.ts @@ -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; + /** customization parts by the tank node they attach to */ + parts: Map; + mixer?: AnimationMixer; } const skins = new Map>(); @@ -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`); } diff --git a/packages/website/src/hooks/useSkinAnimation.ts b/packages/website/src/hooks/useSkinAnimation.ts new file mode 100644 index 000000000..1c686bd28 --- /dev/null +++ b/packages/website/src/hooks/useSkinAnimation.ts @@ -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(); + } + }); +} diff --git a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts index ca5145d1b..0ac78610b 100644 --- a/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts +++ b/packages/website/src/pages/[...chunk_models_legendary]/[id]/skins/[skin].glb.ts @@ -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 diff --git a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts index d0b9d70ef..fdc63216b 100644 --- a/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_models_tanks]/[id]/model.glb.ts @@ -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>(); + const jointBindings: [ + owner: Node, + entity: string, + joint: Node, + skin: Skin, + ][] = []; + const motions = new Map(); + const garage = isPart ? partGarage(sc2, preset) : undefined; + + if (isPart) { + const loading: Promise[] = []; + + 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( 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());