diff --git a/apps/web/tf2/src/runtime.ts b/apps/web/tf2/src/runtime.ts index 688adce7b..794c3ad8e 100644 --- a/apps/web/tf2/src/runtime.ts +++ b/apps/web/tf2/src/runtime.ts @@ -12,6 +12,7 @@ import { APPLICATION_BUILD as __PLAYSRC_APPLICATION_BUILD__, WASM_SHA256 as __PL import { TF2_PRESENTATION_SCHEMA, Tf2WorkerClient, Tf2WorkerError, mergePublicationSnapshots, type CoverageSample, type LoadedGame, type ResourceConfiguration, type SimulationPublication, type VisibilityResult } from "@playsrc/game-tf2-browser" import { Tf2EquipmentProfile, Tf2EquipmentPresentation, equippedWeaponSlots, equipmentPipelinePoseRequests, type Tf2EquipmentPreview } from "@playsrc/game-tf2-browser/equipment" import { selectionTransitionMark, selectionTransitionDraw } from "./selection-transition-profile" +import { teamModelPlayback, TeamModelSkinning, type TeamModelPlayback } from "./team-model-playback" import { initializeTf2GameUiIntegration, type Tf2GameUiIntegration } from "@playsrc/game-tf2-browser/gameui-integration" import type { Tf2GameUiRequest, Tf2LoadingPhase } from "@playsrc/game-tf2-browser/gameui" import { @@ -478,7 +479,10 @@ export class Tf2Application { #teamSelectionRenderRevision = 0 #pendingClassSelectionTeam?: 2 | 3 readonly #teamSelectionPoses = new Map() - readonly #teamSelectionAnimations = new Map>() + readonly #teamSelectionAnimations = new Map() + readonly #teamSelectionSkinning = new TeamModelSkinning() + #teamSelectionUpdateTask: Promise | undefined + #teamSelectionUpdateTime = 0 #teamAdmission?: Readonly<{ generation: number; resolve(): void; reject(error: Error): void }> #hudRootCounts?: Readonly<{ playerStatus: number; ammo: number }> #hudContext?: SessionHudContext @@ -1969,6 +1973,7 @@ export class Tf2Application { this.#pendingProjectileTimeline = [] this.#teamSelectionPoses.clear() this.#teamSelectionAnimations.clear() + this.#teamSelectionSkinning.clear() this.#pendingClassSelectionTeam = undefined this.#pendingPresentation = undefined this.#preparedPresentation = undefined @@ -2468,6 +2473,7 @@ export class Tf2Application { this.#classSelection?.dispatch({ kind: "hide" }) this.#neutral() if (document.pointerLockElement === this.#canvas) await document.exitPointerLock() + this.#teamSelectionUpdateTime = 0 this.#teamSelection.dispatch({ kind: "show", server }) } @@ -2484,25 +2490,24 @@ export class Tf2Application { const authored = this.#teamSelectionModelPanels const viewport = this.#viewport() this.#teamSelectionRenderTask = (async () => { - const requests = authored.flatMap((panel, index) => { - if (panel.sequence === "idle") return [] + const requests = authored.flatMap((panel) => { const artifact = artifacts.models.get(panel.model.toLowerCase()) const timing = artifact?.sequences.find((sequence) => sequence.label.toLowerCase() === panel.sequence.toLowerCase()) if (!artifact || !timing) throw new Error(`TF2 authored team-door sequence is unavailable: ${panel.model}:${panel.sequence}`) - const prior = this.#teamSelectionAnimations.get(panel.name) - const current = prior?.sequence === panel.sequence - ? prior - : Object.freeze({ sequence: panel.sequence, startedSeconds: now, previousSeconds: 0 }) - const elapsed = Math.max(0, now - current.startedSeconds) - if (elapsed > timing.durationSeconds && this.#teamSelectionPoses.has(panel.name)) return [] + const step = teamModelPlayback(this.#teamSelectionAnimations.get(panel.name), panel, now, timing.durationSeconds) + this.#teamSelectionAnimations.set(panel.name, step.state) + if (!step.sample) return [] const request = Object.freeze({ - identity: 0x1000 + index, + identity: 0x1000 + panel.panelId, + entityModelPanel: true, + entityAnimationRevision: panel.animationRevision, + modelPanelReset: step.reset, model: panel.model.toLowerCase(), activity: panel.sequence, - previousElapsedSeconds: Math.min(current.previousSeconds, elapsed), - elapsedSeconds: elapsed, + previousElapsedSeconds: step.previousElapsed, + elapsedSeconds: step.elapsed, currentTimeSeconds: now, - frameTimeSeconds: Math.max(0, elapsed - current.previousSeconds), + frameTimeSeconds: step.frameTime, planarSpeed: 0, screenAspectRatio: viewport.width / viewport.height, worldFarPlane: 1000, @@ -2510,20 +2515,21 @@ export class Tf2Application { lod: 0, bodygroups: Object.freeze(artifact.bodygroupCounts.map(() => 0)), }) - this.#teamSelectionAnimations.set(panel.name, Object.freeze({ ...current, previousSeconds: elapsed })) - return [Object.freeze({ panel: panel.name, request })] + return [Object.freeze({ panel: panel.name, state: step.state, request })] }) if (requests.length > 0) { const posed = await client.models(generation, encodeModelPoseBatch(requests.map((value) => value.request))) - if (generation !== this.#generation || !this.#teamSelection?.state().visible) return + if (generation !== this.#generation || revision !== this.#teamSelectionRenderRevision || !this.#teamSelection?.state().visible) return for (const item of posed) { const selected = requests.find((candidate) => candidate.request.identity === item.identity) if (!selected) throw new Error("TF2 team-door pose identity differs from its authored request") this.#teamSelectionPoses.set(selected.panel, item) + this.#teamSelectionSkinning.observe(selected.panel, item) + this.#teamSelectionAnimations.set(selected.panel, { ...selected.state, sampledSeconds: selected.request.elapsedSeconds, transitioning: item.transitioning }) } } const panels: readonly ModelPanelPass[] = authored.map((panel, index) => { - const pose = this.#teamSelectionPoses.get(panel.name) + const pose = this.#teamSelectionSkinning.needsPose(panel.name) ? this.#teamSelectionPoses.get(panel.name) : undefined return Object.freeze({ identity: panel.name, model: panel.model, @@ -2540,6 +2546,15 @@ export class Tf2Application { }) const result = await renderer.renderModelPanels(panels) if (generation !== this.#generation || revision !== this.#teamSelectionRenderRevision) return + const doorProfile = (globalThis as any).__playsrcProfile + if (doorProfile?.captureTeamDoors === true) { + const frames = doorProfile.teamDoorFrames ??= [] + if (frames.length < 4096) frames.push({ at: performance.now(), now, milliseconds: result.milliseconds, + panels: authored.map(panel => ({ name: panel.name, animation: panel.animation, sequence: panel.sequence, + timing: this.#teamSelectionAnimations.get(panel.name), + cycle: this.#teamSelectionPoses.get(panel.name)?.cycle, + matrices: Array.from(this.#teamSelectionPoses.get(panel.name)?.boneMatrices ?? []) })) }) + } this.#set({ teamSelectionModels: result.panels.map((panel) => `${panel.identity}:${panel.model}:${panel.skin}:${panel.primitives}`).join("|") }) })().catch((error) => { if (generation !== this.#generation || !this.#teamSelection?.state().visible) return @@ -2547,6 +2562,20 @@ export class Tf2Application { }).finally(() => { this.#teamSelectionRenderTask = undefined }) } + #updateTeamSelection(timeSeconds: number): void { + if (!this.#teamSelection?.state().visible || !this.#client || this.#teamSelectionUpdateTask + || timeSeconds < this.#teamSelectionUpdateTime) return + this.#teamSelectionUpdateTime = timeSeconds + 0.1 + const generation = this.#generation, epoch = this.#selectionViewportEpoch + this.#teamSelectionUpdateTask = this.#client.teamSelection(generation).then(server => { + if (generation !== this.#generation || epoch !== this.#selectionViewportEpoch || !this.#teamSelection?.state().visible) return + if (JSON.stringify(server) !== JSON.stringify(this.#teamSelection.state().server)) this.#teamSelection.dispatch({ kind: "update", server }) + }).catch(error => { + if (generation === this.#generation && epoch === this.#selectionViewportEpoch && this.#teamSelection?.state().visible) + this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "TF2 team state update failed") }) + }).finally(() => { this.#teamSelectionUpdateTask = undefined }) + } + #hudPresentationObservation( observation?: ReturnType, currentBinding?: Tf2HudBinding, @@ -4629,7 +4658,10 @@ export class Tf2Application { if (this.#view.localMatchVisible) this.#localMatch?.frame(timeSeconds) if (this.#equipment?.visible()) { this.#equipment.frame(timeSeconds); this.#renderEquipment() } if (this.#classSelection?.state().visible) this.#classSelection.frame(timeSeconds) - if (this.#teamSelection?.state().visible) this.#teamSelection.frame(timeSeconds) + if (this.#teamSelection?.state().visible) { + this.#updateTeamSelection(timeSeconds) + this.#teamSelection.frame(timeSeconds) + } } catch (error) { this.#paused = true this.#set({ phase: "Failed", gameUi: "failure", detail: this.#failureDetail(error, "VGUI frame failed") }) diff --git a/apps/web/tf2/src/team-model-playback.ts b/apps/web/tf2/src/team-model-playback.ts new file mode 100644 index 000000000..f3fa4e058 --- /dev/null +++ b/apps/web/tf2/src/team-model-playback.ts @@ -0,0 +1,55 @@ +export type TeamModelPlayback = Readonly<{ + modelRevision: number + animationRevision: number + sequence: string + startedSeconds: number + previousSeconds: number + lastPaintSeconds: number + sampledSeconds: number | null + transitioning: boolean +}> + +/** Templates already contain sequence zero at cycle zero. Keep those pixels + * and their static GPU pipeline until an actual pose differs; once deformed, + * idle still has to restore the retained skinned instance. */ +export class TeamModelSkinning { + readonly #templates = new Map() + readonly #deformed = new Set() + + observe(name: string, pose: Readonly<{ sequence: number; cycle: number; boneMatrices: Float32Array; flex: readonly unknown[] }>): void { + if (this.#deformed.has(name)) return + const template = this.#templates.get(name) + if (!template && pose.sequence === 0 && pose.cycle === 0 && pose.flex.length === 0) { + this.#templates.set(name, pose.boneMatrices) + return + } + if (!template || pose.flex.length !== 0 || template.length !== pose.boneMatrices.length + || template.some((value, index) => value !== pose.boneMatrices[index])) this.#deformed.add(name) + } + + needsPose(name: string): boolean { return this.#deformed.has(name) } + clear(): void { this.#templates.clear(); this.#deformed.clear() } +} + +/** Presentation scheduling only; the studio owner resolves cycle and pose. */ +export function teamModelPlayback( + prior: TeamModelPlayback | undefined, + panel: Readonly<{ modelRevision: number; animationRevision: number; sequence: string }>, + now: number, + duration: number, +): Readonly<{ state: TeamModelPlayback; elapsed: number; previousElapsed: number; frameTime: number; sample: boolean; reset: boolean }> { + const reset = !prior || prior.modelRevision !== panel.modelRevision + const changed = reset || prior?.animationRevision !== panel.animationRevision + const current: TeamModelPlayback = changed + ? { ...panel, startedSeconds: reset ? now : prior!.lastPaintSeconds, previousSeconds: 0, lastPaintSeconds: now, sampledSeconds: null, transitioning: false } + : prior! + const elapsed = Math.min(Math.max(0, now - current.startedSeconds), duration) + return { + state: { ...current, previousSeconds: elapsed, lastPaintSeconds: now }, + elapsed, + previousElapsed: Math.min(current.previousSeconds, elapsed), + frameTime: Math.max(0, now - (prior?.lastPaintSeconds ?? now)), + sample: current.sampledSeconds === null || current.sampledSeconds < duration || current.transitioning, + reset, + } +} diff --git a/apps/web/tf2/tests/team-model-playback.test.ts b/apps/web/tf2/tests/team-model-playback.test.ts new file mode 100644 index 000000000..e1fdc759c --- /dev/null +++ b/apps/web/tf2/tests/team-model-playback.test.ts @@ -0,0 +1,52 @@ +import { expect, test } from "bun:test" +import { teamModelPlayback, TeamModelSkinning } from "../src/team-model-playback" + +const panel = { sequence: "hoveropen", modelRevision: 1, animationRevision: 1 } +test("samples exact terminal pose even when a render interval crosses its duration", () => { + const initial = teamModelPlayback(undefined, panel, 1, 30 / 65) + const middle = { ...initial.state, sampledSeconds: 0.1, previousSeconds: 0.1 } + const terminal = teamModelPlayback(middle, panel, 2, 30 / 65) + expect(terminal.sample).toBe(true) + expect(terminal.elapsed).toBe(30 / 65) + expect(teamModelPlayback({ ...terminal.state, sampledSeconds: terminal.elapsed }, panel, 3, 30 / 65).sample).toBe(false) +}) +test("idle, same-sequence commands, interrupted changes and recreated panels cannot reuse an old endpoint", () => { + const done = { ...teamModelPlayback(undefined, panel, 1, 1).state, sampledSeconds: 1 } + for (const next of [ + { ...panel, sequence: "idle", animationRevision: 2 }, + { ...panel, animationRevision: 2 }, + { ...panel, modelRevision: 2 }, + ]) { + const step = teamModelPlayback(done, next, 1.02, 1) + expect(step.sample).toBe(true) + expect(step.elapsed).toBeCloseTo(next.modelRevision === panel.modelRevision ? 0.02 : 0, 8) + expect(step.previousElapsed).toBe(0) + } + expect(teamModelPlayback(undefined, { ...panel, sequence: "idle" }, 0, 0).sample).toBe(true) +}) + +test("short sequences keep sampling until the studio transition owner has finished fading", () => { + const hover = { ...panel, sequence: "hover" } + const initial = teamModelPlayback(undefined, hover, 1, 5 / 30) + const ended = { ...initial.state, sampledSeconds: 5 / 30, transitioning: true } + expect(teamModelPlayback(ended, hover, 1.18, 5 / 30).sample).toBe(true) + expect(teamModelPlayback({ ...ended, transitioning: false }, hover, 1.21, 5 / 30).sample).toBe(false) +}) + +test("retains exact template pixels until deformation and restores idle through the same skinned owner", () => { + const state = new TeamModelSkinning() + const idle = { sequence: 0, cycle: 0, boneMatrices: Float32Array.of(1, 0, 0, 2), flex: [] } + state.observe("door", idle) + expect(state.needsPose("door")).toBe(false) + state.observe("door", { ...idle, cycle: 1, boneMatrices: idle.boneMatrices.slice() }) + expect(state.needsPose("door")).toBe(false) + state.observe("door", { ...idle, sequence: 1, boneMatrices: Float32Array.of(0.5, 0, 0, 2) }) + expect(state.needsPose("door")).toBe(true) + state.observe("door", idle) + expect(state.needsPose("door")).toBe(true) + state.observe("disabled-first", { ...idle, sequence: 1 }) + expect(state.needsPose("disabled-first")).toBe(true) + state.clear() + state.observe("door", idle) + expect(state.needsPose("door")).toBe(false) +}) diff --git a/games/tf2/browser/src/presentation.ts b/games/tf2/browser/src/presentation.ts index ed6efdda1..52d75e9ee 100644 --- a/games/tf2/browser/src/presentation.ts +++ b/games/tf2/browser/src/presentation.ts @@ -334,6 +334,8 @@ export type ModelPoseRequest = Readonly<{ classSelection?: boolean controlPoint?: number modelPanel?: boolean + entityModelPanel?: boolean + entityAnimationRevision?: number modelPanelReset?: boolean cloak?: ActorCloakState actorIdentity?: number @@ -390,6 +392,7 @@ export type PosedModel = Readonly<{ cyclesPerSecond: number durationSeconds: number looping: boolean + transitioning: boolean previousCycle: number cycle: number boneMatrices: Float32Array @@ -430,9 +433,12 @@ export function encodeModelPoseBatch(requests: readonly ModelPoseRequest[]): Uin || (request.hudModel && (!request.modelPanel || !request.actorIdentity)) || (request.worldItem !== undefined && typeof request.worldItem !== "boolean") || (request.modelPanel !== undefined && typeof request.modelPanel !== "boolean") + || (request.entityModelPanel !== undefined && typeof request.entityModelPanel !== "boolean") + || (Boolean(request.entityModelPanel) !== (request.entityAnimationRevision !== undefined)) + || (request.entityModelPanel && (request.modelPanel || request.classSelection || request.itemModel || request.phase !== undefined || request.cloak || request.worldItem || request.handsOnlyViewmodel || request.controlPoint !== undefined || request.actorIdentity !== undefined || request.itemDefinition !== undefined || request.packedBody !== undefined)) || (request.modelPanelReset !== undefined && typeof request.modelPanelReset !== "boolean") || ((request.classSelection || request.modelPanel) && ((request.itemModel !== undefined && (request.classSelection || !request.worldItem)) || request.handsOnlyViewmodel || request.phase !== undefined || request.cloak !== undefined)) - || (request.modelPanelReset && !request.classSelection && !request.modelPanel)) { + || (request.modelPanelReset && !request.classSelection && !request.modelPanel && !request.entityModelPanel)) { throw new ProjectilePresentationError("MalformedFact", "model panel pose request") } if (!Number.isSafeInteger(request.identity) || request.identity < 1 || !request.model || !request.activity || @@ -445,7 +451,7 @@ export function encodeModelPoseBatch(requests: readonly ModelPoseRequest[]): Uin const cloak = request.cloak if (cloak && (!Number.isSafeInteger(cloak.identity) || cloak.identity < 1 || cloak.identity > 0xffff_ffff)) throw new ProjectilePresentationError("MalformedFact", "model actor identity") if (request.controlPoint !== undefined && (!Number.isSafeInteger(request.controlPoint) || request.controlPoint <= 0 || request.controlPoint > 0xffff_ffff || request.actorIdentity !== undefined || cloak || request.itemModel || request.classSelection || request.modelPanel)) throw new ProjectilePresentationError("MalformedFact", "control point pose identity") - const actorIdentity = request.actorIdentity ?? cloak?.identity ?? 0 + const actorIdentity = request.entityAnimationRevision ?? request.actorIdentity ?? cloak?.identity ?? 0 if (!Number.isSafeInteger(actorIdentity) || actorIdentity < 0 || actorIdentity > UINT32_MAX || (cloak && cloak.identity !== actorIdentity)) throw new ProjectilePresentationError("MalformedFact", "model actor identity") view.setUint32(at, request.controlPoint ?? actorIdentity, true); at += 4 for (const value of cloak ? [cloak.localFactor, cloak.worldFactor, cloak.rawFactor, ...cloak.playerTint] : [0, 0, 0, 0, 0, 0]) { @@ -462,7 +468,7 @@ export function encodeModelPoseBatch(requests: readonly ModelPoseRequest[]): Uin throw new ProjectilePresentationError("MalformedFact", "model pose sample") } view.setBigUint64(at, sampleTick, true); at += 8 - bytes[at] = request.controlPoint !== undefined ? 7 : request.classSelection ? 3 : request.modelPanel ? request.worldItem ? 6 : 4 : request.worldItem ? 5 : request.handsOnlyViewmodel ? 2 : request.itemModel === undefined ? 0 : 1 + bytes[at] = request.entityModelPanel ? 8 : request.controlPoint !== undefined ? 7 : request.classSelection ? 3 : request.modelPanel ? request.worldItem ? 6 : 4 : request.worldItem ? 5 : request.handsOnlyViewmodel ? 2 : request.itemModel === undefined ? 0 : 1 bytes[at + 1] = Number(request.attachmentsOnly ?? false) bytes[at + 2] = Number(request.fireView !== undefined) bytes[at + 3] = Number(request.modelPanelReset ?? false) | (Number(cloak !== undefined) << 1) | (Number(request.preparation ?? false) << 2) | (Number(request.hudModel ?? false) << 3) @@ -588,8 +594,8 @@ export function decodeModelPoseOutput(bytes: Uint8Array): readonly PosedModel[] wearable = Object.freeze({ itemId, effect, controlPoint, particleBytes }) } const model = text(), activity = text(), sequence = u32(), framesPerSecond = f32(), weightedFrameCount = f32(), - cyclesPerSecond = f32(), durationSeconds = f32(), looping = u8() - if (looping > 1 || u8() || u8() || u8()) throw new ProjectilePresentationError("MalformedFact", "model pose timing") + cyclesPerSecond = f32(), durationSeconds = f32(), looping = u8(), transitioning = u8() + if (looping > 1 || transitioning > 1 || u8() || u8()) throw new ProjectilePresentationError("MalformedFact", "model pose timing") const previousCycle = f32(), cycle = f32() const present=u8();if(present>1||u8()||u8()||u8())throw new ProjectilePresentationError("MalformedFact","viewmodel state");const values=present===1?new Array(15):null;for(let index=0;index<15;index+=1){const value=f32();if(values)values[index]=value}const passRestored=u8(),depthRestored=u8(),itemTranslucent=u8();if(u8())throw new ProjectilePresentationError("MalformedFact","viewmodel flags") const phase=u8(),drawDisposition=u8(),suppression=u8(),reflected=u8(),frontFace=u8(),cullFace=u8(),restoredCull=u8(),reserved=u8() @@ -688,7 +694,7 @@ export function decodeModelPoseOutput(bytes: Uint8Array): readonly PosedModel[] return Object.freeze({ primitive, indices, positions, normals }) })) if (attachmentMode === 1 && (primitives.length !== 0 || eyes.length !== 0 || flex.length !== 0)) throw new ProjectilePresentationError("MalformedFact", "attachment-only pose contains geometry") - output.push(Object.freeze({ identity, cloak, sampleTick, attachmentsOnly: attachmentMode === 1, attachmentsWorld: attachmentsWorld === 1, role: (["single", "hand", "item", "wearable"] as const)[roleCode]!, wearable, model, activity, sequence, framesPerSecond, weightedFrameCount, cyclesPerSecond, durationSeconds, looping: looping === 1, previousCycle, cycle, boneMatrices, events, primitives, attachments, lighting, eyes, flex, viewmodel })) + output.push(Object.freeze({ identity, cloak, sampleTick, attachmentsOnly: attachmentMode === 1, attachmentsWorld: attachmentsWorld === 1, role: (["single", "hand", "item", "wearable"] as const)[roleCode]!, wearable, model, activity, sequence, framesPerSecond, weightedFrameCount, cyclesPerSecond, durationSeconds, looping: looping === 1, transitioning: transitioning === 1, previousCycle, cycle, boneMatrices, events, primitives, attachments, lighting, eyes, flex, viewmodel })) } if (at !== bytes.length) throw new ProjectilePresentationError("MalformedFact", "model pose output trailing bytes") return Object.freeze(output) diff --git a/games/tf2/browser/src/team-selection/model.ts b/games/tf2/browser/src/team-selection/model.ts index f9ea9c6f9..89287b5fb 100644 --- a/games/tf2/browser/src/team-selection/model.ts +++ b/games/tf2/browser/src/team-selection/model.ts @@ -124,8 +124,8 @@ export function transitionTf2TeamSelection( if (!state.visible || !state.server) return outcome("ignored", state) if (event.kind === "update") { if (!validServer(event.server)) return outcome("illegal", state) - const focused = state.focused && available(event.server, state.focused) ? state.focused : initialFocus(event.server) - const hovered = state.hovered && available(event.server, state.hovered) ? state.hovered : null + const focused = state.focused && (state.focused === "red" || state.focused === "blue" || available(event.server, state.focused)) ? state.focused : initialFocus(event.server) + const hovered = state.hovered && (state.hovered === "red" || state.hovered === "blue" || available(event.server, state.hovered)) ? state.hovered : null return outcome("applied", Object.freeze({ ...state, server: event.server, focused, hovered })) } if (event.kind === "cancel") { @@ -139,11 +139,12 @@ export function transitionTf2TeamSelection( if (event.team === state.hovered) return outcome("ignored", state) return outcome("applied", Object.freeze({ ...state, hovered: event.team })) } - if (!available(state.server, event.team)) return outcome("ignored", state) if (event.kind === "focus") { + if ((event.team === "auto" || event.team === "spectate") && !available(state.server, event.team)) return outcome("ignored", state) if (event.team === state.focused) return outcome("ignored", state) return outcome("applied", Object.freeze({ ...state, focused: event.team })) } + if (!available(state.server, event.team)) return outcome("ignored", state) const sameTeam = event.team === "red" && state.server.localTeam === 2 || event.team === "blue" && state.server.localTeam === 3 || event.team === "spectate" && state.server.localTeam === 1 diff --git a/games/tf2/browser/src/team-selection/runtime.ts b/games/tf2/browser/src/team-selection/runtime.ts index 76d03bb93..83f86bc5b 100644 --- a/games/tf2/browser/src/team-selection/runtime.ts +++ b/games/tf2/browser/src/team-selection/runtime.ts @@ -58,6 +58,7 @@ const LIMITS: VguiRuntimeLimits = Object.freeze({ export type Tf2TeamSelectionModelPanel = Readonly<{ name: typeof MODEL_NAMES[number] + panelId: VguiPanelId model: string skin: number fov: number @@ -65,6 +66,8 @@ export type Tf2TeamSelectionModelPanel = Readonly<{ angles: readonly [number, number, number] animation: string sequence: string + animationRevision: number + modelRevision: number bounds: Readonly<{ x: number; y: number; width: number; height: number }> }> @@ -138,6 +141,14 @@ class Integration implements Tf2TeamSelectionIntegration { readonly #onRequest: Tf2TeamSelectionIntegrationRequest["onRequest"] readonly #onModelPanels: Tf2TeamSelectionIntegrationRequest["onModelPanels"] readonly #animations = new Map() + readonly #animationRevisions = new Map() + readonly #entered = new Set() + readonly #hoverDeadlines = new Map() + readonly #disabled = new Map() + readonly #clock: Tf2TeamSelectionIntegrationRequest["clock"] + #modelRevision = 0 + #animationRevision = 0 + #nextTick = 0 #owner = 0 #state = TF2_TEAM_SELECTION_INITIAL_STATE #destroyed = false @@ -147,6 +158,8 @@ class Integration implements Tf2TeamSelectionIntegration { this.#source = request.resources.document(TEAM_SELECTION_PATH) this.#onRequest = request.onRequest this.#onModelPanels = request.onModelPanels + this.#clock = request.clock + this.#nextTick = Math.floor(request.clock.nowSeconds() * 1000) + 100 const initialized = initializeVguiRuntime({ runtimeIdentity: "tf2-team-selection", root: request.root, @@ -234,8 +247,52 @@ class Integration implements Tf2TeamSelectionIntegration { if (this.#state.visible) this.dispatch({ kind: "hover", team: null }) } - #present(previous: Tf2TeamSelectionState): void { + #setAnimation(name: string, animation: string): void { + const authored = this.#source.root.children.find(node => node.name === name) + const model = authored && object(authored, "model") + if (!model?.children.some(child => child.name.toLowerCase() === "animation" && scalar(child, "name") === animation)) return + this.#animations.set(name, animation) + this.#animationRevision += 1 + this.#animationRevisions.set(name, ((this.#animationRevisions.get(name) ?? 0) + 1) >>> 0) + } + + #enter(team: Tf2TeamChoice, entered: boolean): void { + this.#hoverDeadlines.delete(team) + if (entered) { + this.#entered.add(team) + const button = this.#source.root.children.find(node => node.name === BUTTON_NAMES[team]) + const delay = button ? Number(scalar(button, "hover") ?? -1) : -1 + if (delay > 0) this.#hoverDeadlines.set(team, Math.fround(Math.fround(this.#clock.nowSeconds()) + delay)) + } else this.#entered.delete(team) + this.#setAnimation(this.#modelName(team), `${entered ? "enter" : "exit"}_${this.#disabled.get(team) ? "disabled" : "enabled"}`) + } + + #tickButtons(time: number): void { + const server = this.#state.server + if (!server) return + for (const team of TAB_ORDER) { + const disabled = team === "red" ? server.redDisabled : team === "blue" ? server.blueDisabled : false + if (disabled !== (this.#disabled.get(team) ?? false)) { + this.#disabled.set(team, disabled) + if (this.#entered.has(team)) this.#enter(team, true) + else this.#setAnimation(this.#modelName(team), disabled ? "idle_disabled" : "idle_enabled") + } + const deadline = this.#hoverDeadlines.get(team) + if (deadline !== undefined && deadline < Math.fround(time)) { + this.#hoverDeadlines.delete(team) + this.#setAnimation(this.#modelName(team), disabled ? "hover_disabled" : "hover_enabled") + } + } + } + + #present(previous: Tf2TeamSelectionState, event: Tf2TeamSelectionEvent): void { const state = this.#state + if (state.visible && !previous.visible) { + this.#modelRevision += 1 + this.#entered.clear() + this.#hoverDeadlines.clear() + for (const name of MODEL_NAMES) this.#setAnimation(name, "idle_enabled") + } this.#runtime.deferPresentation(() => { apply(this.#runtime, { kind: "set-panel-state", panel: 1, visible: state.visible }) apply(this.#runtime, { kind: "set-panel-state", panel: this.#owner, visible: state.visible }) @@ -266,22 +323,19 @@ class Integration implements Tf2TeamSelectionIntegration { const button = find(this.#runtime, BUTTON_NAMES[team], this.#owner)! apply(this.#runtime, { kind: "set-panel-state", panel: button, enabled: true }) this.#root.querySelector?.(`[data-vgui-name="${BUTTON_NAMES[team]}"]`)?.setAttribute("aria-disabled", String(disabled)) - const name = team === "red" ? "reddoor" : "bluedoor" - const beforeDisabled = team === "red" ? previous.server?.redDisabled : previous.server?.blueDisabled - if (disabled !== beforeDisabled) this.#animations.set(name, disabled ? "idle_disabled" : "idle_enabled") } if (previous.hovered !== state.hovered) { if (previous.hovered) { - const name = this.#modelName(previous.hovered) - const disabled = previous.hovered === "red" ? server.redDisabled : previous.hovered === "blue" ? server.blueDisabled : false - this.#animations.set(name, disabled ? "exit_disabled" : "exit_enabled") + this.#enter(previous.hovered, false) } if (state.hovered) { - const name = this.#modelName(state.hovered) - const disabled = state.hovered === "red" ? server.redDisabled : state.hovered === "blue" ? server.blueDisabled : false - this.#animations.set(name, disabled ? "enter_disabled" : "enter_enabled") + this.#enter(state.hovered, true) } } + if (event.kind === "focus" && previous.focused !== state.focused) { + if (previous.focused) this.#enter(previous.focused, false) + if (state.focused) this.#enter(state.focused, true) + } }) if (state.focused && previous.focused !== state.focused) { this.#root.querySelector?.(`[data-vgui-name="${BUTTON_NAMES[state.focused]}"]`)?.focus?.() @@ -325,6 +379,7 @@ class Integration implements Tf2TeamSelectionIntegration { && child.value === null && scalar(child, "name") === animation) return [Object.freeze({ name, + panelId: snapshot.id, model: scalar(model, "modelname") ?? "", skin: authoredNumber(model, "skin"), fov: authoredNumber(authored, "fov"), @@ -332,6 +387,8 @@ class Integration implements Tf2TeamSelectionIntegration { angles: Object.freeze([authoredNumber(model, "angles_x"), authoredNumber(model, "angles_y"), authoredNumber(model, "angles_z")]) as readonly [number, number, number], animation, sequence: authoredAnimation ? scalar(authoredAnimation, "sequence") ?? "" : "idle", + animationRevision: this.#animationRevisions.get(name) ?? 0, + modelRevision: this.#modelRevision, bounds: snapshot.bounds, })] })) @@ -343,7 +400,7 @@ class Integration implements Tf2TeamSelectionIntegration { const transition = transitionTf2TeamSelection(previous, event) if (transition.disposition !== "applied") return transition this.#state = transition.state - this.#present(previous) + this.#present(previous, event) if (transition.request) this.#onRequest(transition.request) return transition } @@ -386,6 +443,12 @@ class Integration implements Tf2TeamSelectionIntegration { if (this.#state.visible) { apply(this.#runtime, { kind: "frame", timeSeconds }) this.#syncAccessibility() + if (Math.floor(timeSeconds * 1000) >= this.#nextTick) { + this.#nextTick = Math.floor(timeSeconds * 1000) + 100 + const before = this.#animationRevision + this.#tickButtons(timeSeconds) + if (before !== this.#animationRevision) this.#onModelPanels(this.modelPanels()) + } } } diff --git a/games/tf2/browser/tests/team-selection/pose-request.test.ts b/games/tf2/browser/tests/team-selection/pose-request.test.ts new file mode 100644 index 000000000..c8cd6799e --- /dev/null +++ b/games/tf2/browser/tests/team-selection/pose-request.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test" +import { encodeModelPoseBatch, type ModelPoseRequest } from "../../src/presentation" + +const request: ModelPoseRequest = { + identity: 4098, model: "models/vgui/ui_team01_red.mdl", activity: "hoveropen", + entityModelPanel: true, entityAnimationRevision: 1, modelPanelReset: true, + previousElapsedSeconds: 0, elapsedSeconds: 0, currentTimeSeconds: 1, frameTimeSeconds: 0.015, + planarSpeed: 0, screenAspectRatio: 16 / 9, worldFarPlane: 1000, skin: 0, lod: 0, bodygroups: [0], +} +test("entity panel animation requests have a distinct owner without changing other model panels", () => { + const bytes = encodeModelPoseBatch([request]) + expect(bytes[52]).toBe(8) + expect(bytes[55]).toBe(1) + expect(encodeModelPoseBatch([{ ...request, entityModelPanel: false, entityAnimationRevision: undefined, modelPanel: true }])[52]).toBe(4) + expect(encodeModelPoseBatch([{ ...request, entityModelPanel: false, entityAnimationRevision: undefined, modelPanelReset: false }])[52]).toBe(0) + expect(new DataView(bytes.buffer).getUint32(16, true)).toBe(1) + expect(() => encodeModelPoseBatch([{ ...request, modelPanel: true }])).toThrow() + expect(() => encodeModelPoseBatch([{ ...request, actorIdentity: 1 }])).toThrow() +}) diff --git a/games/tf2/browser/tests/team-selection/runtime.test.ts b/games/tf2/browser/tests/team-selection/runtime.test.ts index c8fbbb8bc..a46aaccf4 100644 --- a/games/tf2/browser/tests/team-selection/runtime.test.ts +++ b/games/tf2/browser/tests/team-selection/runtime.test.ts @@ -9,6 +9,7 @@ import { } from "../../src/team-selection" function fixture(overrides: Partial = {}) { + let now = 0 const base = createTf2GameUiTransitionFixture() const requests: Tf2TeamSelectionRequest[] = [] const models: (readonly Tf2TeamSelectionModelPanel[])[] = [] @@ -22,16 +23,61 @@ function fixture(overrides: Partial = {}) { resources: base.resources, viewport: { width: 1280, height: 720, devicePixelRatio: 1 }, reducedMotion: true, - clock: { nowSeconds: () => 0 }, + clock: { nowSeconds: () => now }, random: { nextUnit: () => 0 }, onRequest: (request) => requests.push(request), onModelPanels: (panels) => models.push(panels), }) integration.dispatch({ kind: "show", server }) - return Object.freeze({ ...base, server, integration, requests, models }) + now = 0.1 + integration.frame(now) + return Object.freeze({ ...base, server, integration, requests, models, frame(time: number) { now = time; integration.frame(time) } }) } describe("authored TF2 RED/BLU team-selection VGUI", () => { + test("recreates authored default animations when reopening a hovered door", () => { + const { integration, server } = fixture() + integration.dispatch({ kind: "hover", team: "red" }) + integration.dispatch({ kind: "hide" }) + integration.dispatch({ kind: "show", server }) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("idle") + }) + + test("disabled doors play the authored delayed fullhover and retain hover across rule updates", () => { + const { integration, server, frame } = fixture() + integration.dispatch({ kind: "hover", team: "red" }) + integration.dispatch({ kind: "update", server: { ...server, redDisabled: true } }) + expect(integration.state().hovered).toBe("red") + frame(0.2) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.animation).toBe("enter_disabled") + frame(2.2) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullidle") + frame(2.3) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullhover") + integration.dispatch({ kind: "hover", team: null }) + expect(integration.modelPanels().find(panel => panel.name === "reddoor")?.sequence).toBe("fullidle") + }) + + test("focus navigation enters and exits disabled doors and restarts aliased sequence commands", () => { + const { integration } = fixture({ redDisabled: true }) + integration.dispatch({ kind: "focus", team: "red" }) + const entered = integration.modelPanels().find(panel => panel.name === "reddoor")! + expect(entered.animation).toBe("enter_disabled") + integration.dispatch({ kind: "focus", team: "blue" }) + const exited = integration.modelPanels().find(panel => panel.name === "reddoor")! + expect(exited.animation).toBe("exit_disabled") + expect(exited.sequence).toBe(entered.sequence) + expect(exited.animationRevision).toBeGreaterThan(entered.animationRevision) + expect(integration.dispatch({ kind: "select", team: "red" }).disposition).toBe("ignored") + }) + + test("retains model entity identities when optional neighbouring panels disappear", () => { + const { integration, server } = fixture() + const before = integration.modelPanels().find(panel => panel.name === "spectate")!.panelId + integration.dispatch({ kind: "update", server: { ...server, autoAssignVisible: false } }) + expect(integration.modelPanels().find(panel => panel.name === "spectate")!.panelId).toBe(before) + expect(new Set(integration.modelPanels().map(panel => panel.panelId)).size).toBe(integration.modelPanels().length) + }) test("consumes owned repeats without autoassigning or moving authored focus", () => { const { integration, requests } = fixture() const focused = integration.state().focused diff --git a/games/tf2/wasm/src/lib.rs b/games/tf2/wasm/src/lib.rs index 5ab5e61d0..7c49a84a4 100644 --- a/games/tf2/wasm/src/lib.rs +++ b/games/tf2/wasm/src/lib.rs @@ -663,6 +663,7 @@ struct ClassPreview { model: String, scene: playsrc_tf2::class_selection::ScenePlayer, flex: playsrc_tf2::class_selection::ModelPanelFlexState, + transitions: playsrc_studio_model::SequenceTransitioner, } struct Slot { @@ -2485,6 +2486,9 @@ struct ModelPoseRequest { control_point: Option, class_selection: bool, model_panel: bool, + entity_model_panel: bool, + entity_animation_revision: u32, + pose_transitioning: bool, model_panel_reset: bool, flex_controllers: Option>, actor_identity: u32, @@ -2746,14 +2750,15 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { let item_definition = (definition != u32::MAX).then_some(definition); let start_tick = reader.u64()?; let activity_start_tick = (start_tick != u64::MAX).then_some(start_tick); - if kind > 7 + if kind > 8 || (kind == 7 && (actor_identity == 0 || has_cloak || [local_factor, world_factor, raw_factor].into_iter().chain(player_tint).any(|v| v != 0.0))) || (matches!(kind, 3 | 4 | 6) && actor_identity != 0 && !hud_model) || attachments_only > 1 || has_fire_view > 1 || (attachments_only == 1 && (kind != 1 || has_fire_view != 1)) || (has_fire_view == 1 && kind != 1) - || model_panel_reset > 1 || (model_panel_reset != 0 && !matches!(kind, 3 | 4 | 6)) + || model_panel_reset > 1 || (model_panel_reset != 0 && !matches!(kind, 3 | 4 | 6 | 8)) + || (kind == 8 && (has_cloak || hud_model || item_definition.is_some())) || item_definition.is_some_and(|definition| playsrc_tf2::equipment::supported_item(definition).is_none()) || item_definition.is_some() && !matches!(kind, 3 | 4 | 5 | 6) && activity_start_tick.is_none() { @@ -2776,7 +2781,7 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { let model = reader.text()?.to_ascii_lowercase(); let item_text = reader.text()?.to_ascii_lowercase(); let item = match kind { - 0 | 2 | 3 | 4 | 7 if item_text.is_empty() => None, + 0 | 2 | 3 | 4 | 7 | 8 if item_text.is_empty() => None, 1 | 5 | 6 if !item_text.is_empty() => Some(item_text), _ => return Err(()), }; @@ -2804,7 +2809,7 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { 3 => Some(playsrc_studio_model::ViewModelPhase::ReloadInsertOrLoop), 4 => Some(playsrc_studio_model::ViewModelPhase::ReloadFinish), 5 => Some(playsrc_studio_model::ViewModelPhase::Idle), - u8::MAX if matches!(kind, 0 | 3 | 4 | 5 | 6 | 7) => None, + u8::MAX if matches!(kind, 0 | 3 | 4 | 5 | 6 | 7 | 8) => None, _ => return Err(()), }; let packed_body_value = i32::from_le_bytes(reader.take(4)?.try_into().map_err(|_| ())?); @@ -2899,9 +2904,12 @@ fn decode_model_requests(bytes: &[u8]) -> Result, ()> { control_point: (kind == 7).then_some(actor_identity), class_selection: kind == 3, model_panel: matches!(kind, 3 | 4 | 6), + entity_model_panel: kind == 8, + entity_animation_revision: if kind == 8 { actor_identity } else { 0 }, + pose_transitioning: false, model_panel_reset: model_panel_reset != 0, flex_controllers: None, - actor_identity: if kind == 7 { 0 } else { actor_identity }, + actor_identity: if matches!(kind, 7 | 8) { 0 } else { actor_identity }, cloak: if kind == 7 { None } else { cloak }, preparation, hud_model, @@ -3075,7 +3083,7 @@ fn encode_model_poses( out.extend_from_slice(&0_u32.to_le_bytes()); let mut output_count = 0_u32; let mut sampled_poses = - BTreeMap::<(String, usize, u32, u32, Vec, Option), playsrc_studio_model::SampledPose>::new(); + BTreeMap::<(String, usize, u32, u32, Vec, Option, Option), playsrc_studio_model::SampledPose>::new(); for (request_index, original) in requests.iter().enumerate() { let mut stage = "class-scene"; let result = (|| -> Result<(), ()> { @@ -3336,6 +3344,7 @@ fn encode_model_poses( request.elapsed.to_bits(), pose_parameters.iter().map(|p| p.0).collect(), request.barrel_angle.map(f32::to_bits), + request.entity_model_panel.then_some(request.identity), ); if let std::collections::btree_map::Entry::Vacant(entry) = sampled_poses.entry(pose_key.clone()) @@ -3348,6 +3357,15 @@ fn encode_model_poses( (base, base_cycle - base_cycle.trunc(), vec![playsrc_studio_model::AnimationLayer { sequence, cycle: playsrc_studio_model::Float32(cycle.to_bits()), weight: playsrc_studio_model::Float32(1.0f32.to_bits()), }]) + } else if request.entity_model_panel { + let retained = class_scenes.entry(request.identity).or_default(); + if retained.model != request.model || request.model_panel_reset { + retained.transitions.clear(); + retained.model = request.model.clone(); + } + let layers = retained.transitions.update(model, sequence, cycle, request.current_time, + (request.current_time - request.frame_time).max(0.0), &pose_parameters, request.entity_animation_revision).map_err(|_| ())?; + (sequence, cycle, layers) } else { (sequence, cycle, Vec::new()) }; let pose = playsrc_studio_model::sample_pose_at_time( model, @@ -3364,6 +3382,12 @@ fn encode_model_poses( entry.insert(pose); } let pose = sampled_poses.get(&pose_key).ok_or(())?; + let mut entity_request; + let request = if request.entity_model_panel { + entity_request = request.clone(); + entity_request.pose_transitioning = class_scenes.get(&request.identity).ok_or(())?.transitions.is_transitioning(); + &entity_request + } else { request }; stage = "world-model-primitives"; let selected = playsrc_studio_model::select_primitives( model, @@ -3677,7 +3701,7 @@ fn encode_model_pose_part( ] { out.extend_from_slice(&value.0.to_le_bytes()); } - out.extend_from_slice(&[u8::from(timing.looping), 0, 0, 0]); + out.extend_from_slice(&[u8::from(timing.looping), u8::from(request.pose_transitioning), 0, 0]); out.extend_from_slice(&previous_cycle.to_le_bytes()); out.extend_from_slice(&cycle.to_le_bytes()); if let Some(view) = view { diff --git a/packages/formats/studio-model/rust/src/lib.rs b/packages/formats/studio-model/rust/src/lib.rs index 0109d6389..0d278c5eb 100644 --- a/packages/formats/studio-model/rust/src/lib.rs +++ b/packages/formats/studio-model/rust/src/lib.rs @@ -9,11 +9,13 @@ use std::{ mod eye; mod lighting; mod presentation; +mod sequence_transition; mod static_lighting; mod viewmodel; pub use eye::*; pub use lighting::*; pub use presentation::*; +pub use sequence_transition::*; pub use static_lighting::*; pub use viewmodel::*; diff --git a/packages/formats/studio-model/rust/src/presentation.rs b/packages/formats/studio-model/rust/src/presentation.rs index bce707eee..41dc245d4 100644 --- a/packages/formats/studio-model/rust/src/presentation.rs +++ b/packages/formats/studio-model/rust/src/presentation.rs @@ -5637,6 +5637,51 @@ mod tests { ); } + #[test] + fn entity_panel_transitions_preserve_authored_rotation_sign_hierarchy_and_interruption_order() { + let mut model = build().model; + let root = [float(0.5); 4]; + let handle = [float(0.0), float(0.0), float((std::f32::consts::FRAC_PI_4 / 2.0).sin()), float((std::f32::consts::FRAC_PI_4 / 2.0).cos())]; + model.animations.clear(); + for index in 0..2 { + model.animations.push(animation(index, b"panel", 0.0, 0)); + model.sequences[index].animation_indices = vec![index as i16]; + for frame in &mut model.animations[index].frames { + frame.rotations = vec![root, if index == 0 { identity_quaternion() } else { handle }]; + } + } + let parameters = vec![float(0.0)]; + let mut transitions = crate::SequenceTransitioner::default(); + assert!(transitions.update(&model, 0, 1.0, 1.0, 0.99, ¶meters, 0).unwrap().is_empty()); + let layers = transitions.update(&model, 1, 0.0, 1.02, 1.0, ¶meters, 0).unwrap(); + assert!(transitions.is_transitioning()); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0].sequence, 0); + assert!((f32::from_bits(layers[0].weight.0) - 0.972).abs() < 1e-5); + let state = AnimationState { base_sequence: 1, cycle: float(0.0), pose_parameters: parameters.clone(), layers, bone_rotations: Vec::new() }; + let pose = sample_pose(&model, &state).unwrap(); + assert!(values4(pose.local_rotations[1])[2] > 0.0); + assert!(values4(pose.local_rotations[1])[2] < values4(handle)[2]); + let world = apply_entity_transform(&model, &pose, source_entity_transform(vector([290.0, 0.0, -34.0]), vector([0.0, 180.0, 0.0])).unwrap()).unwrap(); + let matrix = world.bone_matrices[1].0.map(|v| f32::from_bits(v.0)); + assert!(matrix[8] > 0.0 && -matrix[4] > 0.0, "positive local rotation raises the screen-right axis; do not negate it"); + let interrupted = transitions.update(&model, 0, 0.0, 1.05, 1.04, ¶meters, 0).unwrap(); + assert_eq!(interrupted.iter().map(|v| v.sequence).collect::>(), [1, 0]); + assert!(transitions.update(&model, 0, 1.0, 1.3, 1.29, ¶meters, 0).unwrap().is_empty()); + assert!(!transitions.is_transitioning()); + // A terminal pose may have been reused for many paints; its next fade + // starts at the previous paint, not at its last expensive pose sample. + let resumed = transitions.update(&model, 1, 0.0, 5.02, 5.0, ¶meters, 0).unwrap(); + assert!((f32::from_bits(resumed[0].weight.0) - 0.972).abs() < 1e-5); + transitions.clear(); + assert!(transitions.update(&model, 1, 0.0, 6.0, 5.99, ¶meters, 0).unwrap().is_empty()); + assert_eq!(transitions.update(&model, 1, 0.0, 6.01, 6.0, ¶meters, 1).unwrap().len(), 1); + assert!(transitions.update(&model, 1, 1.0, 6.3, 6.29, ¶meters, 1).unwrap().is_empty()); + assert!(transitions.update(&model, 1, 0.0, 6.4, 6.39, ¶meters, 9).unwrap().is_empty(), "three-bit parity wraps without a forced transition"); + model.sequences[0].flags |= 2; + assert!(transitions.update(&model, 0, 0.0, 6.5, 6.49, ¶meters, 10).unwrap().is_empty()); + } + #[test] fn standard_blending_rotations_feed_descendants_palettes_and_attachments() { let model = build().model; diff --git a/packages/formats/studio-model/rust/src/sequence_transition.rs b/packages/formats/studio-model/rust/src/sequence_transition.rs new file mode 100644 index 000000000..1d74b5bf1 --- /dev/null +++ b/packages/formats/studio-model/rust/src/sequence_transition.rs @@ -0,0 +1,87 @@ +use crate::{AnimationLayer, Float32, PresentationError, PresentationErrorCode, PresentationModel, sequence_timing}; + +#[derive(Clone, Debug)] +struct Entry { + sequence: usize, + cycle: f32, + time: f32, + fade_out: f32, + parity: u8, +} + +/// C_BaseAnimating sequence transitions, distinct from studio frame interpolation. +#[derive(Clone, Debug, Default)] +pub struct SequenceTransitioner { + queue: Vec, +} + +impl SequenceTransitioner { + pub fn clear(&mut self) { self.queue.clear(); } + pub fn is_transitioning(&self) -> bool { self.queue.len() > 1 } + + /// `previous_paint_time` also accounts for paints that reused a terminal pose. + /// Finished sequences still participate in the next authored cross-fade. + pub fn update( + &mut self, + model: &PresentationModel, + sequence: usize, + cycle: f32, + time: f32, + previous_paint_time: f32, + parameters: &[Float32], + revision: u32, + ) -> Result, PresentationError> { + let invalid = || PresentationError { code: PresentationErrorCode::InvalidState, identity: model.identity.clone() }; + if ![cycle, time, previous_paint_time].into_iter().all(f32::is_finite) || previous_paint_time > time { + return Err(invalid()); + } + let incoming = model.sequences.get(sequence).ok_or_else(invalid)?; + let parity = (revision & 7) as u8; + if let Some(current) = self.queue.last_mut() { + if current.time < previous_paint_time { + let timing = sequence_timing(model, current.sequence, parameters)?; + current.cycle = advance(current.cycle, previous_paint_time - current.time, timing); + current.time = previous_paint_time; + } + if current.time != 0.0 && (current.sequence != sequence || current.parity != parity) { + if incoming.flags & 2 != 0 { + self.queue.clear(); + } else { + current.fade_out = f32::from_bits(model.sequences[current.sequence].fade_out.0) + .min(f32::from_bits(incoming.fade_in.0)); + } + self.queue.push(Entry { sequence, cycle, time, fade_out: 0.0, parity }); + } else { + *current = Entry { sequence, cycle, time, fade_out: 0.0, parity }; + } + } else { + self.queue.push(Entry { sequence, cycle, time, fade_out: 0.0, parity }); + } + let last = self.queue.len() - 1; + let mut index = 0; + self.queue.retain(|entry| { + let keep = index == last || fade(entry, time) > 0.0; + index += 1; + keep + }); + self.queue[..self.queue.len() - 1].iter().rev().map(|entry| { + let timing = sequence_timing(model, entry.sequence, parameters)?; + Ok(AnimationLayer { + sequence: entry.sequence, + cycle: Float32(advance(entry.cycle, time - entry.time, timing).to_bits()), + weight: Float32(fade(entry, time).to_bits()), + }) + }).collect() + } +} + +fn advance(cycle: f32, elapsed: f32, timing: crate::SequenceTiming) -> f32 { + let value = cycle + elapsed * f32::from_bits(timing.cycles_per_second.0); + if timing.looping { value - value.floor() } else { value.clamp(0.0, 1.0) } +} + +fn fade(entry: &Entry, time: f32) -> f32 { + if entry.fade_out <= 0.0 { return 0.0; } + let s = 1.0 - (time - entry.time) / entry.fade_out; + if s > 0.0 && s <= 1.0 { 3.0 * s * s - 2.0 * s * s * s } else { s.min(1.0) } +} diff --git a/playwright.team-door-lifecycle.config.ts b/playwright.team-door-lifecycle.config.ts new file mode 100644 index 000000000..e527e67d1 --- /dev/null +++ b/playwright.team-door-lifecycle.config.ts @@ -0,0 +1,2 @@ +import { headedProfileConfiguration } from "./tools/playsrc/profile/profile-config" +export default headedProfileConfiguration({ match: "team-door.profile.ts", target: "pl_upward" }) diff --git a/playwright.team-door.config.ts b/playwright.team-door.config.ts new file mode 100644 index 000000000..39574b8db --- /dev/null +++ b/playwright.team-door.config.ts @@ -0,0 +1,2 @@ +import { headedProfileConfiguration } from "./tools/playsrc/profile/profile-config" +export default headedProfileConfiguration({ match: "team-door.profile.ts", target: "jump_beef" }) diff --git a/tools/playsrc/profile/team-door.profile.ts b/tools/playsrc/profile/team-door.profile.ts new file mode 100644 index 000000000..a72656f9f --- /dev/null +++ b/tools/playsrc/profile/team-door.profile.ts @@ -0,0 +1,152 @@ +import { writeFile } from "node:fs/promises" +import { createHash } from "node:crypto" +import path from "node:path" +import { test, expect } from "./application-test" +import { loadLocalConfig } from "../src/config" +import { startupConsoleIdle, startupNativeReader } from "./native-startup" +import { requireStartupNative } from "./static-startup-gate" +import { installBrowserFrameProfiler } from "./browser-frame-profiler" +import { profileArtifact } from "./profile-artifacts" +import { chooseTf2Team } from "./team-selection-evidence" + +const disabledTeam = process.env.PLAYSRC_TEAM_DOOR_DISABLED as "red" | "blue" | undefined + +test("authored team doors real-time motion and reentry", async ({ page, context }) => { + const { sourceCacheDir } = await loadLocalConfig(), directory = process.env.PLAYSRC_PROFILE_RUN_DIRECTORY! + if (!directory) throw new Error("Use the checked team-door profile runner") + const native = await startupNativeReader(page, sourceCacheDir) + const admissions: unknown[] = [], captures: { file: string; before: number; after: number; data: string; timestamp: number; privacy: string; bytes?: number; sha256?: string }[] = [], actions: unknown[] = [] + const cdp = await context.newCDPSession(page) + const check = async (name: string) => { + const observation = await native.read(sampling ? undefined : path.join(directory, `${name}.desktop.png`)) + admissions.push(observation) + requireStartupNative(observation) + } + await page.addInitScript(installBrowserFrameProfiler) + await page.addInitScript(() => { (globalThis as any).__playsrcProfile = { captureTeamDoors: false } }) + let sampling = false, failure: string | null = null + cdp.on("Page.screencastFrame", frame => { + if (sampling && captures.length < 1024) captures.push({ file: `motion-${String(captures.length).padStart(3, "0")}.page.png`, + before: Date.now(), after: Date.now(), timestamp: frame.metadata.timestamp! * 1000, data: frame.data, privacy: "client-only-review-required" }) + void cdp.send("Page.screencastFrameAck", { sessionId: frame.sessionId }).catch(() => {}) + }) + try { + await page.goto("/", { waitUntil: "domcontentloaded" }) + await expect(page.locator("main")).toHaveAttribute("data-phase", "MainMenu") + await check("before-load") + await page.keyboard.press("Backquote") + await page.locator("[aria-label='Console command']").fill(`map ${disabledTeam ? "pl_upward" : "jump_beef"}`) + await page.keyboard.press("Enter") + if (await page.locator("main").getAttribute("data-console-visible") === "true") await page.keyboard.press("Backquote") + await expect(page.locator("main")).toHaveAttribute("data-team-selection-models", /reddoor:[^|]+:\d+:\d+/, { timeout: 75_000 }) + if (disabledTeam) { + await chooseTf2Team(page, "red", () => check("before-team-choice")) + await expect(page.locator("main")).toHaveAttribute("data-phase", "Ready", { timeout: 60_000 }) + await page.keyboard.press("Backquote") + await page.locator("[aria-label='Console command']").fill(`tf_bot_add 3 ${disabledTeam} soldier easy`) + await page.keyboard.press("Enter") + await expect(page.locator("main")).toHaveAttribute("data-bot-count", "3") + await page.keyboard.press("Backquote") + await page.keyboard.press("Period") + await expect(page.locator(`.team-selection-layer [data-vgui-name='teambutton${disabledTeam === "red" ? 1 : 0}']`)).toHaveAttribute("aria-disabled", "true") + } + await page.waitForTimeout(2100) + if (await startupConsoleIdle(sourceCacheDir) < 2000) throw new Error("Team-door motion requires genuine native idle") + await check("before-motion") + const heapBefore = await cdp.send("Runtime.getHeapUsage") + await page.evaluate(() => { (globalThis as any).__playsrcProfile.captureTeamDoors = true; (globalThis as any).__playsrcFrameProfiler.active = true }) + const started = Date.now() + sampling = true + await cdp.send("Page.startScreencast", { format: "png", everyNthFrame: 2 }) + const hover = async (team: "red" | "blue" | "auto" | null, wait: number) => { + actions.push({ at: Date.now(), team }) + if (team) await page.locator(`.team-selection-layer [data-vgui-name='teambutton${team === "blue" ? 0 : team === "red" ? 1 : 2}']`).hover() + else await page.mouse.move(4, 4) + await page.waitForTimeout(wait) + } + if (disabledTeam) { + await hover(null, 200) + await hover(disabledTeam, 2350) + await hover(null, 350) + await check("before-focus") + // Explicit navigation invokes enter/exit; an initial desktop focus alone + // is not a cursor-enter event in the authored PC menu. + await page.keyboard.press("ArrowRight") + for (let index = 0; index < (disabledTeam === "red" ? 3 : 2); index++) await page.keyboard.press("ArrowRight") + actions.push({ at: Date.now(), focus: disabledTeam }) + await page.waitForTimeout(350) + await page.keyboard.press("ArrowRight") + await page.waitForTimeout(350) + await check("before-reopen") + await page.keyboard.press("Escape") + await expect(page.locator("main")).toHaveAttribute("data-team-selection-visible", "false") + await page.keyboard.press("Period") + await expect(page.locator("main")).toHaveAttribute("data-team-selection-visible", "true") + actions.push({ at: Date.now(), reopen: true }) + await hover(disabledTeam, 100) + await hover(null, 100) + await hover(disabledTeam, 350) + await hover(null, 350) + await page.keyboard.press("Backquote") + await page.locator("[aria-label='Console command']").fill("tf_bot_kick all") + await page.keyboard.press("Enter") + await expect(page.locator("main")).toHaveAttribute("data-bot-count", "0") + await page.keyboard.press("Backquote") + await expect(page.locator(`.team-selection-layer [data-vgui-name='teambutton${disabledTeam === "red" ? 1 : 0}']`)).toHaveAttribute("aria-disabled", "false") + actions.push({ at: Date.now(), enabled: disabledTeam }) + await hover(disabledTeam, 550) + await hover(null, 650) + } else { + for (const team of ["auto", "blue", "red"] as const) { + await hover(team, 550) + await hover(null, 650) + await check(`after-${team}`) + } + await hover("red", 70) + await hover(null, 70) + await hover("red", 650) + await hover(null, 650) + } + await page.waitForTimeout(Math.max(0, 6000 - (Date.now() - started))) + sampling = false + await cdp.send("Page.stopScreencast") + const activeMilliseconds = Date.now() - started + await check("after-motion") + const observation = await page.evaluate(() => { + const root = globalThis as any + root.__playsrcProfile.captureTeamDoors = false + root.__playsrcFrameProfiler.active = false + return { doors: root.__playsrcProfile.teamDoorFrames, frames: root.__playsrcFrameProfiler } + }) + const heapAfter = await cdp.send("Runtime.getHeapUsage") + await profileArtifact(async () => { + for (const capture of captures) { + const bytes = Buffer.from(capture.data, "base64") + await writeFile(path.join(directory, capture.file), bytes) + capture.data = "" + capture.bytes = bytes.length + capture.sha256 = createHash("sha256").update(bytes).digest("hex") + } + await writeFile(path.join(directory, "team-door-motion.json"), JSON.stringify({ activeMilliseconds, disabledTeam, observation, actions, captures, heapBefore, heapAfter })) + }) + expect(activeMilliseconds).toBeGreaterThanOrEqual(5000) + expect(activeMilliseconds).toBeLessThan(10000) + expect(captures.length).toBeGreaterThan(30) + if (disabledTeam) { + const name = disabledTeam === "red" ? "reddoor" : "bluedoor" + expect(observation.doors.some((frame: any) => frame.panels.some((panel: any) => panel.name === name && panel.animation === "hover_disabled"))).toBe(true) + expect(observation.doors.at(-1).panels.find((panel: any) => panel.name === name).sequence).toBe("hoverclose") + } + for (const name of disabledTeam ? [disabledTeam === "red" ? "reddoor" : "bluedoor"] : ["autodoor", "bluedoor", "reddoor"]) { + const final = observation.doors.at(-1).panels.find((panel: any) => panel.name === name) + expect(final.cycle).toBe(1) + expect(final.timing.transitioning).toBe(false) + } + } catch (error) { failure = String(error); throw error } + finally { + sampling = false + await cdp.send("Page.stopScreencast").catch(() => {}) + await native.close() + await profileArtifact(async () => { await writeFile(path.join(directory, "team-door-admission.json"), JSON.stringify({ admissions, records: native.records, actions, captures, failure })) }) + } +}) diff --git a/tools/playsrc/src/profile-runner.ts b/tools/playsrc/src/profile-runner.ts index c5e5462ff..467e436fa 100644 --- a/tools/playsrc/src/profile-runner.ts +++ b/tools/playsrc/src/profile-runner.ts @@ -121,6 +121,9 @@ const PROFILES = Object.freeze({ "class-hud": { config: "playwright.class-hud-profile.config.ts", target: "jump_beef" }, "class-selection": { config: "playwright.class-selection-profile.config.ts", target: "jump_beef" }, "selection-transition": { config: "playwright.selection-transition.config.ts", target: "pl_upward", minimumRemainingMilliseconds: 60_000 }, + "team-door": { config: "playwright.team-door.config.ts", target: "jump_beef", minimumRemainingMilliseconds: 60_000 }, + "team-door-disabled-red": { config: "playwright.team-door-lifecycle.config.ts", target: "pl_upward", environment: { PLAYSRC_TEAM_DOOR_DISABLED: "red" }, minimumRemainingMilliseconds: 90_000 }, + "team-door-disabled-blue": { config: "playwright.team-door-lifecycle.config.ts", target: "pl_upward", environment: { PLAYSRC_TEAM_DOOR_DISABLED: "blue" }, minimumRemainingMilliseconds: 90_000 }, "selection-transition-cpu": { config: "playwright.selection-transition.config.ts", target: "pl_upward", environment: { PLAYSRC_SELECTION_CPU: "1" }, minimumRemainingMilliseconds: 60_000 }, equipment: { config: "playwright.equipment-profile.config.ts", target: "pl_upward" }, "melee-unlocks": { config: "playwright.melee-profile.config.ts", target: "pl_upward" },