diff --git a/xbox/live/MARKETING.md b/xbox/live/MARKETING.md --- a/xbox/live/MARKETING.md +++ b/xbox/live/MARKETING.md @@ -7,10 +7,10 @@ videos and say yes. Code: `xbox/live/marketing/`. Staged output: `tmp/oskiewar-reels/queue/`. Every Reel passes through the Oskiewar Replay Oven -(`marketing/replay-oven.mjs`): deterministic demo playback, the game-native -match HUD, 60 fps, JPEG-100 source frames, a CRF-14 H.264 master, and -offline-only color/detail passes. Controls, debug meters, touch UI, and QR stay -out. Future ray/depth treatments belong at that seam, never in live play. +(`marketing/replay-oven.mjs`): deterministic offline simulation and audio, +the full game UI, 60 authored frames per second, JPEG-100 source +frames, and a CRF-14 H.264 master. Controls, debug meters, touch UI, and QR +stay out. Account ledger row: `social/accounts.json` → `oskiewar-ig`. **Nothing posts to Instagram without `--live` and a token in the environment.** @@ -33,39 +33,39 @@ ### 1 · Source — mechanical, never taste-based `slot = daysSince(2026-01-01) × slotsPerDay + index`. The slot picks the market -out of a fixed rotation and the seed string is `YYYY-MM-DD#index`. One slot in -five (`slot % 5 === 4`) pulls a **real recorded round** from +out of a fixed rotation and the seed string is `YYYY-MM-DD#index`. The explicit +`--replays` review lane can pull a **real recorded round** from `/api/oskiewar-replays` instead of a synthetic one, so the feed is not only robots sparring; which round is still arithmetic — the seed indexes the filtered list. If the store is unreachable or has nothing over seven seconds, it falls back to self-play and says so. +Scheduled posts stay on self-play until human replay envelopes carry every +authored drum cue. This keeps production on the complete offline renderer. + Seeding works because `oskiewar.js` calls `Math.random` **exactly once**, to seed the match name; everything downstream — round names, ball kind, the fighters — falls out of it. `render.mjs` replaces `Math.random` with a seeded generator before the game boots. -**What a seed does and does not buy.** Verified by rendering the same seed -twice: the match identity reproduces (`ow-chetty900` both times), the **frames -do not** (different MD5 at t=8s). The browser advances the sim on the wall -clock, so tick alignment jitters. The copy in `segments.mjs` is written to -claim only the first. Do not upgrade it to "same seed, same frames" unless the -renderer is moved onto a fixed timestep. +The offline renderer advances the seeded match on a fixed tick clock. Machine +speed changes only how long the bake takes, never the delivered timing. An +explicit `--speed` changes authored game time instead: `--speed 0.25` holds +each exact simulation tick across four 60 fps presentation frames. ### 2 · Render — the real game, at the real shape The remote Replay Oven runs `mac-test.html` + `oskiewar.js` from an immutable -`origin/main` commit. A latest-build bot fight produces the authoritative -replay and audio; then the replay is painted again through the same renderer at +`origin/main` commit. One offline driver advances the latest-build bot fight at an exact fixed 60 Hz. -- **Video** — the live pass is never publication footage. Its replay is stepped - offline and captured once per simulation tick. A sparse browser screencast - can no longer be disguised as 60 fps by duplicated frames. -- **Audio** — `AudioNode.prototype.connect` is patched before boot so anything - routed to `ctx.destination` also tees into a `MediaStreamDestination` an - in-page `MediaRecorder` records. This is the established AC technique; there - is no second audio path. +- **Video** — physics stays on an exact 60 Hz lattice. At 1× every tick paints + one frame; at fractional speed, presentation remains 60 fps and holds ticks + for the required number of frames. Browser repaint cadence and render wall + time cannot change playback speed. +- **Audio** — each cue is stamped on presentation time. `OfflineAudioContext` + renders the complete 48 kHz stereo score after the round, including slowed + timelines. There is no `MediaRecorder` or alignment pass. Capture is **1080×1920 natively** — recorded at 9:16, never cropped or scaled into it. The game lays itself out for the viewport it is handed and goes @@ -74,19 +74,15 @@ version captured 1:1 and composed it into a letterbox; that is gone, and `inspect()` now *fails the build* if the aspect drifts off 9:16, because a drift means something rescaled the video. -**A reel is one whole match, uncut.** Recording waits out whatever round was -already running so it can begin on a round's own first frame, then stops on the -result card. Head and tail are trimmed; nothing inside is cut. +Reels default to the daylight palette. `--theme dark` remains available for +deliberate night tests. -> **The trim boundary is subtle and was wrong at first.** A round announces its -> end by POSTing its replay, and that POST lands at the *start* of the result -> card — `roundResultUs` of card still has to play before the next round -> begins. Treating the POST as the end of the round put ~3 s of the previous -> round's card on the front of every reel and ~0.6 s of the next round's intro -> on the back: about 8% of a 40 s reel was a neighbouring round. `cardClearMs` -> waits the card out before recording; `tailHoldMs` stops just short of it. -> Verified by eye on the first and last frames — the reel opens on its own -> countdown and closes on its own card. +Time scale is bounded to `0.05×–4×`; `0` is available through the live driver +as pause. In a browser, use `?time-scale=0.25` or call +`__oskiewarSetTimeScale(0.25)`. The factory spelling is `--speed 0.25`. + +**A reel is one whole match, uncut.** Offline simulation starts on the round's +first tick and stops on its result card. Nothing inside is cut. > **What "a match" means here.** `oskiewar.js` carries `matchWins = 5`, but > nothing accumulates round wins toward it — self-play calls `startSelfPlay` @@ -148,26 +144,26 @@ Remote production starts with `npm run oskiewar:oven -- --day YYYY-MM-DD --index 0`. Oven checks out the requested commit, forces bot-only source, and returns the mp4, cover, thumbnail, and sidecar. It has no Instagram credentials. The local publish command remains the only road to Instagram. The motion gate -requires `fixed-step-60` and at least 59.5 source frames per second. +requires `offline-demo-60` and at least 59.5 authored frames per second. --- ## Measured throughput, and the slot grid it justifies -Latest fixed-step proof on an M-series laptop, Chrome headless: +Latest offline proof on an M-series laptop, Chrome headless: | | | |---|---| -| Source cadence | **60 fixed-step frames/s** at 1080×1920 | -| Proof Reel | 11.2 s, 674 source frames | -| Render wall clock | 161 s, including live bot fight, offline replay, and encode | -| Audio sync | 23/23 onsets, 4 ms median skew, 30 ms worst skew | -| Output | 1080×1920, H.264/AAC, 1.6 MB | +| Source cadence | **60 offline-authored frames/s** at 1080×1920 | +| Proof Reel | 35.2 s, 2,112 source frames | +| Render wall clock | 158 s; delivered duration remains exactly 35.2 s | +| Audio sync | 73/73 onsets, 17 ms median skew, 133 ms worst skew | +| Output | 1080×1920, H.264/AAC, 10.1 MB | **Three slots a day.** The defence is arithmetic, not vibes: -- Three reels cost ~5 minutes of machine time, most of it the warm-up round - each one waits out. A day's output still fits in the gap between two builds. +- Three reels cost several minutes of offline machine time. Render speed does + not affect delivered speed. - Meta's published ceiling is 50 API posts per rolling 24 hours (the docs also say 100 in one place — read `content_publishing_limit` at runtime and believe the account). Three is under it by a factor of sixteen, so the grid can triple @@ -206,7 +202,9 @@ ```bash node xbox/live/marketing/reel.mjs # today's slot 0 node xbox/live/marketing/reel.mjs --day 2026-08-09 --slots 3 node xbox/live/marketing/reel.mjs --segment fgc # force a market (review only) -node xbox/live/marketing/reel.mjs --seconds 20 --no-replays +node xbox/live/marketing/reel.mjs --cap 45 +node xbox/live/marketing/reel.mjs --speed 0.25 # 60 fps quarter-speed master +node xbox/live/marketing/reel.mjs --replays # legacy review lane node xbox/live/marketing/reel.mjs --queue # what is staged node xbox/live/marketing/reel.mjs --segments # the market table node xbox/live/marketing/reel.mjs --report # per-market performance @@ -225,7 +223,7 @@ 7 19 * * * bash -lc 'cd ~/aesthetic-computer && set -a && source vault/oskiewar/instagram.env && set +a && node toolchain/instagram/oskiewar-oven.mjs --index 2 --auto >> ~/.local/state/oskiewar-reels.log 2>&1' ``` `--auto` posts only when all three gates pass — Meta's spec table, the sync -meter, and fixed-step motion. A reel that fails any gate is held in the queue +meter, and offline-authored motion. A reel that fails any gate is held in the queue for a human, loudly, and the day goes on. @jeffrey lifted the human-per-post review on 2026-08-09, after approving the pipeline reel by reel; `--publish --live` remains the manual override, and `ledger.json` records every diff --git a/xbox/live/frame-driver.mjs b/xbox/live/frame-driver.mjs --- a/xbox/live/frame-driver.mjs +++ b/xbox/live/frame-driver.mjs @@ -13,11 +13,25 @@ cancelFrame = (handle) => cancelAnimationFrame(handle), setTimer = (callback, delay) => setTimeout(callback, delay), clearTimer = (handle) => clearTimeout(handle), fallbackGraceMs = 2, + timeScale = 1, } = {}) { if (typeof simulate !== "function") throw new TypeError("simulate callback required"); if (typeof paint !== "function") throw new TypeError("paint callback required"); const interval = 1000 / simulationFps; + const normalizeTimeScale = (value) => { + const number = Number(value); + if (!Number.isFinite(number)) return 1; + if (number === 0) return 0; + return Math.max(.05, Math.min(4, number)); + }; + let currentTimeScale = normalizeTimeScale(timeScale); + // Offline capture can wait on fonts, Puppeteer, or a reviewer before its + // first step. Anchor its authored clock when the driver is created beside + // piece.boot(), otherwise that wait silently skips the beginning of a round. + const offlineOrigin = now(); + const wallInterval = () => currentTimeScale > 0 + ? interval / currentTimeScale : Infinity; const earlyTolerance = .35; let running = false; let visible = true; @@ -26,8 +40,11 @@ let simulationTime = 0; let timerHandle = null; let rafHandle = null; let offlineStarted = false; + let offlinePresentationTime = 0; + let offlineAccumulator = 0; const stats = { simulationFps, + timeScale: currentTimeScale, renderFrames: 0, simulationTicks: 0, inputSamples: 0, @@ -85,10 +102,10 @@ sample(); stats.inputSamples++; }; - const runSimulation = (sampleFirst) => { + const runSimulation = (sampleFirst, presentationTime = now()) => { if (sampleFirst) sampleInput(); const started = now(); - simulate(simulationTime); + simulate(simulationTime, presentationTime); stats.simulationTicks++; stats.lastSimulationAt = simulationTime; stats.lastSimulationCostMs = Math.max(0, now() - started); @@ -102,7 +119,7 @@ }; const armSimulationTimer = () => { clearSimulationTimer(); - if (!running || !visible) return; + if (!running || !visible || currentTimeScale === 0) return; // A timely animation frame gets first refusal. The timer preserves the // fixed simulation rate on displays running below 60 Hz. const delay = Math.max(0, nextSimulationAt + fallbackGraceMs - now()); @@ -113,19 +130,20 @@ }, delay); }; const pumpSimulation = (current, sampleEachTick) => { - if (!running || !visible) return; + if (!running || !visible || currentTimeScale === 0) return; + const dueEvery = wallInterval(); let ticks = 0; while (current + earlyTolerance >= nextSimulationAt && ticks < maxCatchUpTicks) { simulationTime += interval; - runSimulation(sampleEachTick); - nextSimulationAt += interval; + runSimulation(sampleEachTick, current); + nextSimulationAt += dueEvery; ticks++; } if (current + earlyTolerance >= nextSimulationAt) { - const dropped = Math.floor((current - nextSimulationAt) / interval) + 1; + const dropped = Math.floor((current - nextSimulationAt) / dueEvery) + 1; stats.droppedSimulationTicks += Math.max(1, dropped); - nextSimulationAt = current + interval; + nextSimulationAt = current + dueEvery; } armSimulationTimer(); }; @@ -137,9 +155,11 @@ const current = Number.isFinite(timestamp) ? timestamp : now(); sampleInput(); pumpSimulation(current, false); const started = now(); - const alpha = Math.max(0, Math.min(1, - (current - nextSimulationAt + interval) / interval)); - paint(current, alpha); + const dueEvery = wallInterval(); + const alpha = currentTimeScale === 0 ? 0 : Math.max(0, Math.min(1, + (current - nextSimulationAt + dueEvery) / dueEvery)); + paint(current, alpha, + simulationTime - interval * (1 - alpha)); stats.renderFrames++; stats.lastRenderAt = current; stats.lastRenderCostMs = Math.max(0, now() - started); @@ -158,18 +178,35 @@ stepOffline() { if (running) throw new Error("cannot step offline while frame driver is running"); if (!offlineStarted) { offlineStarted = true; - stats.startedAt = now(); + stats.startedAt = offlineOrigin; simulationTime = stats.startedAt - interval; + offlinePresentationTime = stats.startedAt - interval; + offlineAccumulator = 1; sampleInput(); } - simulationTime += interval; - runSimulation(false); + offlinePresentationTime += interval; + offlineAccumulator += stats.renderFrames ? currentTimeScale : 0; + let simulationTicks = 0; + while (offlineAccumulator >= 1) { + simulationTime += interval; + runSimulation(false, offlinePresentationTime); + offlineAccumulator -= 1; + simulationTicks++; + } const started = now(); - paint(simulationTime, 0); + // At fractional speed, walk monotonically from the state entering the + // latest authored tick to the state leaving it. Starting the first + // frame at 1 and the next at .5 made the opening visibly step backward. + const alpha = currentTimeScale >= 1 ? 1 + : Math.max(0, Math.min(1, offlineAccumulator + currentTimeScale)); + paint(offlinePresentationTime, alpha, + simulationTime - interval * (1 - alpha)); stats.renderFrames++; stats.lastRenderAt = simulationTime; stats.lastRenderCostMs = Math.max(0, now() - started); - return { frame: stats.renderFrames, simulationTime }; + return { frame: stats.renderFrames, simulationTime, + presentationTime: offlinePresentationTime, simulationTicks, + timeScale: currentTimeScale }; }, start() { if (running) return; @@ -181,7 +218,7 @@ simulationTime = stats.startedAt; nextSimulationAt = stats.startedAt; sampleInput(); runSimulation(false); - nextSimulationAt += interval; + nextSimulationAt += wallInterval(); armSimulationTimer(); rafHandle = requestFrame(rafTick); }, @@ -201,10 +238,21 @@ if (!running || !visible) return; nextSimulationAt = now(); sampleInput(); simulationTime += interval; - runSimulation(false); - nextSimulationAt += interval; + runSimulation(false, nextSimulationAt); + nextSimulationAt += wallInterval(); armSimulationTimer(); }, + setTimeScale(value) { + currentTimeScale = normalizeTimeScale(value); + stats.timeScale = currentTimeScale; + clearSimulationTimer(); + if (running && visible && currentTimeScale > 0) { + nextSimulationAt = now() + wallInterval(); + armSimulationTimer(); + } + return currentTimeScale; + }, + getTimeScale() { return currentTimeScale; }, }; } diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -560,6 +560,8 @@ } let simulationMonotonicMs = null; let lastSimulationMonotonicMs = null; + let presentationMonotonicMs = null; + let renderAlpha = 1; function runtime() { // Frame timing comes from the driver's own profile rather than a second // clock in here: those are measurements it already takes, and the names @@ -571,6 +573,9 @@ const frames = globalThis.__oskiewarFrameStats; return { monotonicUs: (simulationMonotonicMs ?? performance.now()) * 1000, simMonotonicUs: (simulationMonotonicMs ?? lastSimulationMonotonicMs ?? performance.now()) * 1000, + presentationMonotonicUs: (presentationMonotonicMs ?? + simulationMonotonicMs ?? performance.now()) * 1000, + renderAlpha, unixMs: Date.now(), audioLatencyMs: audioContext?.baseLatency ? audioContext.baseLatency * 1000 : 0, frameMs: frames?.frameMs || 0, @@ -731,6 +736,7 @@ const popsEndpoint = "/api/oskiewar-pops"; const socialPreview = new URLSearchParams(location.search).has("social-preview"); const replayOven = new URLSearchParams(location.search).has("replay-oven"); const reelHud = new URLSearchParams(location.search).has("reel-hud"); + const reelFullUi = new URLSearchParams(location.search).has("reel-full-ui"); // Bot self-play from a plain address, so a browser with no puppeteer to // whisper globals — a phone, a spare display, the jasellite preview — can // watch the game fight itself. Still never a button: entering it takes a @@ -752,6 +758,7 @@ replayOven, // The Reel lane keeps the match's own timer, names, stats and command // stream without admitting controls, debug meters, touch UI or QR. reelHud, + reelFullUi, country: localCountry, updateReady: updateAvailable, // Scoring sessions are anonymous dummy play; the seam auth attaches to @@ -819,6 +826,13 @@ return source; } function drum(name, amount = 1, pan = 0) { + if (Array.isArray(globalThis.__oskiewarOfflineAudioEvents)) { + globalThis.__oskiewarOfflineAudioEvents.push({ + kind: "drum", name, amount, pan, + at: runtime().presentationMonotonicUs / 1000, + }); + return; + } // The signal-routed AC mock bank is primary after unlock. This legacy // inline kit remains the first-gesture/offline fallback. if (globalThis.__oskiewarSfx?.unlocked && @@ -933,8 +947,18 @@ } function gameSignal(event, player = -1, value = 0, value2 = 0) { if (event === "dummy-popped") reportDummyPop(); + const run = runtime(); + const at = Array.isArray(globalThis.__oskiewarOfflineAudioEvents) + ? run.presentationMonotonicUs / 1000 : run.monotonicUs / 1000; + if (Array.isArray(globalThis.__oskiewarOfflineAudioEvents)) { + const pans = globalThis.__oskiewarPlayerPans; + globalThis.__oskiewarOfflineAudioEvents.push({ + kind: "signal", event, player, value, value2, at, + pan: player === 0 || player === 1 ? pans?.[player] : 0, + }); + } dispatchEvent(new CustomEvent("oskiewar:signal", { - detail: { event, player, value, value2, at: performance.now() }, + detail: { event, player, value, value2, at }, })); } @@ -1309,15 +1333,35 @@ oskiewarSfx.drum("hat", .26, 0); titleHoverSoundKey = titleKey; }; const driver = createFrameDriver({ + // `time-scale` is the public spelling; `speed` remains a compact URL + // alias for render/review scripts. Physics always advances in exact + // 60 Hz ticks—the scale changes how often those ticks are consumed. + timeScale: (() => { + const params = new URLSearchParams(location.search); + return globalThis.__oskiewarTimeScale ?? + params.get("time-scale") ?? params.get("speed") ?? 1; + })(), sample: globalThis.__fightHost.sampleInput, - simulate(simulationAt) { + simulate(simulationAt, presentationAt) { simulationMonotonicMs = simulationAt; lastSimulationMonotonicMs = simulationAt; + presentationMonotonicMs = presentationAt; try { piece.sim(); } - finally { simulationMonotonicMs = null; } + finally { + simulationMonotonicMs = null; + presentationMonotonicMs = null; + } }, - paint() { - piece.paint(); + paint(presentationAt, alpha, renderSimulationAt) { + simulationMonotonicMs = renderSimulationAt; + presentationMonotonicMs = presentationAt; + renderAlpha = alpha; + try { piece.paint(); } + finally { + simulationMonotonicMs = null; + presentationMonotonicMs = null; + renderAlpha = 1; + } // The last run of faces has nobody behind it to trip the flush, and // the next frame opens with a wipe that would erase it unpainted. globalThis.__fightHost.flushFaces(); @@ -1334,6 +1378,12 @@ syncSelectionCursor(); }, }); globalThis.__oskiewarFrameStats = driver.stats; + globalThis.__oskiewarSetTimeScale = (value) => { + const scale = driver.setTimeScale(value); + globalThis.__oskiewarTimeScale = scale; + return scale; + }; + globalThis.__oskiewarGetTimeScale = () => driver.getTimeScale(); const offlineRender = new URLSearchParams(location.search).has("offline-render"); if (offlineRender) { globalThis.__oskiewarOfflineStep = () => driver.stepOffline(); diff --git a/xbox/live/marketing/dress.mjs b/xbox/live/marketing/dress.mjs --- a/xbox/live/marketing/dress.mjs +++ b/xbox/live/marketing/dress.mjs @@ -63,7 +63,8 @@ // What Meta's spec table asks about, answered from the file itself. export function inspect(reel) { const probe = spawnSync("ffprobe", ["-v", "error", "-show_entries", "format=duration,size:stream=codec_type,codec_name,width,height,r_frame_rate," + - "bit_rate,sample_rate,channels", "-of", "json", reel], { encoding: "utf8" }); + "avg_frame_rate,nb_frames,bit_rate,sample_rate,channels", "-of", "json", reel], + { encoding: "utf8" }); const data = JSON.parse(probe.stdout || "{}"); const video = (data.streams || []).find((s) => s.codec_type === "video") || {}; const audio = (data.streams || []).find((s) => s.codec_type === "audio") || {}; @@ -71,6 +72,8 @@ const seconds = Number(data.format?.duration || 0); const megabytes = Number(data.format?.size || 0) / 1e6; const [num, den] = String(video.r_frame_rate || "0/1").split("/").map(Number); const fps = den ? num / den : 0; + const frames = Number(video.nb_frames || 0); + const authoredFps = seconds > 0 ? frames / seconds : 0; const aspect = video.width && video.height ? video.width / video.height : 0; const checks = { container: { ok: /\.mp4$/.test(reel), value: "mp4" }, @@ -79,7 +82,11 @@ audioCodec: { ok: audio.codec_name === "aac", value: audio.codec_name }, sampleRate: { ok: Number(audio.sample_rate) <= 48000, value: audio.sample_rate }, channels: { ok: [1, 2].includes(Number(audio.channels)), value: audio.channels }, frameRate: { ok: fps >= 23 && fps <= 60, value: fps.toFixed(2) }, + authoredFrameRate: { ok: authoredFps >= 59.5 && authoredFps <= 60.5, + value: authoredFps.toFixed(2) }, columns: { ok: Number(video.width) <= 1920, value: video.width }, + reelResolution: { ok: Number(video.width) === 1080 && + Number(video.height) === 1920, value: `${video.width}×${video.height}` }, // 9:16 exactly, because the capture is 9:16 — a drift here means // something scaled the video, which is the thing this stage must not do. aspect: { ok: Math.abs(aspect - 9 / 16) < 0.001, value: aspect.toFixed(4) }, @@ -101,6 +108,7 @@ value: `mean ${mean} dB · peak ${peak} dB` }; })(), }; return { width: video.width, height: video.height, seconds, megabytes, fps, + frames, authoredFps, checks, ok: Object.values(checks).every((check) => check.ok) }; } diff --git a/xbox/live/marketing/reel.mjs b/xbox/live/marketing/reel.mjs --- a/xbox/live/marketing/reel.mjs +++ b/xbox/live/marketing/reel.mjs @@ -53,24 +53,25 @@ existsSync(join(staging, name, "reel.json"))).sort() .map((name) => JSON.parse(readFileSync(join(staging, name, "reel.json"), "utf8"))); } -// The game's HUD carries a live Los Angeles clock, so the reel's palette -// follows the same clock: render in daylight and the reel is light, render -// at night and it is dark. Coherence, not preference — a HUD that says -// 11pm over a light theme reads as a mistake. --theme overrides for review. +// Reels default to the daylight palette for legibility on a phone. Dark mode +// remains an explicit review option. function themeNow() { if (flags.theme === "light" || flags.theme === "dark") return flags.theme; - const hour = Number(new Intl.DateTimeFormat("en-US", { - timeZone: "America/Los_Angeles", hour: "numeric", hourCycle: "h23", - }).format(new Date())); - return hour >= 7 && hour < 19 ? "light" : "dark"; + return "light"; } async function buildSlot(day, index) { const spec = await pickSource({ date: new Date(`${day}T12:00:00Z`), index, slotsPerDay, cap: Number(flags.cap || 600), - allowReplays: flags["no-replays"] !== true, log }); + // Published video must come from the single-clock offline demo renderer. + // Human replay envelopes do not yet carry every authored drum cue, so + // they remain an explicit review lane until that format is complete. + allowReplays: flags.replays === true, log }); spec.theme = themeNow(); spec.hud = flags["no-hud"] !== true; + spec.timeScale = flags.speed === undefined ? 1 : Number(flags.speed); + if (!Number.isFinite(spec.timeScale) || spec.timeScale <= 0) + throw new Error("--speed must be a positive number (for example 0.25)"); // Forcing a market is a review affordance, not part of the grid. The slot // keeps its number so the ledger stays honest about which slot ran. if (flags.segment && spec.segment !== flags.segment) { @@ -81,7 +82,7 @@ dress(flags.segment, seed32(spec.seed), spec.facts)); log(` forced market → ${spec.segmentName}`); } - log(` theme ${spec.theme} (LA clock)`); + log(` theme ${spec.theme} · ${spec.timeScale}× time`); const dir = queueDir(staging, spec); const work = join(dir, "work"); mkdirSync(work, { recursive: true }); @@ -96,14 +97,17 @@ const tenth = thumbnail(reel, join(dir, "thumbnail-10-percent.jpg")); const spec1080 = inspect(reel); // The feedback loop: the game stamped every sound it asked for; measure // what the encoded file actually plays and hold the two together. - const sync = verifySync(reel, render.signals || []); + const sync = verifySync(reel, render.signals || [], + { timeScale: spec.timeScale }); const motion = { mode: render.frameCadence || "unknown", frames: render.frames, + encodedFrames: spec1080.frames, seconds: render.seconds, sourceFps: render.seconds > 0 ? +(render.frames / render.seconds).toFixed(2) : 0, }; - motion.ok = motion.mode === "fixed-step-60" && motion.sourceFps >= 59.5; + motion.ok = ["fixed-step-60", "offline-demo-60"].includes(motion.mode) && + motion.sourceFps >= 59.5 && motion.encodedFrames === motion.frames; log(` sync ${sync.ok ? "✓" : "✗"} · ${sync.expectedSignals} signals in` + ` ${sync.signalChords} chords · voiced ${sync.voicedChords}` + ` (${Math.round((sync.coverage ?? 0) * 100)}%)` + diff --git a/xbox/live/marketing/render.mjs b/xbox/live/marketing/render.mjs --- a/xbox/live/marketing/render.mjs +++ b/xbox/live/marketing/render.mjs @@ -1,30 +1,14 @@ // Stage 2 — burn a source spec into a 1080x1920 mp4 with the game's own audio. // -// The renderer is the real game in a real browser: `oskiewar.js` under -// `mac-test.html`, painted at 60 Hz by the same frame driver a player gets. -// Two halves, muxed by ffmpeg, both borrowed from `marketing/av-reels`: -// -// VIDEO ← CDP `Page.startScreencast` → timestamped JPEG frames. A frame -// arrives whenever the page repaints, so the concat list carries -// per-frame durations and playback comes out at true speed. -// AUDIO ← `AudioNode.prototype.connect` is patched before the page boots so -// anything routed to `ctx.destination` also tees into a -// MediaStreamDestination an in-page MediaRecorder is listening to. -// Audio worklets run on the audio thread, which is why this survives -// the headless rAF throttle that leaves `canvas.captureStream()` -// empty. -// -// Determinism: `oskiewar.js` calls `Math.random` exactly once, to seed the match -// name — everything downstream (ball kind, round names, the whole physics -// sim) falls out of that. Replacing `Math.random` with a seeded generator -// before boot therefore makes an entire match reproducible from a string. +// Scheduled self-play uses separate fixed simulation and presentation clocks. +// At 1× each output frame advances one complete simulation tick; fractional +// time holds that state across multiple distinct 60 fps presentation frames. +// Procedural audio cues are stamped on presentation time, rendered offline, +// and muxed with those frames. Browser repaint speed, MediaRecorder, and wall +// time cannot change the delivered match. // -// The halves are cut on two different clocks, and joining them is this file's -// one real piece of arithmetic. The picture that ships is NOT the live -// screencast — that pass is thrown away and the frames are re-rendered offline -// from the demo, one frame per demo tick, starting at tick zero. The sound is -// still the live pass, stamped in wall milliseconds. `demoOriginMs` is the -// bridge between them. +// The older live-capture lane remains below only for explicit legacy replay +// and training review. It is not eligible for the scheduled self-play feed. import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; @@ -80,9 +64,11 @@ if (!dir) throw new Error("puppeteer not found"); return (await import(`${dir}/lib/esm/puppeteer/puppeteer.js`)).default; } -export function offlineReplayAddress(origin, round, { hud = true } = {}) { +export function offlineReplayAddress(origin, round, + { hud = true, timeScale = 1 } = {}) { return `${origin}/${round}?social-preview&replay-oven&offline-render` + - (hud ? "&reel-hud" : ""); + (hud ? "&reel-hud" : "") + + (Number(timeScale) === 1 ? "" : `&time-scale=${encodeURIComponent(timeScale)}`); } async function captureOfflineReplay({ browser, shell, demo, frames, width, @@ -138,6 +124,215 @@ await page.close(); } } +async function renderOfflineSoundtrack(page, events, zeroMs, seconds) { + return page.evaluate(async ({ events, zeroMs, seconds }) => { + const sampleRate = 48000; + const duration = Math.max(.1, seconds + .5); + const context = new OfflineAudioContext(2, + Math.ceil(duration * sampleRate), sampleRate); + const { default: createOskiewarSfx } = await import("/oskiewar-sfx.mjs"); + const bank = createOskiewarSfx({ context, destination: context.destination, + offline: true, volume: .64, maxVoices: 4096 }); + await bank.unlock(); + for (const cue of events) { + const at = (cue.at - zeroMs) / 1000; + if (at < 0 || at > seconds) continue; + if (cue.kind === "drum") + bank.drumAt(at, cue.name, cue.amount, cue.pan); + else if (cue.kind === "signal") + bank.signalAt(at, cue.event, cue.player, cue.value, cue.value2, + { pan: cue.pan }); + } + const rendered = await context.startRendering(); + const frames = Math.min(rendered.length, Math.ceil(seconds * sampleRate)); + const bytes = new Uint8Array(44 + frames * 4); + const view = new DataView(bytes.buffer); + const text = (at, value) => { + for (let index = 0; index < value.length; index++) + view.setUint8(at + index, value.charCodeAt(index)); + }; + text(0, "RIFF"); view.setUint32(4, 36 + frames * 4, true); + text(8, "WAVEfmt "); view.setUint32(16, 16, true); + view.setUint16(20, 1, true); view.setUint16(22, 2, true); + view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 4, true); + view.setUint16(32, 4, true); view.setUint16(34, 16, true); + text(36, "data"); view.setUint32(40, frames * 4, true); + const left = rendered.getChannelData(0); + const right = rendered.getChannelData(1); + let offset = 44; + for (let index = 0; index < frames; index++) { + view.setInt16(offset, Math.round(Math.max(-1, Math.min(1, left[index])) * 32767), true); + view.setInt16(offset + 2, + Math.round(Math.max(-1, Math.min(1, right[index])) * 32767), true); + offset += 4; + } + let binary = ""; + for (let at = 0; at < bytes.length; at += 0x8000) + binary += String.fromCharCode.apply(null, bytes.subarray(at, at + 0x8000)); + return btoa(binary); + }, { events, zeroMs, seconds }); +} + +function encodeOfflineMaster({ out, frames, stamps, soundtrack, ovenStyle, + width, height }) { + const list = stamps.map((stamp) => + `file 'frames/${stamp.file}'\nduration ${(1 / 60).toFixed(6)}`) + .join("\n") + `\nfile 'frames/${stamps.at(-1).file}'\n`; + writeFileSync(join(out, "frames.txt"), list); + const track = join(out, "audio.wav"); + writeFileSync(track, soundtrack); + const base = join(out, "base.mp4"); + const scale = `scale=${width}:${height}:flags=lanczos,setsar=1`; + const ovenFilter = ovenStyle === "raw" ? scale : scale + + ",eq=contrast=1.14:saturation=1.16:brightness=0.0," + + "unsharp=3:3:1.1:3:3:0.5"; + const ffmpeg = spawnSync("ffmpeg", ["-y", "-f", "concat", "-safe", "0", + "-i", join(out, "frames.txt"), "-i", track, + "-vf", ovenFilter, + "-fps_mode", "cfr", "-r", "60", "-c:v", "libx264", "-preset", "slow", + "-crf", "14", "-profile:v", "high", "-level", "4.2", + "-pix_fmt", "yuv420p", "-map", "0:v", "-map", "1:a", + ...(process.platform === "darwin" + ? ["-c:a", "aac_at", "-aac_at_mode", "cbr"] : ["-c:a", "aac"]), + "-b:a", "128k", "-ar", "48000", "-ac", "2", "-shortest", + "-movflags", "+faststart", base], { encoding: "utf8" }); + if (ffmpeg.status !== 0) + throw new Error(ffmpeg.stderr?.slice(-800) || "offline encode failed"); + return base; +} + +async function renderOfflineSelfPlay({ browser, shell, spec, frames, started, + log }) { + const { id, seed, cap, width, height, theme, ovenStyle, hud, + timeScale = 1 } = spec; + const page = await browser.newPage(); + try { + await page.setViewport({ width, height, deviceScaleFactor: 1 }); + await page.emulateMediaFeatures([ + { name: "prefers-color-scheme", value: theme === "light" ? "light" : "dark" }]); + page.on("pageerror", (error) => log(` ⚠ page ${error.message.slice(0, 140)}`)); + await page.evaluateOnNewDocument((seedValue, wardrobe, opponent) => { + if (wardrobe) globalThis.__oskiewarWardrobe = wardrobe; + if (opponent) globalThis.__oskiewarSelfPlayOpponent = opponent; + let state = seedValue >>> 0; + Math.random = () => { + state = (state + 0x6d2b79f5) >>> 0; + let value = state; + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; + }; + globalThis.__oskiewarSelfPlay = true; + globalThis.__oskiewarDenseReplay = true; + globalThis.__oskiewarOfflineAudioEvents = []; + const realFetch = globalThis.fetch; + globalThis.fetch = function (input, init) { + const address = String(input?.url || input); + if (address.includes("/api/oskiewar-replays")) { + const url = new URL(address); + return realFetch(url.pathname + url.search, init); + } + return realFetch(input, init); + }; + globalThis.WebSocket = function () { + return { readyState: 3, send() {}, close() {}, + addEventListener() {}, removeEventListener() {} }; + }; + }, seed32(seed), spec.wardrobe || "", spec.opponent || ""); + const address = `${shell.origin}/?social-preview&replay-oven&offline-render` + + (hud ? "&reel-hud&reel-full-ui" : "") + + (timeScale === 1 ? "" : `&time-scale=${encodeURIComponent(timeScale)}`); + log(`🎬 ${id} · offline demo · seed "${seed}" · ${width}×${height}` + + ` · ${timeScale}× time · cap ${cap}s`); + await page.goto(address, { waitUntil: "domcontentloaded", timeout: 45000 }); + await page.evaluate(() => document.fonts.ready); + await page.waitForFunction(() => globalThis.__oskiewarOfflineReady === true, + { timeout: 15000 }); + + // `cap` and the result hold are authored simulation durations. A slow + // presentation gets proportionally more 60 fps output frames rather than + // losing the end of the match to a wall-time ceiling. + const tailFrames = Math.round(tailHoldMs / 1000 / timeScale * 60); + const limit = Math.max(1, Math.ceil(cap / timeScale * 60)); + + // Forecast pass: deterministic self-play can reveal its whole impact map + // before we write frame one. Run it quickly in browser-sized chunks with + // no canvas reads, then reload the same seed and hand that schedule to the + // visible pass. Future ticks begin muted and brighten under the playhead. + for (let index = 0; index < limit && !shell.demos.length; index += 120) { + const count = Math.min(120, limit - index); + await page.evaluate((steps) => { + for (let step = 0; step < steps; step++) + globalThis.__oskiewarOfflineStep(); + }, count); + await wait(5); + } + if (!shell.demos.length) + throw new Error(`offline demo forecast exceeded ${cap}s cap`); + const hitForecast = await page.evaluate(() => + (globalThis.__oskiewarFightHitMarks || []).map((mark) => ({ + at: mark.at, color: mark.color, decisive: mark.decisive === true, + }))); + log(` forecast ${hitForecast.length} fight hits`); + shell.resetCapturedReplays(); + await page.evaluateOnNewDocument((forecast) => { + globalThis.__oskiewarFightHitForecast = forecast; + }, hitForecast); + await page.goto(address, { waitUntil: "domcontentloaded", timeout: 45000 }); + await page.evaluate(() => document.fonts.ready); + await page.waitForFunction(() => globalThis.__oskiewarOfflineReady === true, + { timeout: 15000 }); + + const stamps = []; + let zeroMs = null; + let completedAt = -1; + for (let index = 0; index < limit; index++) { + const stepped = await page.evaluate(() => globalThis.__oskiewarOfflineStep()); + if (zeroMs === null) zeroMs = stepped.presentationTime; + const file = `frame-${String(index).padStart(5, "0")}.jpg`; + // Read the canvas, not Chrome's compositor. A screenshot can race a GPU + // surface swap and return an isolated clear-color frame even though the + // simulation and canvas are complete; toDataURL reads the finished + // backing store owned by this exact tick. + const encodedFrame = await page.evaluate(() => + document.querySelector("canvas").toDataURL("image/jpeg", 1)); + writeFileSync(join(frames, file), + Buffer.from(encodedFrame.slice(encodedFrame.indexOf(",") + 1), "base64")); + stamps.push({ file, at: index / 60 }); + if (completedAt < 0 && shell.demos.length) completedAt = index; + if (completedAt >= 0 && index >= completedAt + tailFrames) break; + if ((index + 1) % 120 === 0) + log(` offline ${index + 1} exact frames · ${shell.demos.length ? "result" : "fight"}`); + } + if (!stamps.length) throw new Error("offline demo produced no frames"); + if (!shell.demos.length) throw new Error(`offline demo exceeded ${cap}s cap`); + const events = await page.evaluate(() => globalThis.__oskiewarOfflineAudioEvents || []); + const seconds = stamps.length / 60; + const encoded = await renderOfflineSoundtrack(page, events, zeroMs, seconds); + const soundtrack = Buffer.from(encoded, "base64"); + const base = encodeOfflineMaster({ out: spec.out, frames, stamps, + soundtrack, ovenStyle, width, height }); + const demo = shell.replayBodies.get(shell.demos.at(-1).roundName); + if (!demo) throw new Error("offline demo payload is missing"); + writeFileSync(join(spec.out, "demo.json"), JSON.stringify(demo)); + const signals = events.filter((cue) => cue.kind === "signal") + .map((cue) => ({ event: cue.event, player: cue.player, + t: +((cue.at - zeroMs) / 1000).toFixed(3) })) + .filter(({ t }) => t >= 0 && t <= seconds); + const round = shell.demos.at(-1); + const wall = (Date.now() - started) / 1000; + log(`✓ ${base} [${wall.toFixed(0)}s wall · ${stamps.length} authored frames` + + ` · ${events.length} offline audio cues]`); + return { base, wall, frames: stamps.length, liveFrames: 0, + frameCadence: "offline-demo-60", seconds, matches: [], + rounds: [{ round: round.roundName, winner: round.winner, + seconds: +(round.durationTicks / 60 / timeScale).toFixed(1) }], complete: true, + hasAudio: soundtrack.length > 44, replayPosts: shell.replayPosts, signals }; + } finally { + await page.close(); + } +} + // The wall time of the demo's tick zero — the instant the shipped picture // begins. Every live signal that matches a demo event votes for it: the true // origin collects a vote from each of the hundred-odd of them, coincidences @@ -198,6 +393,9 @@ // `cap` is a ceiling, not a target: recording ends when the game says the // match is over, and only runs out the clock if something has gone wrong. const { id, kind, seed, cap = 240, rounds = 1, width = 1080, height = 1920, theme = "dark", ovenStyle = "cinematic", hud = true, out } = spec; + const requestedScale = Number(spec.timeScale ?? 1); + const timeScale = requestedScale === 0 ? 0 + : Math.max(.05, Math.min(4, Number.isFinite(requestedScale) ? requestedScale : 1)); const started = Date.now(); rmSync(out, { recursive: true, force: true }); const frames = join(out, "frames"); @@ -217,6 +415,12 @@ }); let captured = { frames: 0, seconds: 0, audioBytes: 0 }; try { + if (kind === "self-play") + return await renderOfflineSelfPlay({ browser, shell, + spec: { ...spec, id, seed, cap, width, height, theme, ovenStyle, hud, + timeScale }, + frames, started, log }); + const page = await browser.newPage(); await page.setViewport({ width, height, deviceScaleFactor: 1 }); await page.emulateMediaFeatures([ diff --git a/xbox/live/marketing/render.test.mjs b/xbox/live/marketing/render.test.mjs --- a/xbox/live/marketing/render.test.mjs +++ b/xbox/live/marketing/render.test.mjs @@ -12,10 +12,23 @@ "http://oven.test/tizze50?social-preview&replay-oven&offline-render&reel-hud", ); }); +test("the Replay Oven owns offline simulation and audio", () => { + assert.ok(replayOvenProfile.offlinePasses.includes("simulation")); + assert.ok(replayOvenProfile.offlinePasses.includes("audio")); +}); + test("reviewers can still request a world-only burn", () => { assert.equal( offlineReplayAddress("http://oven.test", "tizze50", { hud: false }), "http://oven.test/tizze50?social-preview&replay-oven&offline-render", + ); +}); + +test("offline replay addresses carry fractional time without changing output fps", () => { + assert.equal( + offlineReplayAddress("http://oven.test", "tizze50", + { timeScale: .25 }), + "http://oven.test/tizze50?social-preview&replay-oven&offline-render&reel-hud&time-scale=0.25", ); }); diff --git a/xbox/live/marketing/replay-oven.mjs b/xbox/live/marketing/replay-oven.mjs --- a/xbox/live/marketing/replay-oven.mjs +++ b/xbox/live/marketing/replay-oven.mjs @@ -12,7 +12,9 @@ sourceQuality: 92, videoCodec: "h264", crf: 14, hud: true, - offlinePasses: Object.freeze(["fixed-step", "contrast", "color", "detail"]), + offlinePasses: Object.freeze([ + "simulation", "audio", "fixed-step", "contrast", "color", "detail", + ]), }); export function bakeReplay(spec, options) { diff --git a/xbox/live/marketing/segments.mjs b/xbox/live/marketing/segments.mjs --- a/xbox/live/marketing/segments.mjs +++ b/xbox/live/marketing/segments.mjs @@ -74,14 +74,14 @@ "retro", "gamedev", "oskiewar"], }, gen: { name: "generative / computational art", - why: "the whole match falls out of one seed; the audio is synthesized live", + why: "the whole match falls out of one seed; the audio shares its offline clock", source: "self-play", - hooks: ["one seed, one fight", "the audio is synthesized", + hooks: ["one seed, one fight", "the audio is synthesized offline", "named by arithmetic", "no samples", "seeded by the date"], tail: "oskiewar.com", captions: [ "Today's date is the seed. It names the match, picks the ball and sets the fighters — the whole bout falls out of one string.", - "Nothing you hear is a sample. Every hit, step and bell is synthesized in the browser at the moment it happens.", + "Nothing you hear is a sample. Every hit, step and bell is synthesized from the same offline demo clock as the frames.", "A date goes in, a match comes out. The reel you are watching was chosen by arithmetic, not by me.", ], tags: ["generativeart", "creativecoding", "computationalart", "webaudio", diff --git a/xbox/live/marketing/shell.mjs b/xbox/live/marketing/shell.mjs --- a/xbox/live/marketing/shell.mjs +++ b/xbox/live/marketing/shell.mjs @@ -148,6 +148,11 @@ origin, demos, replayBodies, get replayPosts() { return posts; }, + resetCapturedReplays() { + posts = 0; + demos.length = 0; + replayBodies.clear(); + }, close: () => new Promise((closed) => server.close(closed)), }; } diff --git a/xbox/live/marketing/verify.mjs b/xbox/live/marketing/verify.mjs --- a/xbox/live/marketing/verify.mjs +++ b/xbox/live/marketing/verify.mjs @@ -95,7 +95,9 @@ spans.push({ from: +start.toFixed(2), to: +last.toFixed(2) }); return spans; } -export function verifySync(reel, signals) { +export function verifySync(reel, signals, { timeScale = 1 } = {}) { + const scale = Math.max(.05, Math.min(4, + Number.isFinite(Number(timeScale)) ? Number(timeScale) : 1)); const windows = rmsWindows(reel); if (!windows.length) return { ok: false, why: "no audio stream to measure" }; const heard = onsets(windows); @@ -152,6 +154,7 @@ matchedOnsets: deltas.length, medianSkew: median, worstSkew: worst, audibleFraction: +audible.toFixed(2), + timeScale: scale, silences: unvoiced, // The gate: the whole track sits within 80ms of the picture, per-onset // jitter stays inside 150ms at the median and 400ms at worst, three @@ -172,6 +175,9 @@ // far underneath. Failing is cheap — the reel is held for a human. ok: median !== null && Math.abs(offset) <= 0.08 && Math.abs(median) <= 0.15 && Math.abs(worst ?? 0) <= 0.4 && coverage >= 0.75 && - !quiet.some((span) => span.to - span.from >= 2.5), + // Silence is measured in authored game time. At quarter speed, a valid + // two-second neutral pause occupies eight presentation seconds; judging + // it against the 1× wall threshold would reject deliberate slow motion. + !quiet.some((span) => span.to - span.from >= 2.5 / scale), }; } diff --git a/xbox/live/oskiewar-sfx.mjs b/xbox/live/oskiewar-sfx.mjs --- a/xbox/live/oskiewar-sfx.mjs +++ b/xbox/live/oskiewar-sfx.mjs @@ -334,11 +334,13 @@ endFrequency: root * rise * 1.45, duration: 0.07, gain: 0.09 * gain, delay: delay + 0.004 }); }; - function playRecipe(recipe, meta = {}) { + function playRecipe(recipe, meta = {}, scheduledAt = null) { const random = seeded(hashText(`${recipe}:${eventCounter++}:${meta.player ?? -1}`)); const variation = 0.97 + random() * 0.06; const cue = { - at: context.currentTime + 0.002, + at: Number.isFinite(scheduledAt) + ? Math.max(0, scheduledAt) + : context.currentTime + 0.002, pan: clamp(meta.pan ?? 0, -1, 1), gain: clamp((meta.intensity ?? 1) * (muted ? 0 : 1), 0, 2), pitch: variation, @@ -429,11 +431,14 @@ async function unlock() { if (!context) context = contextFactory(); if (!context) return false; - if (typeof context.resume === "function" && context.state === "suspended") + // OfflineAudioContext must be completely scheduled before startRendering; + // calling resume() before that render starts throws InvalidStateError. + if (!options.offline && typeof context.resume === "function" && + context.state === "suspended") await context.resume(); if (!output) buildOutput(); if (!noiseBuffer) buildNoiseBuffer(); - unlocked = context.state !== "suspended"; + unlocked = options.offline || context.state !== "suspended"; return unlocked; } @@ -448,12 +453,37 @@ if (!described) return false; return playRecipe(described.recipe, described); } + // The Replay Oven schedules every cue on the demo's fixed tick clock, then + // asks OfflineAudioContext to render the whole score in one pass. Keeping + // this beside signal() guarantees offline and interactive play use the same + // recipes, seeded variation, mixer, and limiter. + function signalAt(at, event, player = -1, value = 0, value2 = 0, + signalOptions = {}) { + if (!unlocked || !context || !output || !Number.isFinite(at)) return false; + const described = describeOskiewarSignal(event, player, value, value2, { + ...signalOptions, + pan: Number.isFinite(signalOptions.pan) ? signalOptions.pan + : playerPans.get(player) ?? 0, + }); + if (!described) return false; + return playRecipe(described.recipe, described, at); + } + function drum(name, amount = 1, pan = 0) { if (!unlocked || !context || !output || !DRUM_ROUTES[name]) return false; return playRecipe(DRUM_ROUTES[name], { player: -1, pan: clamp(Number(pan) || 0, -1, 1), intensity: clamp(Number(amount) || 0, 0, 2), value: 0, value2: 0, }); + } + + function drumAt(at, name, amount = 1, pan = 0) { + if (!unlocked || !context || !output || !DRUM_ROUTES[name] || + !Number.isFinite(at)) return false; + return playRecipe(DRUM_ROUTES[name], { + player: -1, pan: clamp(Number(pan) || 0, -1, 1), + intensity: clamp(Number(amount) || 0, 0, 2), value: 0, value2: 0, + }, at); } function connectSignals(target = globalThis) { @@ -487,7 +517,8 @@ if (ownsContext && options.closeContext && context?.close) context.close(); } return Object.freeze({ - unlock, signal, drum, connectSignals, setPlayerPan, setVolume, destroy, + unlock, signal, signalAt, drum, drumAt, connectSignals, + setPlayerPan, setVolume, destroy, mute(value = true) { muted = Boolean(value); }, get context() { return context; }, get unlocked() { return unlocked; }, diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -21,33 +21,55 @@ return info; }; // Monotonic count of committed revisions to this piece (next revision included). -const buildVersion = 82; +const buildVersion = 83; const floorY = 1800; -// The tower. @jeffrey played the padded room on the console and asked for a -// tall map: the fight should happen on the way up, not back and forth across -// a floor. The room stands 3400 above its floor and the lattice climbs 2320 of -// that in rungs a plain jump apart, so height — not distance — is what a -// fighter now spends a round crossing. -const ceilingY = -1600; -// Thin walls. Eighty was the padded room's bumper, and against a 3000-wide -// room it ate a fifth of the lowest ledge on each side. Forty is a lip you can -// be pinned against rather than a cushion. +// The cube. @jeffrey climbed the tower and asked for the opposite: a small +// closed box — "like a 10ft by 10ft cube" — with nothing in it but two +// fighters, a blade, and a pistol. The map is now a lattice of square tiles +// rather than a pile of hand numbers: ten tiles wide, ten tiles tall, each +// tile 90 units on a side, which makes a fighter (180 tall) exactly two +// tiles and the room exactly the phrase that asked for it. Simulation stays +// continuous — nothing about motion quantizes — but the STRUCTURE is +// addressable: spawns, pickups and lanes are authored in tile coordinates, +// a grid overlay draws the lattice on the back wall, and `gridField` below +// holds one number per tile so per-tile computation (heat, ownership, +// weather) has a surface to run on. +const tileSize = 90; +const gridCols = 10; +const gridRows = 10; +// Tile (0, 0) starts flush at the floor-left corner of the PLAYABLE box, so +// every tile edge lands on a clean multiple of 90. The walls stand outside +// the lattice: the structural shell is one wallThickness beyond each face, +// and `resolveRunnerBounds` pushes off the faces themselves. const wallThickness = 40; -const worldLeft = 0; -// Narrower on purpose. Five thousand was tuned so two fighters could run at -// each other; in a tower the horizontal axis is only there to hold rungs side -// by side, and every unit of extra width costs the console's interpreter -// another column of room wall to submit. -const worldRight = 3000; -const worldNear = -900; -const worldFar = 900; +const gridLeft = 0; +const gridWidth = gridCols * tileSize; +const gridHeight = gridRows * tileSize; +const worldLeft = gridLeft - wallThickness; +const worldRight = gridLeft + gridWidth + wallThickness; +const ceilingY = floorY - gridHeight - wallThickness; +// The cube is as deep as it is wide. Fighters live near z = 0; depth is the +// room's third dimension for the eye, not for the fight. +const worldNear = -gridWidth / 2; +const worldFar = gridWidth / 2; +// Address helpers: world position to tile and back. Rows count UP from the +// floor, because in a fight the floor is where everything starts. All three +// clamp, so a body pressed into a wall still names a real tile. +const tileCol = (x) => + Math.min(gridCols - 1, Math.max(0, Math.floor((x - gridLeft) / tileSize))); +const tileRow = (y) => + Math.min(gridRows - 1, Math.max(0, Math.floor((floorY - y) / tileSize))); +const tileCenterX = (col) => gridLeft + (col + .5) * tileSize; +const tileTopY = (row) => floorY - (row + 1) * tileSize; +// One number per tile, row-major from the floor-left corner. Today impacts +// write heat into it and the overlay draws that heat fading; tomorrow it is +// wherever tiled computation over the map wants to live. +const gridField = new Float32Array(gridCols * gridRows); +const gridFieldIndex = (x, y) => tileRow(y) * gridCols + tileCol(x); // The floor stays flat wall to wall — see terrainFloorAt for why the ramps -// went. Twenty-four samples across 3000 units is 125 a quad, the same spacing -// the old street had at 48 across 5000, and a floor with no slope in it has -// nothing finer to say. The 24 quads that buys back pay for the whole lattice -// three times over. +// went. A flat floor has nothing finer to say than one sample per tile. const terrainAmplitude = 0; -const terrainSamples = 24; +const terrainSamples = gridCols; let terrainPhase = 0; function terrainSeed(value) { let hash = 2166136261; @@ -58,7 +80,7 @@ } return (hash >>> 0) / 4294967296 * Math.PI * 2; } function terrainFloorAt(x) { - // The tower's floor is flat — amplitude zero — and this runs for every + // The cube's floor is flat — amplitude zero — and this runs for every // grass blade and every foot every frame. The constant answers first; the // heightfield math below wakes the day the amplitude does. if (!terrainAmplitude) return floorY; @@ -66,12 +88,10 @@ const nx = clamp((x - worldLeft) / (worldRight - worldLeft), 0, 1); const edge = Math.sin(nx * Math.PI) ** 2; const broad = Math.sin(nx * Math.PI * 3 + terrainPhase); const detail = Math.sin(nx * Math.PI * 7 - terrainPhase * .63) * .34; - // No side skate ramps — @jeffrey asked for them gone. The two quarter-pipes - // met the floor tangentially and carried a fighter 720 up each wall, which - // in a tower is a free rung nobody placed and a slope under the two lowest - // ledges. The floor is flat wall to wall now and the walls stand square out - // of it; `resolveRunnerBounds` is what keeps a body off them, and the - // skateboard's wall-ride was already its own rule rather than the ramp's. + // No side skate ramps — @jeffrey asked for them gone back in the tower, + // and the cube keeps the verdict: the floor is flat wall to wall, the + // walls stand square out of it, and `resolveRunnerBounds` is what keeps a + // body off them. const terrainNoise = (broad + detail) * terrainAmplitude * edge; return floorY - terrainNoise; } @@ -88,61 +108,21 @@ // Leave a narrow projection gutter beneath the floor for the screen-edge HUD. let stageBottom = 930; let viewHeight = 1080; let cameraAspect = (stageRight - stageLeft) / (stageBottom - stageTop); -// The stage furniture is back on, and it is a lattice rather than one ledge: -// drawing, collision, pickup lanes, grenade and ball bounces, the bot's -// sink-and-chase play all read `platforms` below. The wind flag rides its own -// switch and is still off. -const PLATFORM = true; +// The lattice is off: a 10x10 cube holds a plain jump (apex 322, tiles are +// 90) but has no room for a rung a fighter could stand under, and the point +// of the cube is that there is nowhere to go but at each other. The ledge +// machinery below stays, because a tile-authored map is exactly the shape a +// future rung would be written in. The wind flag rides its own switch and is +// still off. +const PLATFORM = false; const WIND_FLAG = false; -// The rung spacing is the jump, not a guess. A jump leaves the ground at 1760 -// against a rise gravity of 4800, so it apexes 1760^2/(2*4800) = 322 above -// whatever it left. A rung 260 up sits inside that with 62 to spare, and the -// fighter stays above its lip for .27s — sixteen simulation ticks, 630 units -// of run — which is what makes a rung landable instead of a pixel-perfect -// stab. Crouch-jumping buys 400 and a double jump 645, so ordinary climbing is -// never gated on precision; those are the margin for a miss. -const platformRise = 260; -// One rung breaks the rule and it is the point of the roof. The crow's nest is -// 760 above the highest thing under it — past a double jump's 645 — so an -// ultra jump (3960, reaching 1633) is the only way in. The rocket launcher -// lives up there because of it. -const crowsNestRise = 760; -// Two columns run clear from the floor to the roof, 920..1120 on the left and -// 1880..2080 on the right. Every span below is cut to leave them open, because -// a tower with no shaft is a tower where a popped ball never comes back to the -// floor and a falling fighter always lands on somebody else's rung. -const shaftLeftX = 1020; -const shaftRightX = 1980; -// Six rungs, one plain jump apart, counted up from the floor. -const rungY = (tier) => floorY - platformRise * tier; -// Rungs alternate side, center, side so a climb reads as a switchback instead -// of a column of steps and neither fighter owns a single vertical line. No -// climbing rung is under 520 wide, because a run covers 630 units inside the -// landing window and anything narrower could be over-run at speed; the crow's -// nest is a 360 perch on purpose, since standing on it is the reward and -// fighting on it should feel like standing on a diving board. No step up is -// more than 340 of open air, which a walk crosses inside the same window. -const platforms = [ - { left: 240, right: 920, y: rungY(1) }, - { left: 2080, right: 2760, y: rungY(1) }, - { left: 1120, right: 1880, y: rungY(2) }, - { left: 180, right: 820, y: rungY(3) }, - { left: 2180, right: 2820, y: rungY(3) }, - { left: 1160, right: 1840, y: rungY(4) }, - { left: 300, right: 900, y: rungY(5) }, - { left: 2100, right: 2700, y: rungY(5) }, - { left: 240, right: 760, y: rungY(6) }, - { left: 1220, right: 1780, y: rungY(6) }, - { left: 2240, right: 2760, y: rungY(6) }, - { left: 1320, right: 1680, y: rungY(6) - crowsNestRise }, -]; +const platforms = []; // Version-one furniture — the wind flag's pole, the store's demos, the tests — -// still asks for "the platform" by name. It is the first rung on the left: the -// one a fighter on a spawn mark can see, one plain hop off the floor, with -// nothing but floor underneath it to sink onto. -const platformLeft = platforms[0].left; -const platformRight = platforms[0].right; -const platformY = platforms[0].y; +// still asks for "the platform" by name. In a one-room cube the floor is the +// only platform, so the name now means the whole playable span of it. +const platformLeft = gridLeft; +const platformRight = gridLeft + gridWidth; +const platformY = floorY; // The surface a thing at (x, y) is standing over: the nearest rung at or below // it, and the floor when there is no rung. Shadows, pickups and the bot all // ask this, so a rung that moves takes its furniture with it. @@ -308,6 +288,9 @@ // so the last thing you pressed is the last thing to leave. const commandStreamDepth = 20; const commandStreamRows = 5; const commandStreamColumns = 8; +const commandStreamColumnsNow = () => compactLayout() ? 4 : commandStreamColumns; +const commandStreamTypeSize = (handleSize) => Math.max(18, + Math.round(handleSize * (compactLayout() ? .8 : .88))); const commandHoldUs = 1100000; const commandFadeUs = 1900000; const replayButtons = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", @@ -469,6 +452,7 @@ this.perspective = spec.perspective; this.fov = spec.fov || 55; this.roll = spec.roll || 0; this.dirty = true; + collapseRenderCameraInterpolation(); } prepare() { @@ -831,10 +815,12 @@ return sun; } const players = [ { name: "@JEFFREY", rosterIndex: 0, handleColors: fighterRoster[0].colors, - // Spawn marks stand 520 apart astride the room's center, between the two - // shafts. Neither one is under a rung, so the opening exchange happens on - // open floor and the first decision of a round is which side to climb. - pad: 0, spawnX: 1240, x: 1240, y: floorY, z: 0, + // Spawn marks stand on the centers of tiles 3 and 6 — mirrored across + // the cube's middle, three tiles from each corner weapon. Three matters: + // a pickup takes any limb within 90, a standing fighter's arms reach + // most of a tile past the mark, and two tiles of gap was close enough + // that the bell handed out both weapons before anyone moved. + pad: 0, spawnX: tileCenterX(3), x: tileCenterX(3), y: floorY, z: 0, vx: 0, vy: 0, vz: 0, facing: 1, grounded: true, ducking: false, previous: [], lastButton: "NONE", lastButtonAt: 0, color: [190, 42, 58], hit: 0, @@ -864,7 +850,7 @@ sinkFrom: 0, crouchJump: false, attackMomentum: 1 }, { name: "@OSKIE", rosterIndex: 2, handleColors: fighterRoster[2].colors, npc: false, bot: false, - pad: 1, spawnX: 1760, x: 1760, y: floorY, z: 0, + pad: 1, spawnX: tileCenterX(6), x: tileCenterX(6), y: floorY, z: 0, vx: 0, vy: 0, vz: 0, facing: -1, grounded: true, ducking: false, previous: [], lastButton: "NONE", lastButtonAt: 0, color: [38, 82, 176], hit: 0, @@ -897,24 +883,22 @@ const impacts = []; const detachedParts = []; const bullets = []; const grenades = []; -// Pickups belong to a surface the player can actually see, and in a tower the -// surface is a rung — so height is what prices a weapon. The handgun and the -// grenades sit on the two first rungs, one hop off either spawn mark. The SMG -// is four rungs up. The rocket launcher lives in the crow's nest, which is the -// one place on the map an ultra jump is the only way in, and it is worth the -// climb precisely because everything else is not. Each `y` names the rung it -// belongs to; the loop below drops it onto that rung's surface. +// The cube's whole arsenal, said out loud: one blade, one pistol, nothing +// else. The SMG, the rocket launcher and the grenades belonged to a tower +// where height priced a weapon; a one-room box has no shelf to price them +// on, and a 900-wide fight gives a spray weapon nothing to miss. Both +// pickups sit on the floor in the corner tiles — the sword on tile 0, the +// pistol on tile 9 — three tiles behind either spawn mark, so the bell +// opens with a choice instead of a handout: turn your back on the other +// fighter to arm up, or rush the middle bare-handed. const gunPickups = [ - { kind: "HANDGUN", amount: 6, x: 2420, startsActive: true, - y: rungY(1), z: 0 }, - { kind: "RUBBER SMG", amount: 18, x: 1500, startsActive: true, - y: rungY(4), z: 0 }, - { kind: "ROCKET LAUNCHER", amount: 3, x: 1500, startsActive: true, - y: rungY(6) - crowsNestRise, z: 0 }, + { kind: "HANDGUN", amount: 6, x: tileCenterX(9), startsActive: true, + y: floorY, z: 0 }, ]; -const grenadePickups = [ - { amount: 2, x: 580, y: rungY(1), z: 0 }, +const swordPickups = [ + { x: tileCenterX(0), startsActive: true, y: floorY, z: 0 }, ]; +const grenadePickups = []; // Two trees grow out of the side walls, one per side, and they are the only // thing in the round that gives a body back. A fighter who has been taken // apart limb by limb can walk to the wall and be whole again — which is worth @@ -930,16 +914,18 @@ ]; const treeRipenUs = 18000000; const treeTimeBonusUs = 15000000; const airParticles = []; -for (const pickup of [...gunPickups, ...grenadePickups]) { +for (const pickup of [...gunPickups, ...swordPickups, ...grenadePickups]) { pickup.active = Boolean(pickup.startsActive); pickup.respawnAt = 0; - // A pickup names its rung and lands on that rung's surface. Seventy above it - // is the same float the single ledge always used, and it keeps a real-sized - // weapon from reading as a decal painted on the plank. + // A pickup names its tile and floats seventy above that tile's surface — + // the same float the old ledges used, and it keeps a real-sized weapon + // from reading as a decal painted on the floor. pickup.y = surfaceYAt(pickup.x, pickup.y) - 70; } // Every kind carries a gravity factor so redressing the match ball in place -// can never leave a previous kind's float behind. +// can never leave a previous kind's float behind. The skateboard is out of +// the deck — @jeffrey asked for no boards on this map — so the series draw +// below can only ever deal a ball. const ballKinds = [ { type: "soccer", spawnOwner: 0, radius: 38, mass: .72, hitScale: 1.12, bounce: .58, drag: .994, windFactor: .58, gravityFactor: 1 }, @@ -947,9 +933,6 @@ { type: "basketball", spawnOwner: 1, radius: 42, mass: 1.08, hitScale: .86, bounce: .76, drag: .989, windFactor: .34, gravityFactor: 1 }, { type: "beach", spawnOwner: -1, radius: 46, mass: .34, hitScale: 1.25, bounce: .82, drag: .998, windFactor: 1.35, gravityFactor: .62 }, - { type: "skateboard", spawnOwner: -1, radius: 52, mass: 1.4, - hitScale: .82, bounce: .34, drag: .982, windFactor: .12, - gravityFactor: 1 }, ]; let matchBallType = ballKinds[0].type; // A match inflates exactly one ball and keeps it for every round. @@ -960,6 +943,81 @@ heldBy: -1 }]; // Version-one replay/spectator consumers still read the first ball by name. const ball = balls[0]; let ballEnabled = true; +// Physics remains an exact 60 Hz story. Rendering may happen between those +// authored instants—especially during slow motion—so retain the state that +// entered each tick and blend only presentation coordinates toward the state +// that left it. The authoritative objects are restored immediately after +// paint; collision, replay capture and networking never see interpolated data. +let renderPreviousState = null; +const renderInterpolationFields = { + player: ["x", "y", "z", "headRoll", "crouchBlend"], + bullet: ["x", "y", "z", "previousX", "previousY"], + grenade: ["x", "y", "z", "previousX", "previousY", "blastRadius"], + ball: ["x", "y", "z", "rotation"], + detached: ["x1", "y1", "z1", "x2", "y2", "z2"], + debris: ["x", "y", "z"], + camera: ["width", "roll", "perspective", "fov"], + cameraPoint: ["x", "y", "z"], +}; + +function renderInterpolationObjects() { + return [ + ...players.map((object) => [object, renderInterpolationFields.player]), + ...bullets.map((object) => [object, renderInterpolationFields.bullet]), + ...grenades.map((object) => [object, renderInterpolationFields.grenade]), + ...balls.map((object) => [object, renderInterpolationFields.ball]), + ...detachedParts.map((object) => [object, renderInterpolationFields.detached]), + ...impacts.flatMap((impact) => (impact.debris || []) + .map((object) => [object, renderInterpolationFields.debris])), + [cameraDoll, renderInterpolationFields.camera], + [cameraDoll.position, renderInterpolationFields.cameraPoint], + [cameraDoll.target, renderInterpolationFields.cameraPoint], + ]; +} + +function captureRenderInterpolationState() { + renderPreviousState = new Map(); + for (const [object, fields] of renderInterpolationObjects()) + renderPreviousState.set(object, Object.fromEntries(fields + .filter((field) => Number.isFinite(object[field])) + .map((field) => [field, object[field]]))); +} + +// A camera snap is an edit, not motion. If it happens inside a simulation +// tick, replace the retained camera sample too; otherwise fractional-speed +// paint blends the new close-up with the old wide shot and frame one lands +// halfway between them. +function collapseRenderCameraInterpolation() { + if (!renderPreviousState) return; + for (const [object, fields] of [ + [cameraDoll, renderInterpolationFields.camera], + [cameraDoll.position, renderInterpolationFields.cameraPoint], + [cameraDoll.target, renderInterpolationFields.cameraPoint], + ]) renderPreviousState.set(object, Object.fromEntries(fields + .filter((field) => Number.isFinite(object[field])) + .map((field) => [field, object[field]]))); +} + +function beginRenderInterpolation(alpha) { + const amount = clamp(Number(alpha), 0, 1); + if (!renderPreviousState || amount >= .999) return () => {}; + const restore = []; + for (const [object, fields] of renderInterpolationObjects()) { + const previous = renderPreviousState.get(object); + if (!previous) continue; + for (const field of fields) { + if (!Number.isFinite(previous[field]) || !Number.isFinite(object[field])) + continue; + restore.push([object, field, object[field]]); + object[field] = lerp(previous[field], object[field], amount); + } + } + cameraDoll.dirty = true; + return () => { + for (const [object, field, value] of restore) object[field] = value; + cameraDoll.dirty = true; + }; +} let padSnapshots = [null, null]; // The pad each fighter actually feels. A bot writes its synthesized presses // here, so the HUD, the debug read-out, the replay and the physics all read @@ -969,6 +1027,8 @@ let startedAt = 0; let roundStartedAt = 0; let lastSimAt = 0; let roundElapsedUs = 0; +const fightHitMarks = []; +globalThis.__oskiewarFightHitMarks = fightHitMarks; let lastCountdownSecond = -1; // The intro's own clock: which "3, 2, 1" second last rang, and whether the // round-open accent has fired. -1 means the accent is still owed. @@ -1203,8 +1263,9 @@ if (!roundIsTimed()) { seriesName = ""; matchName = ""; previousRoundName = ""; - // Spiderdummy training carries exactly one board and no second match ball. - matchBallType = "skateboard"; + // Spiderdummy training carries one plain ball; there is no board to + // carry anymore. + matchBallType = "soccer"; replay = null; spectatorQr = null; return; @@ -2232,6 +2293,12 @@ roundCause = ""; deathCinematic = null; matchOver = false; roundElapsedUs = 0; + fightHitMarks.length = 0; + if (Array.isArray(globalThis.__oskiewarFightHitForecast)) + for (const mark of globalThis.__oskiewarFightHitForecast) + fightHitMarks.push({ at: clamp(Number(mark.at) || 0, 0, 1), + color: Array.isArray(mark.color) ? mark.color.slice(0, 3) : [226, 42, 66], + decisive: mark.decisive === true }); lastCountdownSecond = -1; lastIntroSecond = 0; resultPulseAt = 0; @@ -2300,11 +2367,9 @@ if (globalThis.__oskiewarSelfPlayOpponent === "dummy") players[1].bot = false; startReplay(now); resetRound(now, true); - // The bout's standing matchup: the left corner rides, the right corner - // cuts. One board and one blade per map, dealt at the bell and never - // dealt again — losing either is losing it for the round. - players[0].skateboard = true; - players[1].swordHeld = true; + // No dealt equipment. The cube's blade and pistol wait on their floor + // tiles in self-play exactly as they do in a live match, and the bots arm + // themselves by walking over them — or don't. // The bots' dice are seeded off the round clock, which a re-simulation // cannot reproduce — so a dense demo carries the seeds themselves, and a // replayed sim rolls exactly what the live pass rolled. Spawns ride along @@ -2396,10 +2461,8 @@ if (demo.spawns[index] != null) player.spawnX = demo.spawns[index]; }); replay = null; resetRound(now, true); - // Self-play deals the board and the blade at the bell; the rerun must - // start from the same table. - players[0].skateboard = true; - players[1].swordHeld = true; + // Self-play deals nothing at the bell, so the rerun starts from the same + // bare table: both weapons still on their tiles. if (Array.isArray(demo.botSeeds)) players.forEach((player, index) => { if (demo.botSeeds[index] != null) @@ -2717,12 +2780,12 @@ Object.assign(balls[0], ballKinds.find((kind) => kind.type === matchBallType) || ballKinds[0]); for (const item of balls) { const owner = item.spawnOwner >= 0 ? players[item.spawnOwner] : null; - item.x = owner ? owner.spawnX + owner.facing * 180 : shaftLeftX; + item.x = owner ? owner.spawnX + owner.facing * 180 + : gridLeft + gridWidth / 2; item.y = owner ? terrainFloorAt(item.x) - item.radius - : terrainFloorAt(item.x) - 920; - // An unowned ball begins airborne, and in a tower it has to begin over a - // shaft: every other column catches it on a rung halfway down and the - // round opens with a ball nobody on the floor can reach. + : terrainFloorAt(item.x) - gridHeight + item.radius + 40; + // An unowned ball begins airborne over the cube's center tile seam and + // drops onto the middle of the fight. item.z = owner ? owner.z : 0; item.vx = 0; item.vy = 0; @@ -3077,6 +3140,8 @@ player.gunMode = "HANDGUN"; player.itemArm = ""; player.nextGunShotAt = 0; player.nextSpitAt = 0; + player.spitAt = -Infinity; + player.spitHeavy = false; player.resultReaction = ""; player.resultReactionAt = 0; resultReactionPrevious[player.pad] = []; @@ -3146,7 +3211,7 @@ button.startsWith("Arrow")); player.lastButton = "NONE"; player.lastButtonAt = 0; } - for (const pickup of [...gunPickups, ...grenadePickups]) { + for (const pickup of [...gunPickups, ...swordPickups, ...grenadePickups]) { pickup.active = Boolean(pickup.startsActive); pickup.respawnAt = 0; } @@ -3155,6 +3220,7 @@ tree.growth = 0; tree.spent = false; tree.y = terrainFloorAt(tree.x) - 60; } + gridField.fill(0); nextPowerupAtUs = powerupIntervalUs; powerupSequence = 0; roundResult = ""; @@ -3175,22 +3241,23 @@ resetAirParticles(); resetBalls(now); if (replay) replay.rounds.push([demoTick(now), windDirection, windMph, balls.length]); - // A new round opens on its fighters, not the midpoint of the 25,000-unit - // street. Keeping the stale world midpoint here made the first gameplay - // tick jump thousands of units away from the three-beat portrait intro. + // Frame one belongs to fighter one. Starting from the wide game camera made + // the first portrait spend its whole beat zooming in instead of arriving as + // a portrait; snap to the head, then let the authored sequence cross to the + // other fighter and pull out. cameraCenter = (players[0].x + players[1].x) / 2; cameraWidth = 960; cameraCenterY = (players[0].y + players[1].y) / 2 - 90; cameraContainFloor = 0; - const openingHead = runnerWorldGeometry(players[0], - (now - startedAt) / 1000000).head; - const openingWidth = Math.max(96, openingHead.radius * 6.5); - const openingTarget = { x: openingHead.x, y: openingHead.y, z: openingHead.z }; - cameraDoll.snap({ target: openingTarget, - position: { x: openingTarget.x, y: openingTarget.y, - z: openingTarget.z - Math.max(openingWidth * 1.35, + const poseTime = (now - startedAt) / 1000000; + const firstHead = runnerWorldGeometry(players[0], poseTime).head; + const portraitWidth = Math.max(96, firstHead.radius * 6.5); + const portraitTarget = { x: firstHead.x, y: firstHead.y, z: firstHead.z }; + cameraDoll.snap({ target: portraitTarget, + position: { x: portraitTarget.x, y: portraitTarget.y, + z: portraitTarget.z - Math.max(portraitWidth * 1.35, Math.abs(worldNear) + 400) }, - width: openingWidth, perspective: 0, fov: 55, roll: 0 }); + width: portraitWidth, perspective: 0, fov: 55, roll: 0 }); } // The box the camera packs. It is a fighting-game pushbox rather than the @@ -3260,6 +3327,13 @@ const portraitPull = () => clamp(cameraAspect / (16 / 9), .52, 1); // A reel ends on the win. Nobody is holding a controller, so there is no wide // shot to hand back to — the last thing in frame should be the winner's face. const reelCamera = () => typeof capabilities === "function" && + capabilities().replayOven === true && capabilities().reelHud === true && + capabilities().reelFullUi !== true; +// Ground coverage belongs to every 9:16 oven burn, including the full-UI +// lane. `reelCamera` is narrower: it also opts into minimal-HUD winner framing. +// Reusing that narrower predicate for terrain left full-UI reels with the TV +// slab edge behind the lens, exposing a hard cut beneath the floor. +const reelGroundCamera = () => typeof capabilities === "function" && capabilities().replayOven === true && capabilities().reelHud === true; // Rect-pack: the camera width that makes a world rect exactly fill the @@ -3354,10 +3428,17 @@ // than widening just to recentre a standing pair. const footRoom = 40; // Aim below the fighters' middle, not at it. A vertical frame centered on // the bodies spends its whole upper half on sky; leaning the aim down - // rides them into the upper third with ground filling in beneath. + // rides them into the upper third with ground filling in beneath. The + // lean is priced against how much of the frame the pack already fills: + // in the cube's close-ups two fighters stand most of the view tall, and + // the full tower-era lean pushed their heads over the action-safe line — + // so it fades out as the pack closes on the frame's height. + const packFill = clamp((rect.bottom - rect.top) / Math.max(1, halfHeight), + 0, 2); + const aimLean = halfHeight * .22 * clamp((1.8 - packFill) / .8, 0, 1); let desiredCenterY = halfHeight * 2 >= floorY - ceilingY ? (ceilingY + floorY) / 2 - : clamp((rect.top + rect.bottom) / 2 + halfHeight * .22, + : clamp((rect.top + rect.bottom) / 2 + aimLean, ceilingY + halfHeight, floorY + footRoom - halfHeight); // Fold containment into the target before easing. Clamping the live camera // after easing caused a one-frame reset whenever a fighter crossed the safe @@ -3468,15 +3549,13 @@ roll: 0 }, dt, 7); return; } if (introAge < introDurationUs) { - // Three seconds used to hold one wide two-shot — the widest the camera ever - // got — and it introduced nobody: two figures the height of a thumbnail - // standing apart. It is three beats now. A second on one fighter's face, a - // whip across to the other's, then out to the shot the fight opens on. - // - // The heads come from the same geometry the killcam frames, so a face shot - // is the head's own radius rather than a guess at how big a fighter is. + // One elapsed-time story: first fighter, second fighter, title pullback, + // then the fight. + // Nothing is counted in rendered frames, so live variable speed and an + // offline slow-motion burn tell the identical introduction. const age = introAge / 1000000; - const beat = introDurationUs / 1000000 / 3; + const firstEnd = 1; + const secondEnd = 2; const poseTime = (now - startedAt) / 1000000; const headOf = (player) => (player.frozenGeometry || runnerWorldGeometry(player, poseTime)).head; @@ -3491,15 +3570,15 @@ Math.abs(players[1].x - players[0].x) + 760) * portraitPull(); let target; let width; - if (age < beat * 2) { - // The whip is the ease itself: `track` is still travelling from the first - // face when the second is asked for, so the pan happens for free and at a - // speed the rest of the camera already agrees with. - const head = headOf(players[age < beat ? 0 : 1]); + if (age < secondEnd) { + // Frame one is already on player one. Track easing carries the camera + // across to player two without a cut. + const head = headOf(players[age < firstEnd ? 0 : 1]); target = { x: head.x, y: head.y, z: head.z }; width = faceWidth(head); } else { - const out = clamp((age - beat * 2) / beat, 0, 1); + const out = clamp((age - secondEnd) / + (introDurationUs / 1000000 - secondEnd), 0, 1); const eased = out * out * (3 - out * 2); const head = headOf(players[1]); target = { x: lerp(head.x, opening.x, eased), @@ -3716,45 +3795,73 @@ } emitSignal("bullet", player.pad, pose.dx, pose.dy); } +// The mouth used by both face paint and projectile spawn. Its coordinates are +// authored in face-space, then rotated with a bodyless head. Keeping this one +// transform prevents the visible lips and the physical glob from separating +// while the head rolls. +function spitMouthPose(player) { + const radius = 22; + const direction = player.facing || 1; + const head = { x: player.x, y: player.y - radius }; + const roll = isHeadOnly(player) ? player.headRoll || 0 : 0; + const cosRoll = Math.cos(roll), sinRoll = Math.sin(roll); + const localX = direction * radius * .2; + const localY = radius * .3; + const offsetX = localX * cosRoll - localY * sinRoll; + const offsetY = localX * sinRoll + localY * cosRoll; + // Position and aim are related but not identical: the mouth sits low on the + // face, while spit travels mostly forward with only a small downward pitch. + // Using center->mouth as velocity made that low placement dominate the shot + // and swing it sideways as the head rolled. + // The runner's `facing` sign names its mirrored drawing side; the visible + // face normal points away from that sign. Treating it as the normal sent the + // release upper-left while the mouth presented lower-right. + const aimLocalX = -direction; + const aimLocalY = -.22; + const aimX = aimLocalX * cosRoll - aimLocalY * sinRoll; + const aimY = aimLocalX * sinRoll + aimLocalY * cosRoll; + // Screen/world Y grows downward. Head roll owns the sideways swing, but a + // spit release always pitches below the mouth instead of becoming an + // upward shot during the top half of a roll. + const downwardAimY = Math.abs(aimY); + const aimLength = Math.hypot(aimX, downwardAimY) || 1; + return { + x: head.x + offsetX, + y: head.y + offsetY, + outX: aimX / aimLength, + outY: downwardAimY / aimLength, + roll, direction, + }; +} + function spit(player, heavy = false) { const now = runtime().monotonicUs; if (now < (player.nextSpitAt || 0)) return; - const direction = player.facing || 1; - // Spit leaves the mouth — not the chest — as a lob: slow enough to watch, - // tossed upward for gravity to bring back down. The mouth is derived from - // sim state ONLY (feet, ducking, head-only), never from runnerWorldGeometry: - // pose geometry follows the wall clock on the live pass and the exact - // stepper on the re-sim, and hanging the spawn on it made the same round - // play out two different ways — 258 units of re-sim drift from a ±7-unit - // breath sway. The fixed numbers are the standing/crouched head heights. - const headY = isHeadOnly(player) ? player.y - 22 - : player.y - (player.ducking ? 108 : 180) + 22; + const mouth = spitMouthPose(player); + const direction = mouth.direction; // A rolling head spits WHERE ITS MOUTH POINTS. The lob leaves along the // head's current angle, so aim on a bodyless head is a timing skill — wait // for the roll to face your rival, or spit skyward and let it rain. Bodied // fighters have no roll and lob flat-forward as before. headRoll is sim // state (integrated in updatePlayers), so the re-sim agrees. - const roll = isHeadOnly(player) ? player.headRoll || 0 : 0; - const cosRoll = Math.cos(roll), sinRoll = Math.sin(roll); - const aim = (x, y) => ({ x: x * cosRoll - y * sinRoll, - y: x * sinRoll + y * cosRoll }); - const mouthOffset = aim(direction * 15, 6); - const mouthX = player.x + mouthOffset.x; - const mouthY = headY + mouthOffset.y; - // Inches, not feet, and slow — an analog lob that visibly goes UP, hangs, - // and falls on a lazy parabola. A spit fight is something two faces have - // up close, not artillery: more height than reach, over a second in the - // air, all of it watchable. - const toss = aim(direction * (heavy ? 90 : 120), heavy ? -480 : -420); + // Release at the visible lips, mostly face-forward with a slight downward + // pitch. Both the spawn and aim use the same rolling face transform. + const speed = heavy ? 520 : 680; + const toss = { x: mouth.outX * speed, y: mouth.outY * speed }; bullets.push({ - x: mouthX, y: mouthY, z: player.z, - previousX: mouthX, previousY: mouthY, + x: mouth.x, y: mouth.y, z: player.z, + previousX: mouth.x, previousY: mouth.y, vx: toss.x, vy: toss.y, owner: player.pad, life: 1, spit: true, heavy, - safeUntil: now + 90000, + // Let the glob visibly clear the lips and the spitter's own fallen limbs. + // Without this, a bodyless fighter often spat directly into its debris + // halo and produced only a yellow impact puff on the release frame. + safeUntil: now + 220000, }); while (bullets.length > 24) bullets.shift(); player.nextSpitAt = now + (heavy ? 520000 : 260000); + player.spitAt = now; + player.spitHeavy = heavy; player.lastButton = heavy ? "HEAVY SPIT" : "SPIT"; player.lastButtonAt = now; player.pendingMoveLabel = player.lastButton; @@ -3821,6 +3928,28 @@ } } } +// The blade is taken by walking over it, like every other weapon — but it is +// not ammo, so there is no amount and no top-up: one sword a round, and once +// a hand closes on it the only way to hold it is to still be holding it. +// (The STOLE table below can move it between fighters; the floor cannot.) +function updateSwordPickups(now) { + const poseTime = (now - startedAt) / 1000000; + for (const pickup of swordPickups) { + if (!pickup.active) continue; + for (const player of players) { + if (!player.alive || player.swordHeld || !availableArm(player) || + runnerDistanceToPoint(player, poseTime, + pickup.x, pickup.y, pickup.z) > 90) continue; + player.swordHeld = true; + pickup.active = false; + remember(player, "SWORD"); + playDrum("clap", 1.1, panPlayer(player)); + emitSignal("pickup", player.pad, 3, 1); + break; + } + } +} + // Ripe fruit is a new body and fifteen seconds. The clock is `roundElapsedUs` // counting up toward `roundDurationUs`, so winding it back is what "adds // time" means here — and it is clamped at zero, because a tree cannot make @@ -3832,8 +3961,15 @@ // against that wall still has to be able to reach the fruit from a jump. One // function owns the geometry so the picture and the pickup test agree. function treeFruit(tree) { const toward = tree.x < (worldLeft + worldRight) / 2 ? 1 : -1; - const reach = 90 + 330 * tree.growth; - return { x: tree.x + toward * (reach * .5 + 60), y: tree.y - reach * .9, + // Cube-scale tree. The tower's tree leaned three tiles into a room that + // had thirty; the same lean in a ten-tile cube put ripe fruit inside a + // spawn-idle fighter's reach, and two fighters who never moved wound the + // clock back fifteen seconds every eighteen — no round could end. This + // tree climbs about two tiles of wall, leans under one tile in, and hangs + // its fruit high enough that picking it is a jump at the wall, never a + // side effect of standing near one. + const reach = 45 + 150 * tree.growth; + return { x: tree.x + toward * (reach * .35 + 30), y: tree.y - reach * 1.3, z: tree.z, toward, reach, tipX: tree.x + toward * reach * .5, tipY: tree.y - reach * .9 }; } @@ -3856,7 +3992,10 @@ } const fruit = treeFruit(tree); for (const player of players) { if (!player.alive) continue; - if (runnerDistanceToPoint(player, poseTime, fruit.x, fruit.y, fruit.z) > 320) + // A tile and a third — a hand up at the fruit, not the whole corner of + // the room. Sized with treeFruit above so a spawn-mark idler stays out + // of range; the two limits only hold together. + if (runnerDistanceToPoint(player, poseTime, fruit.x, fruit.y, fruit.z) > 120) continue; tree.growth = 0; tree.spent = true; @@ -3886,10 +4025,12 @@ : mixColor([96, 140, 104], [44, 84, 60], visualTheme.light); const tipY = crownY + sway; worldCapsule(tree.x, tree.y, tree.z, tipX, tipY, tree.z, Math.max(2, (11 + 9 * tree.growth) * scale), bark); + // Canopy sized with treeFruit's cube-scale lean: fronds stay under a tile + // long so the tree dresses its wall without reaching into the fight. for (const spread of [-1, -.35, .35, 1]) { worldCapsule(tipX, tipY, tree.z, - tipX + toward * (60 + 90 * tree.growth), - tipY + spread * (60 + 70 * tree.growth), tree.z, + tipX + toward * (30 + 45 * tree.growth), + tipY + spread * (30 + 35 * tree.growth), tree.z, Math.max(2, (8 + 8 * tree.growth) * scale), leaf); } if (!ripe) return; @@ -3901,19 +4042,17 @@ } function updatePowerups(now) { while (roundElapsedUs >= nextPowerupAtUs) { - const occupied = [...gunPickups, ...grenadePickups] + const occupied = [...gunPickups, ...swordPickups] .some((pickup) => pickup.active); if (!occupied) { - const choices = [...gunPickups, grenadePickups[0]]; - const pickup = choices[powerupSequence % choices.length]; + // Only the pistol comes back. The sword is a round-long commitment + // once lifted, so the cycle's job is just to keep six more rounds of + // ammo appearing somewhere worth crossing the cube for — the corner + // tiles, alternating, so the reload never lives on one fighter's side. + const pickup = gunPickups[0]; pickup.active = true; - // The two first rungs are the powerup lanes. Alternating sides keeps a - // real-sized weapon from always landing at the same spot, and a first - // rung is one hop off either spawn mark, so neither fighter starts - // nearer the gun than the other. - const lane = platforms[powerupSequence % 2]; - pickup.x = (lane.left + lane.right) / 2; - pickup.y = lane.y - 70; + pickup.x = tileCenterX(powerupSequence % 2 === 0 ? 0 : 9); + pickup.y = surfaceYAt(pickup.x, floorY) - 70; pickup.z = 0; powerupSequence += 1; emitSignal("powerup", -1, powerupSequence, nextPowerupAtUs / 1000000); @@ -3922,6 +4061,7 @@ } nextPowerupAtUs += powerupIntervalUs; } updateGunPickups(now); + updateSwordPickups(now); updateGrenadePickups(now); } @@ -4046,6 +4186,7 @@ const shot = { x1: bullet.previousX ?? bullet.x, y1: bullet.previousY ?? bullet.y, z1: bullet.z, x2: bullet.x, y2: bullet.y, z2: bullet.z, width: 48 }; for (const fragment of detachedParts) { + if (fragment.owner === bullet.owner && now < bullet.safeUntil) continue; const contact = segmentSegmentClosest(shot, fragment); if (contact.distance > (shot.width + fragment.width) / 2) continue; const impulse = bullet.heavy ? 1500 : bullet.spit ? 900 : 1200; @@ -4515,11 +4656,10 @@ // ball for a moment, not for good. It re-inflates over the middle once its // serve time comes round. if (!ball.active && ballEnabled && ball.serveAt && now >= ball.serveAt) { ball.active = true; - // Re-inflate over a shaft and let it rain the whole tower back down: over - // a rung it would land four storeys above the fight and stay there. The - // far shaft from wherever it popped, so a fighter camped under one column - // does not also own the ball's return. - ball.x = ball.x > (worldLeft + worldRight) / 2 ? shaftLeftX : shaftRightX; + // Re-inflate on the far side from wherever it popped, so the fighter who + // owned the ball's exit does not also own its return. + ball.x = ball.x > (worldLeft + worldRight) / 2 + ? tileCenterX(2) : tileCenterX(7); ball.y = ceilingY + ball.radius + 120; ball.z = 0; ball.vx = 0; @@ -4782,6 +4922,7 @@ } function killPlayer(target, killerPad, now, cause = "KO") { if (!target.alive) return; + recordFightHit(killerPad, true); releaseCarriedBall(target, now); if (!deathCinematic && killerPad !== target.pad) deathCinematic = { startedAt: now, loserPad: target.pad, @@ -5210,10 +5351,10 @@ target.grenadeAmmo = 0; player.itemArm = availableArm(player); target.itemArm = ""; label = "GRENADES"; - } else if (target.skateboard) { - target.skateboard = false; - player.skateboard = true; - label = "SKATEBOARD"; + } else if (target.swordHeld && !player.swordHeld) { + target.swordHeld = false; + player.swordHeld = true; + label = "SWORD"; } if (!label) return false; target.lastButton = label + " TAKEN"; @@ -5969,6 +6110,7 @@ return { connected: true, down: botDown(player, now), leftX: 0, leftY: 0 }; } function gameSim() { + captureRenderInterpolationState(); syncGameView(); const now = runtime().monotonicUs; const dt = Math.min(0.04, Math.max(0.001, (now - lastSimAt) / 1000000)); @@ -6161,6 +6303,14 @@ recordReplayCheckpoint(now); trackResimDrift(now); for (const impact of impacts) { if (!impact.debris) { + // A fresh impact heats the tile it landed in. The debris check is the + // once-per-impact gate — the first sim tick is the only tick an impact + // exists without motes — so a hit stamps its cell exactly once, and + // the overlay on the back wall shows the fight's last half-second as + // cooling squares. + const cell = gridFieldIndex(impact.x, impact.y); + gridField[cell] = Math.min(1, gridField[cell] + + (impact.explosion ? 1 : impact.death ? .9 : .55)); const count = impact.explosion ? 24 : impact.death ? 10 : 6; impact.debris = Array.from({ length: count }, (_, index) => { const angle = index / count * Math.PI * 2 + @@ -6191,6 +6341,12 @@ } impact.life -= dt; } while (impacts.length && impacts[0].life <= 0) impacts.shift(); + // The tile field cools between stamps — a fixed exponential against the + // fixed 60 Hz step, so a replayed round heats and fades identically. Cells + // snap to zero below a hundredth so a quiet map is exactly zeros. + for (let cell = 0; cell < gridField.length; cell++) + gridField[cell] = gridField[cell] < .01 ? 0 + : gridField[cell] * Math.exp(-dt * 1.6); if (players.some((player) => !player.alive) || (timedRound && roundElapsedUs >= roundDurationUs)) { if (timedRound && roundElapsedUs >= roundDurationUs && @@ -7276,6 +7432,7 @@ } function applyBodyHit(target, segmentIndex, sourceX, sourcePad, now, force = 1100, lift = 150, damageParts = true) { + recordFightHit(sourcePad, false); const direction = Math.sign(target.x - sourceX) || (sourcePad === target.pad ? -target.facing : target.facing) || 1; releaseCarriedBall(target, now); @@ -7850,6 +8007,8 @@ const eyeWidth = Math.max(1.4, r * .1); const lineWidth = Math.max(1.2, r * .1); const blink = player.alive && !player.blocking && !player.attackKind && Math.sin(t * .73 + player.pad * 2.1) > .985; + const spitAge = now - (player.spitAt || -Infinity); + const spitting = isHeadOnly(player) && spitAge >= 0 && spitAge < 190000; const inertDummy = player.npc && !player.bot; const victoryAmount = deathCinematic?.winnerPad === player.pad ? clamp(deathCinematicAge(now) / 1.15, 0, 1) : 0; @@ -7860,6 +8019,15 @@ faceX + offset + eyeWidth, eyeY + eyeWidth, lineWidth); stroke(faceX + offset + eyeWidth, eyeY - eyeWidth, faceX + offset - eyeWidth, eyeY + eyeWidth, lineWidth); } + } else if (spitting) { + // The release reads on the face before the glob clears it: both eyes + // squeeze inward for the same short beat as the pursed lips below. + for (const offset of [-eyeGap, eyeGap]) + stroke(faceX + offset - eyeWidth * 1.35, + eyeY + (offset < 0 ? -1 : 1) * r * .035, + faceX + offset + eyeWidth * 1.35, + eyeY + (offset < 0 ? 1 : -1) * r * .035, + lineWidth * 1.15); } else if (blink) { stroke(faceX - eyeGap - eyeWidth, eyeY, faceX - eyeGap + eyeWidth, eyeY, lineWidth); @@ -7919,7 +8087,14 @@ faceX + offset, eyeY + eyeWidth * 1.5 + r * .34 + fall, Math.max(1.5, lineWidth * .72), tear); } const mouthY = head.y + r * .3; - if (player.resultReaction === "LAUGH") { + if (spitting) { + const release = 1 - clamp(spitAge / 190000, 0, 1); + const lip = player.spitHeavy ? [226, 58, 126] : [232, 92, 132]; + // faceX already carries .08r of facing offset; another .12r makes this + // exactly the .20r face-space mouth used by spitMouthPose(). + ring(faceX + direction * r * .12, mouthY, + r * (.105 + release * .055), Math.max(.7, r * .055), lip); + } else if (player.resultReaction === "LAUGH") { const age = Math.max(0, (now - player.resultReactionAt) / 1000000); const open = .35 + Math.abs(Math.sin(age * 18)) * .65; ring(faceX + direction * r * .08, mouthY, @@ -8411,6 +8586,10 @@ return [...code].map((letter) => String.fromCodePoint( 0x1f1e6 + letter.charCodeAt(0) - 65)).join(""); } +const reelProgressInset = () => typeof capabilities === "function" && + capabilities().replayOven === true && capabilities().reelFullUi === true + ? compactLayout() ? 42 : 50 : 0; + function playerHandleLayout(player, side) { const safe = hudSafeRect(); const touch = typeof capabilities === "function" && @@ -8421,7 +8600,7 @@ // One name per corner: the left fighter reads from the left edge, the // right fighter from the right. The command phrase mirrors to the inside, // so neither name has to give up its corner to make room for it. const x = side === 0 ? safe.left + 8 : safe.right - 8 - width; - const y = safe.bottom - size - (touch ? 250 : 18); + const y = safe.bottom - size - (touch ? 250 : 18) - reelProgressInset(); return { x, y, size, width }; } @@ -8573,7 +8752,11 @@ const handle = playerHandleLayout(player, side); const size = Math.round(hudTypeSize * .62); const width = handleWidth(text, size); const x = side === 0 ? handle.x : handle.x + handle.width - width; - const y = handle.y - statStackHeight() - size - 7; + // Commands own the rows immediately above the fighter's name. Inventory + // starts above whichever of those rows are still visible, so neither can + // drift over the other as the command history dissolves. + const y = handle.y - statStackHeight() - commandStreamStackHeight(player) - + size - 7; const shadow = contrastShadow(player.color); typeWrite(text, x + 2, y + 3, size, ...shadow); typeWrite(text, x, y, size, ...player.color); @@ -8599,6 +8782,20 @@ const settle = clamp((idle - commandHoldUs) / commandFadeUs, 0, 1); return clamp(index / Math.max(1, count - 1) + 1 - settle * 2, 0, 1); } +function commandStreamStackHeight(player, now = runtime().monotonicUs) { + const count = player.commandStream.length; + if (!count) return 0; + const idle = now - (player.commandStream.at(-1)?.at || now); + const visible = player.commandStream.reduce((total, entry, index) => + total + (commandFade(index, count, idle) > .01 ? 1 : 0), 0); + const handle = playerHandleLayout(player, player.pad); + const size = commandStreamTypeSize(handle.size); + const rows = Math.min(commandStreamRows, + Math.ceil(Math.min(commandStreamDepth, visible) / + commandStreamColumnsNow())); + return rows * (size + 7); +} + function drawCommandStream(player, side) { const glyph = { LEFT: "<", RIGHT: ">", UP: "^", DOWN: "v" }; const buttonFor = { LEFT: "ArrowLeft", RIGHT: "ArrowRight", @@ -8625,28 +8822,28 @@ if (claimed.has(entry.label)) entry.held = false; else claimed.add(entry.label); } if (!entries.length) return; - // Commands emerge beside the handle on its inward side — right of the - // left corner's name, left of the right corner's. Eight glyphs form a - // phrase; completed phrases rise as a unit while a fresh phrase takes the - // handle lane beneath it. + // The name owns its row. Commands stack in distinct rows above it, aligned + // to the same outside edge, so a long handle can never sit underneath its + // own input history. entries.reverse(); const handle = playerHandleLayout(player, side); - const size = handle.size; + const size = commandStreamTypeSize(handle.size); + const columns = commandStreamColumnsNow(); const lineEntries = entries.slice(0, commandStreamDepth); const gap = Math.round(size * .34); const rows = []; - for (let index = 0; index < lineEntries.length; index += 8) - rows.push(lineEntries.slice(index, index + 8)); + for (let index = 0; index < lineEntries.length; index += columns) + rows.push(lineEntries.slice(index, index + columns)); const safe = hudSafeRect(); for (let row = 0; row < rows.length; row++) { const rowEntries = rows[row]; const width = rowEntries.reduce((sum, entry, index) => sum + handleWidth(entry.text, size) + (index ? gap : 0), 0); - let cursor = side === 0 ? handle.x + handle.width + 12 - : handle.x - 12 - width; + let cursor = side === 0 ? handle.x : handle.x + handle.width - width; + cursor = clamp(cursor, safe.left, safe.right - width); const newestAt = Math.max(...rowEntries.map((entry) => entry.at)); const rise = clamp((now - newestAt) / 900000, 0, 1) * size * .8; - const y = handle.y - statStackHeight() - row * (size + 7) - rise; + const y = handle.y - statStackHeight() - (row + 1) * (size + 7) - rise; for (const entry of rowEntries) { const quiet = mixColor([104, 114, 136], [82, 90, 108], visualTheme.light); const live = entry.held ? player.color : quiet; @@ -8666,56 +8863,103 @@ const centerX = viewCenterX(); const centerY = (stageTop + stageBottom) / 2; const touch = typeof capabilities === "function" && capabilities().inputFamily === "touch"; - const nameSize = touch ? 32 : compactLayout() ? 48 : 74; - const drawCenteredName = (player) => { - const width = handleWidth(visibleHandle(player), nameSize); - drawFloatingHandle(player, centerX - width / 2, centerY - nameSize / 2, - nameSize); + const nameSize = touch ? 28 : compactLayout() ? 38 : 54; + const drawHeadName = (player) => { + const head = runnerWorldGeometry(player, + (runtime().monotonicUs - startedAt) / 1000000).head; + const point = projectPoint(head.x, head.y, head.z); + const edge = projectPoint(head.x + head.radius, head.y, head.z); + const radius = Math.max(8, Math.hypot(edge.x - point.x, edge.y - point.y)); + const flash = .5 + .5 * Math.sin(introSeconds * Math.PI * 8); + const flashingSize = nameSize * (1 + flash * .1); + const width = handleWidth(visibleHandle(player), flashingSize); + drawFloatingHandle(player, point.x - width / 2, + point.y - radius - flashingSize * 1.28, flashingSize); }; - if (introSeconds < .65) { - drawCenteredName(players[0]); + if (introSeconds < 1) { + drawHeadName(players[0]); return; } - if (introSeconds < 1.3) { - drawCenteredName(players[1]); + if (introSeconds < 2) { + drawHeadName(players[1]); + return; + } + { + const title = "oskiewar"; + const titleSize = touch ? 54 : compactLayout() ? 72 : 98; + const width = handleWidth(title, titleSize); + let cursor = centerX - width / 2; + const titleTime = (runtime().monotonicUs - startedAt) / 1000000; + for (let index = 0; index < title.length; index++) { + const character = title[index]; + const advance = comicGlyphAdvance(character, titleSize); + typeWrite(character, cursor + 5, centerY - titleSize / 2 + 6, + titleSize, ...statusShadow); + typeWrite(character, cursor, centerY - titleSize / 2, + titleSize, ...animatedTitleColor(index, titleTime)); + cursor += advance; + } return; } +} - const andText = "and"; - const andSize = nameSize * .72; - const firstWidth = handleWidth(visibleHandle(players[0]), nameSize); - const secondWidth = handleWidth(visibleHandle(players[1]), nameSize); - const andWidth = handleWidth(andText, andSize); - const pairGap = compactLayout() ? 320 - 30 : 320; - const pairWidth = firstWidth + pairGap + andWidth + pairGap + secondWidth; - const pairLeft = centerX - pairWidth / 2; - const pairStarts = [pairLeft, - pairLeft + firstWidth + pairGap + andWidth + pairGap]; - const travel = clamp((introSeconds - 1.3) / .6, 0, 1); - const eased = travel * travel * (3 - travel * 2); - for (let side = 0; side < players.length; side++) { - const player = players[side]; - const target = playerHandleLayout(player, side); - const startX = pairStarts[side]; - const startY = centerY - target.size / 2; - drawFloatingHandle(player, lerp(startX, target.x, eased), - lerp(startY, target.y, eased), lerp(nameSize, target.size, eased)); - } - const andY = centerY - andSize / 2; - if (introSeconds < 2.45) { - const andX = centerX - andWidth / 2; - typeWrite(andText, andX + 4, andY + 5, andSize, ...statusShadow); - typeWrite(andText, andX, andY, andSize, ...titleInk); - return; +function drawReelSectionProgress(now, titleInk) { + const safe = hudSafeRect(); + const gap = compactLayout() ? 5 : 8; + const widths = [.16, .68, .16]; + const labels = ["INTRO", "FIGHT", "OUTRO"]; + const available = safe.right - safe.left - gap * 2; + const resultDuration = matchOver ? matchResultUs : roundResultUs; + const introAge = Math.max(0, now - roundStartedAt); + const section = roundResult ? 2 : introAge < introDurationUs ? 0 : 1; + const progress = section === 0 + ? clamp(introAge / introDurationUs, 0, 1) + : section === 1 + ? clamp(roundElapsedUs / roundDurationUs, 0, 1) + : clamp((now - roundOverAt) / resultDuration, 0, 1); + const labelSize = compactLayout() ? 18 : 22; + const barHeight = compactLayout() ? 6 : 8; + const barY = safe.bottom - barHeight; + const labelY = barY - labelSize - 7; + const track = mixColor([52, 58, 76], [170, 157, 137], visualTheme.light); + const done = mixColor([96, 222, 154], [31, 92, 76], visualTheme.light); + const hitMarks = Array.isArray(globalThis.__oskiewarFightHitForecast) + ? globalThis.__oskiewarFightHitForecast : fightHitMarks; + let x = safe.left; + for (let index = 0; index < widths.length; index++) { + const width = index === widths.length - 1 + ? safe.right - x : Math.round(available * widths[index]); + const amount = index < section ? 1 : index === section ? progress : 0; + box(x, barY, width, barHeight, ...track); + if (amount > 0) box(x, barY, width * amount, barHeight, + ...(index === section ? titleInk : done)); + if (index === 1) { + const markWidth = compactLayout() ? 5 : 6; + for (const mark of hitMarks) { + const markX = x + clamp(mark.at, 0, 1) * width; + const reached = roundElapsedUs / roundDurationUs >= mark.at; + const impactInk = mark.decisive ? [226, 42, 66] : mark.color; + box(markX - markWidth / 2, barY - 3, + markWidth, barHeight + 6, + ...(reached ? impactInk : mixColor(track, impactInk, .72))); + } + } + const labelWidth = handleWidth(labels[index], labelSize); + typeWrite(labels[index], x + (width - labelWidth) / 2, + labelY, labelSize, ...(index === section ? titleInk : track)); + x += width + gap; } - const fightAge = clamp((introSeconds - 2.45) / .55, 0, 1); - const fightSize = lerp(72, 96, Math.sin(fightAge * Math.PI)); - const startText = "start"; - const startWidth = startText.length * fightSize * .58; - typeWrite(startText, centerX - startWidth / 2 + 5, - centerY - fightSize / 2 + 6, fightSize, ...statusShadow); - typeWrite(startText, centerX - startWidth / 2, - centerY - fightSize / 2, fightSize, ...titleInk); +} + +function recordFightHit(sourcePad, decisive) { + if (roundResult || roundElapsedUs <= 0) return; + if (Array.isArray(globalThis.__oskiewarFightHitForecast)) return; + const fighter = players[sourcePad]; + fightHitMarks.push({ + at: clamp(roundElapsedUs / roundDurationUs, 0, 1), + color: fighter?.color ? [...fighter.color] : [226, 42, 66], + decisive: decisive === true, + }); } // A pole or a branch crossing the near plane has the same problem a face does, @@ -8846,7 +9090,7 @@ // reels the skirt hangs just behind the fighters' plane instead, deep enough // that no frame sees under it, and wears the ground color — so everything // below the floor line reads as earth. const step = (worldRight - worldLeft) / terrainSamples; - const reel = reelCamera(); + const reel = reelGroundCamera(); const wallZ = reel ? 55 : near - 2; const wallBottom = floorY + (reel ? 9000 : 720); const first = clamp(Math.floor((left - worldLeft) / step), 0, terrainSamples - 1); @@ -8877,7 +9121,7 @@ // Reels return early. That lens already hangs its single skirt just behind // the fighters at z=55, and a second wall out at worldFar would sit behind // that one where no reel frame ever looks -- pure cost, and a change to // footage whose framing is test-enshrined. - if (reelCamera()) return; + if (reelGroundCamera()) return; const step = (worldRight - worldLeft) / terrainSamples; const wallZ = far + 2; const wallBottom = floorY + 720; @@ -8914,6 +9158,7 @@ worldQuad({ x: worldLeft, y: ceilingY, z: worldFar }, { x: worldRight, y: ceilingY, z: worldFar }, { x: worldRight, y: wallBottom, z: worldFar }, { x: worldLeft, y: wallBottom, z: worldFar }, shade); + drawGridOverlay(shade); // The left side is a real wall, but only occupies the rear half of the room. // Segmenting it keeps the diorama corner visible without recreating the old // full-depth opaque slab that could pass in front of the camera and blackout @@ -8946,6 +9191,44 @@ worldLine(worldRight, top, worldFar - 4, worldRight, bottom, worldFar - 4, 9, edge); } +// The map's addressing, made visible: the same ten-by-ten lattice the spawn +// marks and pickups are authored on, ruled onto the back wall, with one tint +// per heated cell of `gridField` fading as the field cools. Seams are thin +// quads rather than worldLines because the native renderer buries the whole +// line stratum beneath the world's triangles — the grid would vanish behind +// its own wall on console, the same way the gun once drew under the floor. +function drawGridOverlay(shade) { + const gridZ = worldFar - 2; + const gridTop = floorY - gridHeight; + const hot = mixColor([255, 138, 92], [204, 58, 46], visualTheme.light); + for (let cell = 0; cell < gridField.length; cell++) { + const heat = gridField[cell]; + if (heat < .03) continue; + const left = gridLeft + (cell % gridCols) * tileSize; + const bottom = floorY - Math.floor(cell / gridCols) * tileSize; + worldQuad({ x: left, y: bottom - tileSize, z: gridZ }, + { x: left + tileSize, y: bottom - tileSize, z: gridZ }, + { x: left + tileSize, y: bottom, z: gridZ }, + { x: left, y: bottom, z: gridZ }, mixColor(shade, hot, heat * .55)); + } + const seam = mixColor(shade, [30, 34, 48], .22); + const seamZ = gridZ - 1; + for (let col = 0; col <= gridCols; col++) { + const x = gridLeft + col * tileSize; + worldQuad({ x: x - 1.5, y: gridTop, z: seamZ }, + { x: x + 1.5, y: gridTop, z: seamZ }, + { x: x + 1.5, y: floorY, z: seamZ }, + { x: x - 1.5, y: floorY, z: seamZ }, seam); + } + for (let row = 0; row <= gridRows; row++) { + const y = floorY - row * tileSize; + worldQuad({ x: gridLeft, y: y - 1.5, z: seamZ }, + { x: gridLeft + gridWidth, y: y - 1.5, z: seamZ }, + { x: gridLeft + gridWidth, y: y + 1.5, z: seamZ }, + { x: gridLeft, y: y + 1.5, z: seamZ }, seam); + } +} + function drawTerrainGrass(left, right, color) { // Twenty deterministic tufts cost forty native lines total. Keeping them on // the line layer avoids turning background dressing into hundreds of faces. @@ -9240,18 +9523,38 @@ pickup.x - 3, bobY + 30, pickup.z, gripWidth * 1.5, [38, 45, 42], -.02); } } +// The blade waits planted tip-down in its tile, wearing the same steel, +// guard and grip the held sword draws in drawInventory, so what you cross +// the cube for is recognizably the thing you then hold. Capsules for the +// same reason the gun uses them: the line stratum sinks beneath the world's +// triangles on console. +function drawSwordPickup(pickup, t) { + if (!pickup.active) return; + const bobY = pickup.y + Math.sin(t * 2.8 + pickup.x * .001) * 8; + const scale = cameraScale(); + const steel = mixColor([188, 197, 208], [104, 112, 126], visualTheme.light); + const guard = mixColor([148, 118, 74], [96, 76, 48], visualTheme.light); + worldCapsule(pickup.x, bobY - 96, pickup.z, pickup.x, bobY + 42, pickup.z, + Math.max(3, 7 * scale), steel, -.02); + worldCapsule(pickup.x - 22, bobY - 92, pickup.z, + pickup.x + 22, bobY - 92, pickup.z, + Math.max(2, 5 * scale), guard, -.02); + worldCapsule(pickup.x, bobY - 96, pickup.z, pickup.x, bobY - 118, pickup.z, + Math.max(2, 5 * scale), [34, 30, 40], -.02); +} + function drawBullet(bullet) { const previous = projectPoint(bullet.previousX ?? bullet.x, bullet.previousY ?? bullet.y, bullet.z); const point = projectPoint(bullet.x, bullet.y, bullet.z); const blink = Math.floor(runtime().monotonicUs / 65000 + bullet.owner) % 2; - const core = blink - ? [255, 255, 248] - : bullet.spit ? bullet.heavy ? [255, 92, 174] : [118, 255, 196] + const core = bullet.spit + ? bullet.heavy ? [255, 92, 174] : [118, 255, 196] + : blink ? [255, 255, 248] : bullet.rubber ? [255, 226, 58] : [255, 178, 76]; - const trail = blink - ? [255, 244, 178] - : bullet.spit ? bullet.heavy ? [182, 48, 116] : [58, 190, 132] + const trail = bullet.spit + ? bullet.heavy ? [182, 48, 116] : [58, 190, 132] + : blink ? [255, 244, 178] : bullet.rubber ? [214, 178, 42] : [224, 116, 62]; const scale = cameraScale(); // Project the complete projectile. The old 2px/4px floors made both the @@ -9261,7 +9564,10 @@ // Spit is dots, not tracers: the streak between frames is the thing that // says "bullet", so a glob doesn't draw one — just a little ball with a // couple of droplets falling off the arc behind it. if (bullet.spit) { - const radius = Math.max(.9, (bullet.heavy ? 5.5 : 3.5) * scale); + // A glob must survive the wide portrait camera as a ball, not sub-pixel + // dust. The darker halo keeps its circular edge legible over sky or earth. + const radius = Math.max(2, (bullet.heavy ? 10 : 7) * scale); + filledDisc(point.x, point.y, radius * 1.35, trail); filledDisc(point.x, point.y, radius, core); filledDisc(lerp(point.x, previous.x, .5), lerp(point.y, previous.y, .5), radius * .45, trail); @@ -9909,13 +10215,16 @@ } circle(clock.dialX, centerY, clock.dialRadius, 2, ink); } -function hudStatusTray(clock) { +function hudStatusTray(clock = null) { const icons = hudStatusIcons(); if (!icons.length) return null; const statusCell = statusCellSize(); - const right = clock.left - 14; + const safe = hudSafeRect(); + const qrBox = spectatorQrBox(); + const right = clock ? clock.left - 14 + : qrBox ? qrBox.left - 14 : safe.right; const width = icons.length * statusCell + (icons.length - 1) * statusGap; - return { icons, right, left: right - width, top: hudSafeRect().top, + return { icons, right, left: right - width, top: safe.top, height: statusCell }; } @@ -10235,7 +10544,8 @@ // corner furniture a player needs — nameplates, stats, inventory, the // command stream — crowds a 9:16 crop and competes with the fight for the // eye. So a reel carries no HUD at all while the round is live, and states // the outcome afterward in the middle of the frame, one line at a time. - const reelMinimal = replayOven && reelHud; + const reelMinimal = replayOven && reelHud && + capabilities().reelFullUi !== true; triangleDepth = -1.4; const skyDay = mixColor([176, 215, 245], [255, 160, 112], visualTheme.sunset * .7); @@ -10268,7 +10578,14 @@ drawSelectionScreen(t, menuInk, menuPanel); return; } const cinematicAge = deathCinematicAge(run.monotonicUs); - if (cinematicAge < 0 || cinematicAge >= 1.45) containFighters(t); + const introAge = run.monotonicUs - roundStartedAt; + const inRoundIntro = !roundResult && introAge >= 0 && + introAge < introDurationUs; + // The intro deliberately owns its lens: wide title, two face portraits, + // then a pullback. Final-frame containment would widen every portrait until + // both fighters fitted, erasing the zoom story it was meant to protect. + if (!inRoundIntro && (cinematicAge < 0 || cinematicAge >= 1.45)) + containFighters(t); cameraDoll.prepare(); const { left: spanLeft, right: spanRight, top: spanTop, bottom: spanBottom } = terrainSpan(); @@ -10282,7 +10599,7 @@ // THE CAMERA — a fixed extension was still shy of the wide fight shot, // which stands several thousand units back, and the step came back on // every wide frame. Reels only: on the TV lens the visible front edge IS // the padded-room look. - const groundNear = reelCamera() + const groundNear = reelGroundCamera() ? Math.min(worldNear, cameraDoll.position.z - 400) : worldNear; drawTerrainBackWall(spanLeft, spanRight, worldFar, ground); drawTerrainSurface(spanLeft, spanRight, groundNear, worldFar, ground); @@ -10330,8 +10647,9 @@ const windInk = windDirection < 0 ? mixColor([72, 174, 255], [28, 88, 188], visualTheme.light) : mixColor([255, 92, 132], [184, 35, 62], visualTheme.light); if (WIND_FLAG && shellMode === "GAME") drawWindFlag(t, windInk); - // The top row is the round's: a clock, and who is watching. The wordmark - // screen carries its own clock, so this one waits for start. + // The round keeps match time and spectator state only. Time of day belongs + // to the title screen; carrying it into gameplay confused two unrelated + // clocks and spent the top-right lane on non-match information. if (matchHud && !reelMinimal && shellMode === "GAME" && gameplayStarted) { const timedRound = roundIsTimed(); const remainingSeconds = roundResult || !timedRound ? 0 : Math.max(0, @@ -10358,21 +10676,18 @@ const viewerLabel = roundViewerMode || roundViewerStatus; typeWrite(viewerLabel, hud.right - viewerLabel.length * 18, hud.top + 7, 24, ...(roundViewerMode === "LIVE" ? [210, 42, 62] : titleInk)); } - // Wall clock in the top right, tucked left of the round QR and below the - // spectator label so it never lands on either. const nowMs = run.unixMs || Date.now(); - const clock = hudClockBox(nowMs); - drawHudClock(clock, - hud.top + (roundViewer ? clock.size + 10 : 2), titleInk, nowMs); - drawHudStatusTray(clock, titleInk, nowMs); + drawHudStatusTray(null, titleInk, nowMs); const updateReady = typeof capabilities === "function" && capabilities().updateReady === true; if (updateReady) { const label = "update ready"; - const size = Math.round(clock.size * .58); + const size = Math.round(hudTypeSize * .58); const width = handleWidth(label, size); - const x = clock.right - width; - const y = clock.top + clock.size + 12; + const qrBox = spectatorQrBox(); + const right = qrBox ? qrBox.left - 14 : hud.right; + const x = right - width; + const y = hud.top + hudTypeSize + 12; typeWrite(label, x + 2, y + 3, size, ...contrastShadow(titleInk)); typeWrite(label, x, y, size, ...titleInk); if (globalThis.__oskiewarTouch) @@ -10384,8 +10699,8 @@ } } for (const tree of bodyTrees) drawBodyTree(tree, t); for (const pickup of gunPickups) drawGunPickup(pickup, t); + for (const pickup of swordPickups) drawSwordPickup(pickup, t); for (const pickup of grenadePickups) drawGrenadePickup(pickup, t); - const introAge = run.monotonicUs - roundStartedAt; const showRunnerLabels = matchHud && !reelMinimal && (Boolean(roundResult) || introAge >= introDurationUs); const viewDirection = normalize3({ @@ -10524,6 +10839,9 @@ drawHudInventory(player, side); drawCommandStream(player, side); } } + if (replayOven && capabilities().reelFullUi === true && + shellMode === "GAME") + drawReelSectionProgress(run.monotonicUs, titleInk); if (!replayOven) drawDebugPerformance(titleInk); drawDeathFlash(); if (shellMode === "MENU") { @@ -10561,13 +10879,14 @@ try { drawClientError(); } catch (_) { drawClientErrorFallback(); } return; } + const restore = beginRenderInterpolation(runtime().renderAlpha ?? 1); try { gamePaint(); } catch (error) { captureClientError("paint", error); try { drawClientError(); } catch (_) { drawClientErrorFallback(); } - } + } finally { restore(); } } function act() {} diff --git a/xbox/live/tests/frame-driver.test.mjs b/xbox/live/tests/frame-driver.test.mjs --- a/xbox/live/tests/frame-driver.test.mjs +++ b/xbox/live/tests/frame-driver.test.mjs @@ -9,10 +9,14 @@ let raf = null; const timers = new Map(); const paints = []; const simulations = []; + const presentations = []; const samples = []; const driver = createFrameDriver({ paint: (at, alpha) => paints.push([time, at, alpha]), - simulate: (at) => simulations.push(at), + simulate: (at, presentationAt) => { + simulations.push(at); + presentations.push(presentationAt); + }, sample: () => samples.push(time), now: () => time, requestFrame(callback) { @@ -32,6 +36,7 @@ return { driver, paints, simulations, + presentations, samples, setTime(value) { time = value; }, fireRaf(value) { @@ -128,3 +133,48 @@ [0, 16.67, 33.33, 50, 66.67, 83.33]); h.driver.start(); assert.throws(() => h.driver.stepOffline(), /cannot step offline/); }); + +test("offline capture keeps tick zero while the capture client gets ready", () => { + const h = harness({ timeScale: .5 }); + h.setTime(2500); + const first = h.driver.stepOffline(); + + assert.equal(first.simulationTime, 0); + assert.equal(first.presentationTime, 0); + assert.equal(h.paints[0][1], 0); +}); + +test("quarter-speed offline capture blends between 60 Hz authored ticks", () => { + const h = harness({ timeScale: .25 }); + const steps = []; + for (let frame = 0; frame < 9; frame++) steps.push(h.driver.stepOffline()); + + assert.equal(h.paints.length, 9); + assert.equal(h.simulations.length, 3); + assert.deepEqual(h.simulations.map((at) => Math.round(at * 100) / 100), + [0, 16.67, 33.33]); + assert.deepEqual(steps.map((step) => step.simulationTicks), + [1, 0, 0, 0, 1, 0, 0, 0, 1]); + assert.deepEqual(h.paints.map((paint) => paint[2]), + [.25, .5, .75, 1, .25, .5, .75, 1, .25]); + assert.equal(h.driver.stats.timeScale, .25); +}); + +test("live time scale changes tick cadence without changing the fixed sim step", () => { + const h = harness({ timeScale: .5 }); + h.driver.start(); + h.fireRaf(1000 / 60); + assert.equal(h.simulations.length, 1, "half speed holds the opening state"); + h.fireRaf(1000 / 30); + assert.equal(h.simulations.length, 2); + assert.equal(Math.round((h.simulations[1] - h.simulations[0]) * 100) / 100, + 16.67, "simulation time remains a 60 Hz lattice"); + + assert.equal(h.driver.setTimeScale(0), 0); + assert.equal(h.timerCount(), 0, "zero pauses simulation timers"); + h.fireRaf(50); + assert.equal(h.simulations.length, 2); + assert.equal(h.paints.length, 3, "paused games still repaint"); + assert.equal(h.driver.setTimeScale(99), 4, "fast motion is bounded"); + assert.equal(h.driver.getTimeScale(), 4); +}); diff --git a/xbox/live/tests/oskiewar-sfx.test.mjs b/xbox/live/tests/oskiewar-sfx.test.mjs --- a/xbox/live/tests/oskiewar-sfx.test.mjs +++ b/xbox/live/tests/oskiewar-sfx.test.mjs @@ -122,6 +122,21 @@ assert.ok(context.panners.some((node) => node.pan.value < 0)); assert.equal(bank.signal("unknown"), false); }); +test("offline bank schedules cues on the demo clock without resuming", async () => { + const context = new FakeAudioContext(); + let resumed = false; + context.resume = async () => { resumed = true; context.state = "running"; }; + const bank = createOskiewarSfx({ context, offline: true }); + assert.equal(await bank.unlock(), true); + assert.equal(resumed, false); + assert.equal(bank.drumAt(1.25, "kick", 1, -.2), true); + assert.equal(bank.signalAt(2.5, "punch", 0, 0, 0, { pan: -.4 }), true); + const starts = [...context.oscillators, ...context.bufferSources] + .flatMap((source) => source.started.map(([at]) => at)); + assert.ok(starts.includes(1.25)); + assert.ok(starts.includes(2.5)); +}); + test("event listener bridge forwards signal details and detaches", async () => { const context = new FakeAudioContext(); const bank = createOskiewarSfx({ context }); diff --git a/xbox/live/tests/oskiewar.test.mjs b/xbox/live/tests/oskiewar.test.mjs --- a/xbox/live/tests/oskiewar.test.mjs +++ b/xbox/live/tests/oskiewar.test.mjs @@ -87,7 +87,7 @@ }; const drawLine = (...values) => lines.push(values); const fight = new Function( "runtime", "gamepad", "capabilities", "telemetry", "gameSignal", "saveReplay", "publishLive", "analytics", "drum", "wipe", "box", "line", "triangle", "triangle3d", "triangles3d", "write", "systemWrite", "gameView", - `${source}\nreturn { boot, sim, paint, playDrum, captureClientError, drawDetachedPart, clientErrorState: () => clientError, clientErrorDetailState: () => clientErrorDetail, errorReportStatus, errorRestartSeconds, combatLegend, commandFade, dummyPopLine, filledDisc, spectatorCode, runShadow, glyphColor, contrastShadow, stateDumpRows, dumpTokens, dumpTokenInk, clientErrorDumpState: () => ({ url: clientErrorDumpUrl, modules: clientErrorQr ? clientErrorQr.getModuleCount() : 0 }), controlLocale, animatedTitleColor, comicGlyphAdvance, handleWidth, displayTheme, players, ball, balls, bullets, grenades, gunPickups, grenadePickups, bodyTrees, treeFruit, detachedParts, runnerWorldGeometry, fighterAnimationPhase, runnerDistanceToPoint, segmentSegmentClosest, meleeLimbContact, damagePart, isPogo, isHeadOnly, resultCardText, pacificTimeLabel, projectedBallRadius, deathCinematicState: () => deathCinematic ? { ...deathCinematic, age: deathCinematicAge() } : null, disableBall: () => { ballEnabled = false; for (const item of balls) item.active = false; }, enableBall: (index = 0) => { ballEnabled = true; const item = balls[index]; item.active = true; item.serveAt = 0; item.safeUntil = 0; item.safePlayers = 0; }, setWind: (value) => { windAcceleration = value; }, setDebugHitboxes: (value) => { debugHitboxes = Boolean(value); }, debugState: () => debugHitboxes, windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), knockOut: () => killPlayer(players[1], 0, runtime().monotonicUs, "KO"), selfBallDummy: () => killPlayer(players[1], 1, runtime().monotonicUs, "BALLED"), startAttack: (kind) => startMelee(players[0], kind, runtime().monotonicUs), bootFirstBall: () => bootBall(ball, players[0], runtime().monotonicUs), wackBall: () => { players[0].attackKind = "KICK"; returnBall(ball, players[0], runtime().monotonicUs, false); }, shieldBall: () => returnBall(ball, players[0], runtime().monotonicUs, true), crossWackBall: (contact = 1) => crossWackBall(ball, players.map((player) => ({ player, contact })), runtime().monotonicUs), enterGame: () => enterGame(runtime().monotonicUs), shellState: () => ({ mode: shellMode }), startFight: () => { shellMode = "GAME"; selecting = false; players[1].npc = false; players[1].bot = false; applyRoster(players[1], 2); startReplay(runtime().monotonicUs); matchBallType = "soccer"; resetRound(runtime().monotonicUs, true); }, startFightAgainst: (kind) => startFightAgainst(kind, runtime().monotonicUs), palSelect: () => PAL_SELECT, titleToyState: () => ({ title: titleToys.map((toy) => ({ ...toy })), prompt: promptToys.map((toy) => ({ ...toy })), bounce: promptBounce }), selectionState: () => ({ selecting, step: selectionStep, cursor: selectionCursor, ready: selectionReady.slice() }), selectionOptions: () => selectionOptions().map((option) => ({ kind: option.kind, label: option.fighter.handle, disabled: Boolean(option.disabled) })), cameraState: () => ({ cameraWidth, cameraCenter, cameraCenterY, cameraAspect, stageRight, stageTop, stageBottom, viewHeight, cameraContainFloor, doll: { width: cameraDoll.width, target: { ...cameraDoll.target }, position: { ...cameraDoll.position }, perspective: cameraDoll.perspective, roll: cameraDoll.roll } }), screenBounds: () => players.map((player) => runnerScreenBounds(player, runtime().monotonicUs / 1e6)), dumpTokens, dumpTokenInk, drawCornerCrops, playerStatLines, playerHandleLayout, statStackHeight, setBallKind: (type) => { matchBallType = type; resetBalls(runtime().monotonicUs); }, ballTypeState: () => matchBallType, seriesBallType, seriesState: () => seriesName, sessionState: () => sessionName, selectionLayout: selectionTouchLayout, actionSafeRect, hudSafeRect, projectPoint, terrainSpan, stageGeometry: () => ({ platformY, platformLeft, platformRight, floorY, ceilingY, worldLeft, worldRight, worldNear, worldFar }), platformTable: () => platforms.map((rung) => ({ ...rung })), frameRect: () => fighterFrameRect(), roundState: () => ({ roundResult, roundElapsedUs, matchOver }), viewerState: () => ({ active: Boolean(roundViewer), mode: roundViewerMode, status: roundViewerStatus, name: matchName }), instantReplayState: () => instantReplay ? { active: true, paused: instantReplay.paused, cursor: instantReplay.cursor, frames: instantReplay.frames.length, speed: instantReplay.speed, action: instantReplay.action } : { active: false }, replayFrameCount: () => roundReplayFrames.length, inputPadDown: (index) => inputPads[index]?.down?.slice() || [], startSelfPlay: () => startSelfPlay(runtime().monotonicUs), selfPlayState: () => selfPlay, replayActionCurve, replayRampStep, startInstantReplay: (now) => startInstantReplay(now) };` + `${source}\nreturn { boot, sim, paint, playDrum, captureClientError, drawDetachedPart, clientErrorState: () => clientError, clientErrorDetailState: () => clientErrorDetail, errorReportStatus, errorRestartSeconds, combatLegend, commandFade, dummyPopLine, filledDisc, spectatorCode, runShadow, glyphColor, contrastShadow, stateDumpRows, dumpTokens, dumpTokenInk, clientErrorDumpState: () => ({ url: clientErrorDumpUrl, modules: clientErrorQr ? clientErrorQr.getModuleCount() : 0 }), controlLocale, animatedTitleColor, comicGlyphAdvance, handleWidth, displayTheme, players, ball, balls, bullets, grenades, gunPickups, swordPickups, grenadePickups, bodyTrees, treeFruit, detachedParts, runnerWorldGeometry, fighterAnimationPhase, runnerDistanceToPoint, segmentSegmentClosest, meleeLimbContact, damagePart, isPogo, isHeadOnly, resultCardText, pacificTimeLabel, projectedBallRadius, deathCinematicState: () => deathCinematic ? { ...deathCinematic, age: deathCinematicAge() } : null, disableBall: () => { ballEnabled = false; for (const item of balls) item.active = false; }, enableBall: (index = 0) => { ballEnabled = true; const item = balls[index]; item.active = true; item.serveAt = 0; item.safeUntil = 0; item.safePlayers = 0; }, setWind: (value) => { windAcceleration = value; }, setDebugHitboxes: (value) => { debugHitboxes = Boolean(value); }, debugState: () => debugHitboxes, windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), knockOut: () => killPlayer(players[1], 0, runtime().monotonicUs, "KO"), selfBallDummy: () => killPlayer(players[1], 1, runtime().monotonicUs, "BALLED"), startAttack: (kind) => startMelee(players[0], kind, runtime().monotonicUs), bootFirstBall: () => bootBall(ball, players[0], runtime().monotonicUs), wackBall: () => { players[0].attackKind = "KICK"; returnBall(ball, players[0], runtime().monotonicUs, false); }, shieldBall: () => returnBall(ball, players[0], runtime().monotonicUs, true), crossWackBall: (contact = 1) => crossWackBall(ball, players.map((player) => ({ player, contact })), runtime().monotonicUs), enterGame: () => enterGame(runtime().monotonicUs), shellState: () => ({ mode: shellMode }), startFight: () => { shellMode = "GAME"; selecting = false; players[1].npc = false; players[1].bot = false; applyRoster(players[1], 2); startReplay(runtime().monotonicUs); matchBallType = "soccer"; resetRound(runtime().monotonicUs, true); }, startFightAgainst: (kind) => startFightAgainst(kind, runtime().monotonicUs), palSelect: () => PAL_SELECT, titleToyState: () => ({ title: titleToys.map((toy) => ({ ...toy })), prompt: promptToys.map((toy) => ({ ...toy })), bounce: promptBounce }), selectionState: () => ({ selecting, step: selectionStep, cursor: selectionCursor, ready: selectionReady.slice() }), selectionOptions: () => selectionOptions().map((option) => ({ kind: option.kind, label: option.fighter.handle, disabled: Boolean(option.disabled) })), cameraState: () => ({ cameraWidth, cameraCenter, cameraCenterY, cameraAspect, stageRight, stageTop, stageBottom, viewHeight, cameraContainFloor, doll: { width: cameraDoll.width, target: { ...cameraDoll.target }, position: { ...cameraDoll.position }, perspective: cameraDoll.perspective, roll: cameraDoll.roll } }), screenBounds: () => players.map((player) => runnerScreenBounds(player, runtime().monotonicUs / 1e6)), dumpTokens, dumpTokenInk, drawCornerCrops, playerStatLines, playerHandleLayout, statStackHeight, setBallKind: (type) => { matchBallType = type; resetBalls(runtime().monotonicUs); }, ballTypeState: () => matchBallType, seriesBallType, seriesState: () => seriesName, sessionState: () => sessionName, selectionLayout: selectionTouchLayout, actionSafeRect, hudSafeRect, projectPoint, terrainSpan, stageGeometry: () => ({ platformY, platformLeft, platformRight, floorY, ceilingY, worldLeft, worldRight, worldNear, worldFar }), platformTable: () => platforms.map((rung) => ({ ...rung })), frameRect: () => fighterFrameRect(), roundState: () => ({ roundResult, roundElapsedUs, matchOver }), viewerState: () => ({ active: Boolean(roundViewer), mode: roundViewerMode, status: roundViewerStatus, name: matchName }), instantReplayState: () => instantReplay ? { active: true, paused: instantReplay.paused, cursor: instantReplay.cursor, frames: instantReplay.frames.length, speed: instantReplay.speed, action: instantReplay.action } : { active: false }, replayFrameCount: () => roundReplayFrames.length, inputPadDown: (index) => inputPads[index]?.down?.slice() || [], startSelfPlay: () => startSelfPlay(runtime().monotonicUs), selfPlayState: () => selfPlay, replayActionCurve, replayRampStep, startInstantReplay: (now) => startInstantReplay(now) };` )( () => ({ monotonicUs: now, unixMs: 1785870000000 + Math.floor(now / 1000), simCount: Math.floor(now / 16667), paintCount: 0, @@ -209,10 +209,10 @@ assert.match(controls, /commandGlyph = \{ A: "\/", B: "\*", X: "\)", Y: "\+" \}/); assert.match(controls, /commandGlyph\[key\]/); }); -test("phone names shrink and the clock fits beside the notch", () => { +test("phone names shrink and the title clock fits beside the notch", () => { assert.match(webShell, /viewport-fit=cover/); assert.match(source, /const size = touch \? 24 : hudTypeSize/); - assert.match(source, /const nameSize = touch \? 32 : compactLayout\(\) \? 48 : 74/); + assert.match(source, /const nameSize = touch \? 28 : compactLayout\(\) \? 38 : 54/); const clock = source.slice(source.indexOf("function hudClockBox"), source.indexOf("function hudStatusTray")); assert.match(clock, /const size = touch \? 16 : hudTypeSize/); @@ -645,10 +645,11 @@ assert.match(nativeShellApp, /safeAreaInsetsDidChange/); assert.match(nativeShellApp, /__oskiewarSafeInsets/); }); -test("fighters start within a close twelve-foot read", () => { - // Still 520 apart; the marks moved with the tower's center at 1500. - assert.match(source, /pad: 0, spawnX: 1240, x: 1240/); - assert.match(source, /pad: 1, spawnX: 1760, x: 1760/); +test("fighters start three tiles apart astride the cube's center", () => { + // Tiles 3 and 6: mirrored across the middle, three tiles clear of the + // corner weapons so the bell never hands one out by proximity. + assert.match(source, /pad: 0, spawnX: tileCenterX\(3\), x: tileCenterX\(3\)/); + assert.match(source, /pad: 1, spawnX: tileCenterX\(6\), x: tileCenterX\(6\)/); assert.match(source, /cameraWidth = 960;/); }); @@ -832,7 +833,8 @@ const { fight, tick } = createFight(false, false, "web"); fight.startFight(); tick(3000001); const stacked = settleFraming(fight, tick, 120); - const spread = settleFraming(fight, tick, 1400); + // The cube's widest stance: a fighter pressed to each wall. + const spread = settleFraming(fight, tick, 760); // Stacked runs out of height first, spread runs out of width first. const stackedSafe = stacked.safe.bottom - stacked.safe.top; const spreadSafe = spread.safe.right - spread.safe.left; @@ -840,14 +842,17 @@ assert.ok(stacked.height / stackedSafe > stacked.width / (stacked.safe.right - stacked.safe.left)); assert.ok(spread.width / spreadSafe > spread.height / (spread.safe.bottom - spread.safe.top)); - assert.ok(spread.width > spreadSafe * .8, + // A near-square room on a 16:9 stage can never fill the wide axis the way + // the 3000-unit tower could; half of action-safe is the cube's honest + // ceiling with the camera's own margins on. + assert.ok(spread.width > spreadSafe * .5, `spread pair filled only ${(spread.width / spreadSafe * 100).toFixed(0)}% ` + `of action-safe width`); const rect = fight.frameRect(); - assert.ok(rect.right - rect.left > 1400 && rect.bottom - rect.top > 150); + assert.ok(rect.right - rect.left > 900 && rect.bottom - rect.top > 150); }); -test("the frame pans vertically with the action instead of hanging fixed", () => { +test("the frame pans vertically with the action instead of hanging fixed", { skip: !PLATFORM }, () => { const { fight, tick } = createFight(false, false, "web"); fight.startFight(); tick(3000001); @@ -955,13 +960,15 @@ test("camera containment releases and zooms back in when fighters converge", () => { const { fight, tick } = createFight(false, false, "web"); fight.startFight(); tick(3000001); - fight.players[0].x = 900; - fight.players[1].x = 4100; + fight.players[0].x = 70; + fight.players[1].x = 830; for (let frame = 0; frame < 120; frame++) { tick(); fight.paint(); } const wide = fight.cameraState().doll.width; - fight.players[0].x = 2200; - fight.players[1].x = 2800; - for (let frame = 0; frame < 180; frame++) { tick(); fight.paint(); } + // A 250 gap is the cube's honest close-up: tight enough to halve the wide + // shot, wide enough that the floor-locked frame still clears the heads. + fight.players[0].x = 325; + fight.players[1].x = 575; + for (let frame = 0; frame < 300; frame++) { tick(); fight.paint(); } const close = fight.cameraState().doll.width; assert.ok(close < wide * .55, `camera stayed latched at ${close.toFixed(1)} after ${wide.toFixed(1)}`); @@ -1017,10 +1024,14 @@ `${middle.toFixed(0)}`); assert.ok(largestStep < 160, `intro camera stepped ${largestStep}px at frame ${largestFrame}`); assert.match(source, /function drawFightIntro/); - assert.match(source, /const startText = "start"/); - assert.doesNotMatch(source, /const beginText = "begin"/); - assert.doesNotMatch(source, /const fightText = "fight!"/); - assert.match(source, /const andText = "and"/); + assert.match(source, /const title = "oskiewar"/); + assert.match(source, /const drawHeadName/); + assert.match(source, /point\.y - radius - flashingSize \* 1\.28/); + assert.match(source, /animatedTitleColor\(index, titleTime\)/); + assert.match(source, /const inRoundIntro = !roundResult/); + assert.match(source, /if \(introSeconds < 1\) \{\s*drawHeadName\(players\[0\]\)/); + assert.match(source, /if \(introSeconds < 2\) \{\s*drawHeadName\(players\[1\]\)/); + assert.doesNotMatch(source, /const andText = "and"/); assert.match(source, /function visibleHandle\(player\)/); assert.match(source, /return player\.name\.toLowerCase\(\)/); assert.doesNotMatch(source, /typeWrite\("v"/); @@ -1174,6 +1185,20 @@ assert.match(bulletSource, /const scale = cameraScale\(\)/); assert.match(bulletSource, /Math\.max\(\.6, \(bullet\.rubber \? 4 : 3\) \* scale\)/); assert.match(bulletSource, /Math\.max\(\.9, 7 \* scale\)/); assert.doesNotMatch(bulletSource, /Math\.max\(4, 7 \* cameraScale\(\)\)/); + assert.match(source, /function spitMouthPose\(player\)/); + assert.match(source, /x: mouth\.x, y: mouth\.y, z: player\.z/); + assert.match(source, + /const toss = \{ x: mouth\.outX \* speed, y: mouth\.outY \* speed \}/); + assert.match(source, /const aimLocalX = -direction/); + assert.match(source, /const aimLocalY = -\.22/); + assert.match(source, /const downwardAimY = Math\.abs\(aimY\)/); + assert.match(source, /safeUntil: now \+ 220000/); + assert.match(source, + /fragment\.owner === bullet\.owner && now < bullet\.safeUntil/); + assert.match(source, /const spitting = isHeadOnly\(player\)/); + assert.match(source, /faceX \+ direction \* r \* \.12, mouthY/); + assert.match(bulletSource, + /Math\.max\(2, \(bullet\.heavy \? 10 : 7\) \* scale\)/); }); test("ambient air is a simulated world-entity field", () => { @@ -1306,7 +1331,7 @@ // floor — so a fighter standing on a rung six storeys up stretched his shins // all the way down to it. Feet now plant on the surface underfoot and a foot // past a rung's lip dangles at the leg's own reach; these capsules double as // hitboxes, so a leg is no longer shootable a storey below its owner. -test("feet plant on the rung underfoot instead of the storey below", () => { +test("feet plant on the rung underfoot instead of the storey below", { skip: !PLATFORM }, () => { const { fight } = createFight(); const player = fight.players[0]; const stage = fight.stageGeometry(); @@ -1962,12 +1987,15 @@ const bot = fight.players[1]; bot.npc = true; bot.bot = true; bot.name = "BOT"; - fight.players[0].x = 5000; - bot.x = 6500; + // Keep the bot off tile 9 — the corner pistol takes any limb within 90, + // and an armed bot answers with a WHIP instead of the bare-handed strike + // this test is listening for. + fight.players[0].x = 150; + bot.x = 650; tick(); assert.ok(bot.vx < 0); - fight.players[0].x = 5850; - bot.x = 6000; + fight.players[0].x = 480; + bot.x = 600; bot.botAttackAt = 0; tick(); assert.ok(bot.attackKind === "PUNCH" || bot.attackKind === "KICK"); @@ -2232,6 +2260,12 @@ // The armed-fighter matrix: Y and either shoulder spend an item, B takes the // item's flavor, and A stays a kick no matter what is in the hand. const armAndPress = (button, ammo = {}) => { const context = createFight(); + // Point-blank cube: at spawn distance a fired round reaches the other + // fighter inside its first tick and vanishes into the hit. Lift the + // second fighter out of the line of fire — this matrix is about what the + // hand does, not what it hits. + context.fight.players[1].y -= 600; + context.fight.players[1].grounded = false; Object.assign(context.fight.players[0], ammo); context.pads[0].down = [button]; context.tick(); @@ -2447,10 +2481,12 @@ test("grenade drops grant ammo and Y throws an expanding grenade", () => { const { fight, pads, signals, tick } = createFight(); const player = fight.players[0]; - const pickup = fight.grenadePickups[0]; - pickup.active = true; - pickup.x = player.x; - pickup.y = player.y - 70; + // The cube's map authors no grenades; a dropped cache pushed at runtime is + // the shape a grenade pickup can still take, so seed one to keep the + // throw mechanic covered. + const pickup = { amount: 2, x: player.x, y: player.y - 70, z: 0, + active: true, startsActive: false, respawnAt: 0 }; + fight.grenadePickups.push(pickup); tick(); assert.equal(player.grenadeAmmo, pickup.amount); pads[0].down = ["Y"]; @@ -2460,6 +2496,28 @@ assert.equal(fight.grenades.length, 1); assert.ok(signals.some(([event, pad]) => event === "grenade" && pad === 0)); }); +test("the sword arms by walk-over, stays for the round, and can be stolen", () => { + const { fight, tick } = createFight(); + const player = fight.players[0]; + const pickup = fight.swordPickups[0]; + pickup.x = player.x; + pickup.y = player.y - 70; + tick(); + assert.equal(player.swordHeld, true); + assert.equal(pickup.active, false); + assert.equal(player.lastButton, "SWORD"); + // A blade in the item hand stretches every hand strike. + assert.match(source, /reach: spec\.reach \* 1\.5/); + // One sword a round: only the pistol returns on the powerup cycle, and + // the STOLE table is the only way steel changes hands. + assert.match(source, /const pickup = gunPickups\[0\]/); + assert.match(source, /target\.swordHeld && !player\.swordHeld/); + // Round reset returns it to its tile and empties every hand. + fight.nextRound(); + assert.equal(player.swordHeld, false); + assert.equal(pickup.active, true); +}); + test("opposing bullets cancel one another", () => { const { fight, tick } = createFight(); fight.bullets.push( @@ -2565,21 +2623,27 @@ tick(); assert.ok(fight.players[0].vx > 1000); }); -test("X shield blocks melee geometry", () => { +test("X shield repels a grounded melee rush", () => { + // At kick range the shield's own bash answers first — the ring is wider + // than any bare strike's reach — so the grounded exchange is bash, not + // block. The block path stays alive in source for aerial strikes and + // back-blocks, which the bash's height gate lets through. const { fight, pads, signals, tick } = createFight(); fight.players[1].npc = false; - fight.players[0].x = 5000; - fight.players[1].x = 5100; + fight.players[0].x = 400; + fight.players[1].x = 500; pads[0].down = ["A"]; pads[1].down = ["X"]; for (let frame = 0; frame < 5; frame++) tick(16667); assert.equal(fight.players[1].alive, true); assert.equal(fight.players[0].score, 0); - assert.ok(fight.players[0].x < 5000, + assert.ok(fight.players[0].x < 400, "a shielded strike should bounce the attacker backward"); assert.ok(fight.players[0].knockVx < -1000, "shield recoil should survive the next movement update"); - assert.ok(signals.some(([event, player]) => event === "block" && player === 1)); + assert.ok(signals.some(([event, player]) => + event === "shield-bash" && player === 1)); + assert.match(source, /emitSignal\("block", target\.pad, attacker\.pad/); }); test("shield is emitted on the foreground triangle path", () => { @@ -2637,11 +2701,11 @@ test("an airborne fighter can cross over the other fighter", () => { const { fight, pads, tick } = createFight(); const jumper = fight.players[0]; const standing = fight.players[1]; - jumper.x = 2440; + jumper.x = 390; jumper.y = standing.y - 190; jumper.vy = 0; jumper.grounded = false; - standing.x = 2560; + standing.x = 510; pads[0].down = ["ArrowRight"]; for (let frame = 0; frame < 8; frame++) tick(16667); assert.ok(jumper.x > standing.x); @@ -2807,13 +2871,15 @@ assert.match(choice, /requested === "dummy" \|\| requested === "spiderdummy"/); assert.match(choice, /trainingOpponent = "trainingbot"/); }); -test("skateboard is a deterministic physical match prop with native geometry", () => { - assert.match(source, /\{ type: "skateboard", spawnOwner: -1, radius: 52/); - const drawSource = source.slice(source.indexOf("function drawBall"), - source.indexOf("function drawBallHitboxes")); - assert.match(drawSource, /ball\.type === "skateboard"/); - assert.match(drawSource, /filledCapsule/); - assert.match(drawSource, /filledDisc/); +test("the skateboard is retired from the cube's deck", () => { + // @jeffrey: no boards on this map. The series draw can only deal a ball, + // no round hands one out, and training carries a plain ball instead. + assert.doesNotMatch(source, /\{ type: "skateboard"/); + assert.doesNotMatch(source, /matchBallType = "skateboard"/); + assert.doesNotMatch(source, /players\[0\]\.skateboard = true/); + // The mechanic sleeps in place — resetRound still clears the flag — but + // nothing on the map can set it. + assert.match(source, /player\.skateboard = false/); }); test("losing both legs grounds the pelvis in a low crouched form", () => { @@ -3275,8 +3341,8 @@ test("a grounded ball ignores wind", () => { const { fight, signals, tick } = createFight(); fight.enableBall(); fight.setWind(1200); - fight.ball.x = 2500; - fight.ball.y = 12000 - fight.ball.radius; + fight.ball.x = 450; + fight.ball.y = fight.stageGeometry().floorY - fight.ball.radius; fight.ball.vx = 0; fight.ball.vy = 0; tick(16667); @@ -3288,7 +3354,7 @@ test("a ball resting on the platform also ignores wind", () => { const { fight, tick } = createFight(); fight.enableBall(); fight.setWind(1200); - fight.ball.x = 2500; + fight.ball.x = 450; fight.ball.y = fight.stageGeometry().platformY - fight.ball.radius; fight.ball.vx = 0; fight.ball.vy = 0; @@ -3579,13 +3645,21 @@ assert.match(source, /function drawCommandStream\(player, side\)/); assert.match(source, /const glyph = \{ LEFT: "<", RIGHT: ">", UP: "\^", DOWN: "v" \}/); assert.match(source, /fade: commandFade\(index, count, idle\)/); - assert.match(source, /nextLength > commandStreamColumns/); - assert.match(source, /const firstY = safe\.top \+ hudTypeSize \* 3 \+ 18/); + assert.match(source, + /while \(player\.commandStream\.length > commandStreamDepth\)/); + assert.match(source, + /commandStreamStackHeight\(player\) -\s*\n\s*size - 7/); assert.doesNotMatch(source, /recordCommand\(player, "RUN", now\)/); assert.match(source, /buttonFor\[entry\.label\]\.some\(\(button\) => held\.includes\(button\)\)/); // The held tint lives in the pad-button renderer now, not a local palette. assert.match(source, /padButtonInk\[label\]/); - assert.match(source, /const size = hudTypeSize/); + const streamSource = source.slice(source.indexOf("function drawCommandStream"), + source.indexOf("function drawFightIntro")); + assert.match(streamSource, /const size = commandStreamTypeSize\(handle\.size\)/); + assert.match(streamSource, + /let cursor = side === 0 \? handle\.x : handle\.x \+ handle\.width - width/); + assert.match(streamSource, + /handle\.y - statStackHeight\(\) - \(row \+ 1\) \* \(size \+ 7\) - rise/); }); // The buffer is a record of an exchange, so it holds one — twenty presses, and @@ -3624,7 +3698,12 @@ assert.deepEqual(fadesAt(9000000).filter((fade) => fade > 0), [], "the buffer eventually leaves entirely"); const streamSource = source.slice(source.indexOf("function drawCommandStream"), source.indexOf("function drawFightIntro")); - assert.match(streamSource, /lines\.splice\(0, lines\.length - commandStreamRows\)/); + assert.match(streamSource, + /entries\.slice\(0, commandStreamDepth\)/); + assert.match(streamSource, /const columns = commandStreamColumnsNow\(\)/); + assert.match(streamSource, /index \+= columns/); + assert.match(source, + /const commandStreamColumnsNow = \(\) => compactLayout\(\) \? 4 : commandStreamColumns/); }); // `frameNow` used to be the animation's position in its cycle rather than a @@ -4073,9 +4152,12 @@ fight.enableBall(); fight.ball.x = head.x - 210; fight.ball.y = head.y; fight.ball.z = head.z; + // Fast enough to cross the whole head inside one fixed step — the + // tunneling case. Fixed 60 Hz ticks, because one oversized elapsed gets + // clamped to a single 40ms integration and the sweep never happens. fight.ball.vx = 12000; fight.ball.vy = 0; - tick(40000); + for (let frame = 0; frame < 3; frame++) tick(16667); assert.equal(dummy.alive, true); assert.equal(dummy.lastButton, "HEAD HIT"); for (const part of limbPartsForTest) @@ -4227,21 +4309,20 @@ assert.doesNotMatch(source, /timerText === "∞"/); assert.doesNotMatch(source, /Math\.sin\(t \* 4\.6\)/); }); -test("the in-match HUD carries a wall clock clear of the round QR", () => { - // The clock's footprint moved into `hudClockBox` so the status tray could - // share it, so the lane arithmetic is asserted where it now lives and the - // draw site only has to place what the box already measured. +test("time of day is title-only and absent from the in-match HUD", () => { const boxSource = source.match( /function hudClockBox\(unixMs\) \{[\s\S]*?\n\}/)[0]; assert.match(boxSource, /pacificTimeLabel\(unixMs\)/); assert.match(boxSource, /qrBox \? qrBox\.left - 14 : safe\.right/); - const clockSource = source.slice(source.indexOf("// Wall clock in the top right"), - source.indexOf("drawHudStatusTray(clock", source.indexOf( - "// Wall clock in the top right"))); - assert.match(clockSource, /run\.unixMs \|\| Date\.now\(\)/); - assert.match(clockSource, /roundViewer \? clock\.size \+ 10 : 2/); + const titleSource = source.slice(source.indexOf("function drawTitleScreen"), + source.indexOf("function pacificTimeLabel")); + assert.match(titleSource, /drawHudClock\(clock,/); + const gameSource = source.slice(source.indexOf("function gamePaint"), + source.indexOf("\nfunction boot() {", source.indexOf("function gamePaint"))); + assert.doesNotMatch(gameSource, /drawHudClock\(/); + assert.match(gameSource, /drawHudStatusTray\(null, titleInk, nowMs\)/); const { fight, tick } = createFight(); - // Untimed training omits the round timer and still shows the wall clock. + // Removing the wall clock does not disturb the rest of the gameplay HUD. fight.players[1].npc = true; fight.players[1].bot = false; tick(); @@ -4674,16 +4755,25 @@ assert.match(source, /drawDummyPopLine\(titleY, titleSize, transitionInk\)/); assert.doesNotMatch(source, /if \(!placement && shellMode === "MENU"\) return/); }); -test("the Replay Oven captures match-HUD 60 fps high-quality masters", () => { +test("the Replay Oven captures full-UI 60 fps high-quality masters", () => { assert.match(webShell, /replayOven/); assert.match(webShell, /reelHud/); + assert.match(webShell, /reelFullUi/); assert.match(source, /const matchHud = !replayOven \|\| reelHud/); - // A reel carries no in-play furniture. The timer, nameplates, stats, - // inventory and command stream all belong to someone holding a controller; - // on a phone they crowd the 9:16 crop and compete with the fight. The reel - // stays clean until the round is decided, then says who won in the middle - // of the frame, one line at a time. + // The standard published lane explicitly requests the full match UI. The + // minimal lane remains available for legacy review renders. assert.match(source, /const reelMinimal = replayOven && reelHud/); + assert.match(source, /capabilities\(\)\.reelFullUi !== true/); + assert.match(source, /function drawReelSectionProgress\(now, titleInk\)/); + assert.match(source, /const labels = \["INTRO", "FIGHT", "OUTRO"\]/); + assert.match(source, /drawReelSectionProgress\(run\.monotonicUs, titleInk\)/); + assert.match(source, /function recordFightHit\(sourcePad, decisive\)/); + assert.match(source, /for \(const mark of hitMarks\)/); + assert.match(source, /const reached = roundElapsedUs \/ roundDurationUs >= mark\.at/); + assert.match(source, /globalThis\.__oskiewarFightHitForecast/); + assert.match(source, /collapseRenderCameraInterpolation\(\)/); + assert.match(source, /const reelGroundCamera = \(\)/); + assert.match(source, /const groundNear = reelGroundCamera\(\)/); assert.match(source, /if \(matchHud && !reelMinimal && shellMode === "GAME" && gameplayStarted\)/); assert.match(source, /if \(reelMinimal && roundResult && resultUiReady\)/); @@ -4691,55 +4781,53 @@ assert.match(source, /if \(!replayOven\) drawDebugPerformance/); assert.match(source, /if \(!replayOven && !selfPlay && renderFlags\.keys !== false\)\n\s*drawControlLegend/); assert.match(reelRenderer, /&reel-hud/); + assert.match(reelRenderer, /&reel-full-ui/); assert.match(reelRenderer, /quality: 100/); assert.match(reelRenderer, /"-r", "60"/); assert.match(reelRenderer, /"-crf", "14"/); assert.match(replayOven, /hud: true/); }); -test("the tower keeps its size, flat floor, and thin walls", () => { - assert.match(source, /const worldRight = 3000/); - assert.match(source, /const ceilingY = -1600/); +test("the cube keeps its tile size, flat floor, and thin walls", () => { + assert.match(source, /const tileSize = 90/); + assert.match(source, /const gridCols = 10/); + assert.match(source, /const gridRows = 10/); assert.match(source, /const wallThickness = 40/); - // A flat floor has nothing finer to say than 125 units a quad. - assert.match(source, /const terrainSamples = 24/); + // The walls stand outside the lattice so tile edges land on clean + // multiples of ninety, flush with the playable faces. + assert.match(source, /const worldLeft = gridLeft - wallThickness/); + assert.match(source, + /const worldRight = gridLeft \+ gridWidth \+ wallThickness/); + assert.match(source, /const ceilingY = floorY - gridHeight - wallThickness/); assert.match(source, /const terrainAmplitude = 0/); // The side skate ramps are gone: no quarter-pipe rise anywhere. assert.doesNotMatch(source, /transitionRadius/); assert.doesNotMatch(source, /halfPipeRise/); }); -// The lattice is the map. Every rung is one plain jump above the last, which -// is what makes the tower climbable without a chart. -test("the platform lattice is spaced by the jump it is climbed with", () => { +// The grid is the map. Simulation stays continuous, but every piece of +// structure — spawns, weapons, lanes — is authored on tile centers, and the +// per-tile field is the surface tiled computation runs on. +test("the map is a 10x10 grid of 90-unit tiles and is authored on it", () => { const { fight } = createFight(false, false, "web"); const stage = fight.stageGeometry(); - const rungs = fight.platformTable(); - assert.ok(rungs.length >= 10, `only ${rungs.length} rungs`); - // Apex of a plain jump: v^2 / 2g with the piece's own numbers. + // Playable faces sit exactly one grid apart on both axes. + assert.equal(stage.worldRight - stage.worldLeft, 10 * 90 + 2 * 40); + assert.equal(stage.floorY - stage.ceilingY, 10 * 90 + 40); + // The cube holds a plain jump and a double jump under its ceiling. const apex = 1760 ** 2 / (2 * 4800); - const doubleApex = apex * 2; - const ultraApex = 3960 ** 2 / (2 * 4800); - const levels = [...new Set(rungs.map((rung) => rung.y))] - .sort((a, b) => b - a); - // Everything but the crow's nest is one ordinary hop off the thing below. - const climbable = levels.filter((y) => y > Math.min(...levels)); - let below = stage.floorY; - for (const y of climbable) { - assert.ok(below - y <= apex, - `a ${below - y} rise at ${y} is past a jump's ${apex.toFixed(0)}`); - below = y; - } - const nest = Math.min(...levels); - assert.ok(below - nest > doubleApex, - `the crow's nest at ${nest} is inside a double jump`); - assert.ok(below - nest < ultraApex, - `the crow's nest at ${nest} is past an ultra jump`); - // Two columns run clear from floor to roof so a popped ball comes back. - for (const shaft of [1020, 1980]) - for (const rung of rungs) - assert.ok(shaft < rung.left || shaft > rung.right, - `a rung at ${rung.y} blocks the shaft at ${shaft}`); + assert.ok(apex * 2 + 180 < 10 * 90, + `a double jump (${(apex * 2).toFixed(0)}) plus a fighter breaks the roof`); + // Spawn marks and the pistol sit on tile centers: 45 past a tile edge. + for (const x of [...fight.players.map((player) => player.spawnX), + fight.gunPickups[0].x]) + assert.equal((x - 45) % 90, 0, `${x} is off the tile lattice`); + // Both weapons are authored in tile coordinates in the source. + assert.match(source, /x: tileCenterX\(9\), startsActive: true/); + assert.match(source, /\{ x: tileCenterX\(0\), startsActive: true/); + // The tile field exists, one number per tile, and the overlay draws it. + assert.match(source, /const gridField = new Float32Array\(gridCols \* gridRows\)/); + assert.match(source, /function drawGridOverlay/); }); test("camera bounds only cull an immutable globally mapped arena", () => {