diff --git a/packages/i18n/strings/en.json b/packages/i18n/strings/en.json index c55989b8f..2913ea96f 100644 --- a/packages/i18n/strings/en.json +++ b/packages/i18n/strings/en.json @@ -461,6 +461,18 @@ "sandbox": { "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": { "hull_down": "Hull down", "face_hug": "Face hug", diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinAnimationSwitcher.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinAnimationSwitcher.tsx new file mode 100644 index 000000000..7400dd82c --- /dev/null +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/components/SkinAnimationSwitcher.tsx @@ -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 ( + + {sections.map((section) => ( + + {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 ( + + skinAnimationEvent.dispatch(event)} + /> + + ); + })} + + ))} + + ); +} diff --git a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx index 725ec6803..d36d61023 100644 --- a/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx +++ b/packages/website/src/components/Tankopedia/HeroSection/components/Options/index.tsx @@ -50,6 +50,7 @@ import { SidebarIconButton, SidebarIconGroup, } from "./components/SidebarIconButton"; +import { SkinAnimationSwitcher } from "./components/SkinAnimationSwitcher"; import { SkinSwitcher } from "./components/SkinSwitcher"; import { SpallLinerSwitcher } from "./components/SpallLinerSwitcher"; import { Thicknesses } from "./components/Thicknesses"; @@ -93,6 +94,7 @@ export function Options({ thicknessRange, canvas, skeleton }: OptionsProps) { + @@ -107,6 +109,15 @@ export function TankModel() { /> ); }, + primitive(object, key) { + return ( + + ); + }, }); })} @@ -184,6 +195,15 @@ export function TankModel() { /> ); }, + primitive(object, key) { + return ( + + ); + }, }); })} @@ -265,6 +285,15 @@ export function TankModel() { /> ); }, + primitive(object, key) { + return ( + + ); + }, }); })} diff --git a/packages/website/src/core/blitzkit/skinAnimations/extras.ts b/packages/website/src/core/blitzkit/skinAnimations/extras.ts new file mode 100644 index 000000000..ba5d8725b --- /dev/null +++ b/packages/website/src/core/blitzkit/skinAnimations/extras.ts @@ -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, + ), + ); +} 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/runtime.ts b/packages/website/src/core/blitzkit/skinAnimations/runtime.ts new file mode 100644 index 000000000..4cdc990d6 --- /dev/null +++ b/packages/website/src/core/blitzkit/skinAnimations/runtime.ts @@ -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 & { defaults?: Record }; + connections?: Record< + string, + { pinIn: string; pinOut: string; target: string } + >; +} + +interface Script { + nodes: Record; + variables?: Record; +} + +interface Scenario { + owner: Object3D; + script: Script; + variables: Map; +} + +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( + (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(); + const scenarios: Scenario[] = []; + const actions = new Map(); + const playing = new Map(); + 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(); + let ambient = false; + const events = new Set(); + const internalEvents = new Set(); + const activeStates = new Map(); + const motionSpeeds = new Map(); + // reset whenever the menu starts a new session (a trigger or a replay) + const firedAlarms = new Set(); + const started = new Set(); + 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 + | 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(); + + 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; 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..83cd377d3 --- /dev/null +++ b/packages/website/src/core/blitzkit/skinAnimations/skeleton.ts @@ -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, + 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, + 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; +} diff --git a/packages/website/src/core/blitzkit/skinTree.tsx b/packages/website/src/core/blitzkit/skinTree.tsx index f22336518..ad989de63 100644 --- a/packages/website/src/core/blitzkit/skinTree.tsx +++ b/packages/website/src/core/blitzkit/skinTree.tsx @@ -1,17 +1,18 @@ +import type { ReactNode } from "react"; import type { Material, Object3D } from "three"; import type { Skin } from "../../hooks/useSkin"; import { jsxTree } from "./jsxTree"; -type Mergers = Parameters[1]; +type Mergers = NonNullable[1]> & { + /** renders a customization part as is so its animations play */ + primitive?: (object: Object3D, key: string) => ReactNode; +}; export function skinTree( node: Object3D, skin: Skin | undefined, mergers: Mergers, ) { - const skinNodes = new Map( - skin?.scene.children.map((node) => [node.name, node]), - ); const skinMergers: Mergers = { mesh(mesh, props, key) { const material = skin?.materials.get((mesh.material as Material).name); @@ -23,7 +24,7 @@ export function skinTree( ); }, group(object3d, props, key) { - const skinNode = skinNodes.get(object3d.name); + const parts = skin?.parts.get(object3d.name); const isSwitch = object3d.userData.state !== undefined; const state = skin ? skin.scene.userData.states[object3d.name] @@ -36,7 +37,13 @@ export function skinTree( .filter((child) => child.name === state) .map((child) => jsxTree(child, skinMergers)) : props.children} - {skinNode?.children.map((child) => jsxTree(child, mergers))} + {parts?.map((part) => + mergers.primitive ? ( + mergers.primitive(part, part.uuid) + ) : ( + + ), + )} ); }, diff --git a/packages/website/src/hooks/useSkin.ts b/packages/website/src/hooks/useSkin.ts index 4fc69e7c5..43a377fc5 100644 --- a/packages/website/src/hooks/useSkin.ts +++ b/packages/website/src/hooks/useSkin.ts @@ -1,6 +1,13 @@ import { alias } from "@blitzkit/core"; 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 { TRANSITION_ANIMATION_TIME } from "../components/Tankopedia/HeroSection/components/TankSandbox/components/Lighting"; import { Tankopedia } from "../stores/tankopedia"; @@ -8,6 +15,9 @@ import { Tankopedia } from "../stores/tankopedia"; export interface Skin { scene: Group; materials: Map; + animations: AnimationClip[]; + /** customization parts by the tank node they attach to */ + parts: Map; } const skins = new Map>(); @@ -19,8 +29,26 @@ async function loadSkin(path: string) { const skin = { scene: gltf.scene, materials: new Map(materials.map((material) => [material.name, material])), + animations: gltf.animations, + // captured now because rendering moves the parts out of the scene + 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); return skin; diff --git a/packages/website/src/hooks/useSkinAnimation.ts b/packages/website/src/hooks/useSkinAnimation.ts new file mode 100644 index 000000000..de05cd7bd --- /dev/null +++ b/packages/website/src/hooks/useSkinAnimation.ts @@ -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(); + +export function useSkinAnimation(skin?: Skin) { + const runtime = useRef(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) { + 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(); + }); +} diff --git a/packages/website/src/pages/[...chunk_api]/icons/skin-events/[event].webp.ts b/packages/website/src/pages/[...chunk_api]/icons/skin-events/[event].webp.ts new file mode 100644 index 000000000..cb16d9c58 --- /dev/null +++ b/packages/website/src/pages/[...chunk_api]/icons/skin-events/[event].webp.ts @@ -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 = { + 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}/texture0@2x.packed.webp`)), + 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())); +} diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts b/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts index faf26b002..1ed66dc07 100644 --- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts +++ b/packages/website/src/pages/[...chunk_api]/tanks/[id]/model.glb.ts @@ -8,18 +8,38 @@ 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 { + 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 { vertexAttributeGLTFName, @@ -86,6 +106,42 @@ 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(); + + 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) => { + if (motion) motions.set(hierarchy, motion); + }), + ); + } + + return null; + }); + + await Promise.all(loading); + + for (const component of Object.values( + (sc2["#sceneComponents"] ?? {}) as Record>, + )) { + if (component["comp.typename"] === "ScenarioSingletonComponent") { + scene.setExtras({ scripts: toExtras(component["ssc.scripts"]) }); + } + } + } parseHierarchies( sc2["#hierarchy"], @@ -99,6 +155,41 @@ 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 = 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) => { switch (component["comp.typename"]) { case "LodComponent": @@ -121,18 +212,70 @@ export async function extractModel( } case "StateSwitcherComponent": - node.setExtras({ + addExtras(node, { state: component[`ssc.state${component["ssc.activeState"]}`], }); + + if (isPart) { + addExtras(node, { + stateNames: times( + component["ssc.statesCount"], + (index) => component[`ssc.state${index}`], + ), + }); + } break; + case "ActionComponent": + if (!isPart) break; + + addExtras(node, { + actions: toExtras( + times( + component["ac.actionCount"], + (index) => component[index.toString().padStart(4, "0")], + ), + ), + }); + break; + + case "ScenarioComponent": + if (!isPart) break; + + addExtras(node, { scenario: component["scenario.scriptId"] }); + break; + + case "SkeletonJointBindingComponent": { + 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": { const renderObject = component["rc.renderObj"]; times(renderObject["ro.batchCount"], (batchIndex): void => { 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 batch = renderObject["ro.batches"][batchKey]; @@ -193,10 +336,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); }); @@ -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")); await document.transform(prune({ keepAttributes: true }), dedup()); @@ -226,6 +389,16 @@ export async function extractModel( 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( document: Document, sc2: ReturnType, diff --git a/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts b/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts index bee9b21f9..993dc504b 100644 --- a/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts +++ b/packages/website/src/pages/[...chunk_api]/tanks/[id]/skins/[skin].glb.ts @@ -6,6 +6,7 @@ import { type TankParameters, } from "@blitzkit/core"; import { + Animation, Document, Node, NodeIO, @@ -19,6 +20,7 @@ import type { Vector3Tuple, Vector4Tuple } from "three"; import { SKIN_NUMBER } from "../../../../../constants/skinNumber"; import { api } from "../../../../../core/blitzkit/api"; import { mixStaticPaths } from "../../../../../core/blitzkit/mixStaticPaths"; +import type { SkinMotion } from "../../../../../core/blitzkit/skinAnimations/skeleton"; import { vfs } from "../../../../../core/blitzkit/vfs"; import { getStaticPaths as _getStaticPaths } from "../../../_index"; import { extractMaterials, extractModel } from "../model.glb"; @@ -166,7 +168,26 @@ export async function GET({ props }: APIContext<{ skin: number }>) { .createNode(name) .setTranslation(slot.position) .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); if (!target) { diff --git a/packages/website/src/stores/tankopedia.ts b/packages/website/src/stores/tankopedia.ts index 6685a419e..979199b81 100644 --- a/packages/website/src/stores/tankopedia.ts +++ b/packages/website/src/stores/tankopedia.ts @@ -104,6 +104,10 @@ interface Tankopedia { xpMultiplier: (typeof XP_MULTIPLIERS)[number]; customShell?: ShellDefinition; skin?: number; + /** events the selected camo's animations can be triggered with */ + skinEvents: string[]; + /** a triggered camo animation is still playing */ + skinAnimating: boolean; requestedDisplay: TankopediaDisplay; display: TankopediaDisplay; statSearch?: string; @@ -120,6 +124,8 @@ export const Tankopedia = new Varuna((model) => ({ skills: createDefaultSkills(skillDefinitions), model, xpMultiplier: 1, + skinEvents: [], + skinAnimating: false, requestedDisplay: TankopediaDisplay.Model, display: TankopediaDisplay.Model, }));