From 735df8af331cfef71812a046a0dd0d43e493e9c1 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 25 Sep 2026 20:59:45 -0700 Subject: [PATCH] oskiewar: replays carry the explosion, captions name their keys, dashes streak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replay frames now record impacts (strike sparks, the death burst, blast rings) and detached limbs alongside the projectiles, so a replayed kill shows what it did. Playback reconciles those collections in place, keeping surviving objects for render interpolation, and blends body poses between neighbouring frames instead of stepping at the capture rate, which was the glitch under the action ramp. Every move caption wears its own color, a hue hashed off the move's name, and names the keys behind it in the command stream's glyphs: a dash is "> >", a kick while moving is "> /". Sideways dashes leave speed lines behind the body, and a lone head gets a real boost from the double tap — a shove along its roll with a hop and a spin — where before the double tap did nothing for a head. The near terrain skirt wears the wall material in four bands instead of a flat shade, so the bottom of the TV frame is not a blank slab under a photographed floor; the reel's deep skirt keeps its shade. Tall structural walls still refuse the stretched strip. Rides along: the daylight backdrop, ambient light and browser theme toggle that were sitting uncommitted in the checkout — the light-mode sky in photo-theme.mjs, the toggle in mac-test.html, and bracket keys for the shoulders. Co-Authored-By: Claude Fable 5.1 --- xbox/live/mac-test.html | 32 +++- xbox/live/oskiewar.js | 214 +++++++++++++++++++++++++-- xbox/live/photo-theme.mjs | 20 ++- xbox/live/tests/oskiewar.test.mjs | 119 ++++++++++++++- xbox/live/tests/photo-theme.test.mjs | 2 +- 5 files changed, 368 insertions(+), 19 deletions(-) diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html index 3bd55ca118..9876354493 100644 --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -127,6 +127,13 @@ box-shadow: 2px 3px 0 rgba(20, 25, 38, .35); cursor: pointer; transition: transform .12s ease, background .12s ease, box-shadow .12s ease; } + #theme-toggle { position: fixed; right: max(14px, env(safe-area-inset-right)); + bottom: max(14px, env(safe-area-inset-bottom)); z-index: 4; + padding: 9px 12px; border: 2px solid #5b647a; border-radius: 9px; + background: #b8c1d2; color: #172036; cursor: pointer; + font: 700 15px/1 "Comic Relief", Arial, sans-serif; } + body:not(.title-open) #theme-toggle, body.social-preview #theme-toggle { display: none; } + #theme-toggle:focus-visible { outline: 3px solid #e6cd5c; outline-offset: 2px; } #logout:hover { background: #d9dfee; transform: translate(-1px, -2px); box-shadow: 4px 6px 0 rgba(20, 25, 38, .4); } #logout:active { transform: translate(1px, 2px); @@ -195,6 +202,7 @@ +
@@ -249,6 +257,7 @@ let titleOpen = true; const keyMap = new Map([ ["KeyW", [0, "ArrowUp"]], ["KeyS", [0, "ArrowDown"]], + ["BracketLeft", [0, "LeftShoulder"]], ["BracketRight", [0, "RightShoulder"]], ["KeyA", [0, "ArrowLeft"]], ["KeyD", [0, "ArrowRight"]], ["Space", [0, "A"]], ["Enter", [0, "B"]], ["ShiftLeft", [0, "X"]], ["ShiftRight", [0, "X"]], @@ -892,7 +901,28 @@ if (new URLSearchParams(location.search).has("self-play")) globalThis.__oskiewarSelfPlay = true; - const browserTheme = new URLSearchParams(location.search).get("theme"); + let browserTheme = new URLSearchParams(location.search).get("theme"); + if (!["light", "dark", "sun"].includes(browserTheme)) { + try { browserTheme = localStorage.getItem("oskiewar-theme"); } catch {} + if (!["light", "dark", "sun"].includes(browserTheme)) browserTheme = "sun"; + } + // The toggle is a web-only control; the test shell's document has no + // such button, so every touch of it is guarded. + const themeButton = document.querySelector?.("#theme-toggle"); + const themeButtonLive = Boolean(themeButton?.addEventListener); + function updateThemeButton() { + if (!themeButtonLive) return; + themeButton.textContent = browserTheme === "sun" ? "Auto theme" : + browserTheme === "light" ? "Light mode" : "Dark mode"; + themeButton.setAttribute("aria-label", themeButton.textContent + "; change appearance"); + } + if (themeButtonLive) themeButton.addEventListener("click", () => { + browserTheme = browserTheme === "sun" ? "light" : browserTheme === "light" ? "dark" : "sun"; + try { localStorage.setItem("oskiewar-theme", browserTheme); } catch {} + updateThemeButton(); + themeButton.blur(); + }); + updateThemeButton(); function capabilities() { const mac = navigator.userAgent.includes("Mac"); const hasGamepad = padSeats.some((padIndex) => padIndex !== null); diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index cd97890298..8cb4ee51a1 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -2858,10 +2858,69 @@ function makeRoundReplayFrame(now) { // kill shows the thing that made it. bullets: structuredClone(bullets), grenades: structuredClone(grenades), + // And what it did: the strike sparks, the death burst, a blast ring and + // the limbs that came off. Without them a replay was two bodies moving + // through an explosion that never showed. + impacts: structuredClone(impacts), + detachedParts: structuredClone(detachedParts), camera: { center: cameraCenter, centerY: cameraCenterY, width: cameraWidth }, }; } +// Reconcile a live collection with a frame's copy without replacing the +// objects that survive: render interpolation is keyed on object identity, so +// a mote or a limb that keeps its object keeps its smooth motion between +// ticks. `key` pairs entries across frames; index order is the fallback. +function syncReplayCollection(target, source, key) { + const wanted = source || []; + const kept = []; + for (let index = 0; index < wanted.length; index++) { + const state = wanted[index]; + const match = key + ? target.find((item) => item[key] === state[key] && !kept.includes(item)) + : target[index]; + if (match && !kept.includes(match)) { + syncReplayObject(match, state); + kept.push(match); + } else kept.push(structuredClone(state)); + } + target.splice(0, target.length, ...kept); +} + +function syncReplayObject(target, state) { + for (const field of Object.keys(state)) { + const value = state[field]; + if (Array.isArray(value) && Array.isArray(target[field]) && + target[field].length === value.length && + value.every((entry) => entry && typeof entry === "object")) { + value.forEach((entry, index) => syncReplayObject(target[field][index], entry)); + } else target[field] = value && typeof value === "object" + ? structuredClone(value) : value; + } + for (const field of Object.keys(target)) + if (!(field in state)) delete target[field]; +} + +// Frames land every instantReplayStepUs; the sim ticks faster than that and +// slower still under the action ramp, so a body read straight off the nearer +// frame steps while its shadow and position glide. Blend the two neighbouring +// poses by the cursor's fraction instead — every numeric field, head and +// segment alike, when both frames describe the same body. +function blendReplayGeometry(from, to, mix) { + if (!to || !from || mix <= 0 || !from.segments || !to.segments || + from.segments.length !== to.segments.length) return from; + const lerpFields = (a, b) => { + const out = { ...a }; + for (const field of Object.keys(a)) + if (typeof a[field] === "number" && typeof b?.[field] === "number") + out[field] = a[field] + (b[field] - a[field]) * mix; + return out; + }; + return { ...from, head: lerpFields(from.head, to.head), + segments: from.segments.map((segment, index) => + lerpFields(segment, to.segments[index])) }; +} + function captureRoundReplay(now, force = false) { if (!INSTANT_REPLAY) return; if (!force && now - roundReplayLastAt < instantReplayStepUs) return; @@ -2897,17 +2956,23 @@ function captureFrameTelemetry(now, force = false) { frameTelemetryFlushAt = now + 1000000; } -function applyRoundReplayFrame(frame, now) { +function applyRoundReplayFrame(frame, now, next = null, mix = 0) { if (frame.ropes) skateRopes.splice(0, skateRopes.length, ...structuredClone(frame.ropes)); for (let index = 0; index < players.length; index++) { const player = players[index]; const state = frame.players[index]; + const ahead = next?.players[index]; for (const key of ["x", "y", "z", "vx", "vy", "vz", "facing", "grounded", "ducking", "alive", "blocking", "blockFlash", "hit", "attackKind", "ropeIndex", "ropeLink", "skateRotation", "skateboard"]) player[key] = state[key]; + const blend = ahead && mix > 0 && ahead.alive === state.alive; + if (blend) + for (const key of ["x", "y", "z"]) + player[key] = state[key] + (ahead[key] - state[key]) * mix; player.attackStartedAt = now + state.attackStartedOffset; player.attackUntil = now + state.attackUntilOffset; - player.replayGeometry = state.geometry; + player.replayGeometry = blend + ? blendReplayGeometry(state.geometry, ahead.geometry, mix) : state.geometry; } const frameBalls = frame.balls || [frame.ball]; for (let index = 0; index < balls.length; index++) { @@ -2916,8 +2981,11 @@ function applyRoundReplayFrame(frame, now) { for (const key of ["x", "y", "z", "vx", "vy", "active"]) balls[index][key] = state[key]; } - if (frame.bullets) bullets.splice(0, bullets.length, ...structuredClone(frame.bullets)); - if (frame.grenades) grenades.splice(0, grenades.length, ...structuredClone(frame.grenades)); + // Frames recorded before a collection rode along leave the live one be. + if (frame.bullets) syncReplayCollection(bullets, frame.bullets); + if (frame.grenades) syncReplayCollection(grenades, frame.grenades); + if (frame.impacts) syncReplayCollection(impacts, frame.impacts, "id"); + if (frame.detachedParts) syncReplayCollection(detachedParts, frame.detachedParts); cameraCenter = frame.camera.center; cameraCenterY = frame.camera.centerY; cameraWidth = frame.camera.width; @@ -3062,7 +3130,9 @@ function updateInstantReplay(now, dt) { return; } } - applyRoundReplayFrame(instantReplay.frames[Math.floor(instantReplay.cursor)], now); + const at = Math.floor(instantReplay.cursor); + applyRoundReplayFrame(instantReplay.frames[at], now, + instantReplay.frames[at + 1] || null, instantReplay.cursor - at); instantReplay.previous = down.slice(); const target = { x: cameraCenter, y: cameraCenterY, z: 0 }; const visible = activePlayers(); @@ -7421,9 +7491,55 @@ function quantizedInput(pad, suppressed = []) { return { horizontal, vertical }; } +// The keys behind a move, in the command stream's own glyphs: a double-tap +// move names its two taps, anything else names what the hand is holding as +// the move lands. Directions first, then the face buttons, so "> /" reads as +// "right then kick" the way the fingers did it. +const moveKeyGlyphs = { ArrowLeft: "<", ArrowRight: ">", ArrowUp: "^", + ArrowDown: "v", A: "/", B: "*", X: ")", Y: "+", LeftShoulder: "+", + RightShoulder: "+" }; +const doubleTapKeys = { "DASH LEFT": "< <", "DASH RIGHT": "> >", + "ULTRA AIR": "^ ^", "GROUND POUND": "v v", SINK: "v v", + "CROUCH HOP": "v v", "VERT OUT": "> >" }; +function moveKeys(label, held = []) { + const move = String(label || "").toUpperCase(); + for (const name of Object.keys(doubleTapKeys)) + if (move === name || move.startsWith(name + " ")) + return doubleTapKeys[name].replace(/>/g, move.endsWith("LEFT") ? "<" : ">"); + const keys = []; + for (const button of Object.keys(moveKeyGlyphs)) + if (held.includes(button) && !keys.includes(moveKeyGlyphs[button])) + keys.push(moveKeyGlyphs[button]); + return keys.join(" "); +} + +// Every move wears its own color: a hue hashed off the name, spread by the +// golden angle so neighbours in the table land far apart on the wheel, at a +// saturation and lightness that stay legible over the arena in both themes. +function moveColor(label) { + let hash = 0; + for (const char of String(label || "").toUpperCase()) + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + const hue = (hash * 137.508) % 360; + return hslColor(hue, .82, .62); +} + +function hslColor(hue, saturation, lightness) { + const chroma = (1 - Math.abs(2 * lightness - 1)) * saturation; + const wedge = (hue % 360 + 360) % 360 / 60; + const second = chroma * (1 - Math.abs(wedge % 2 - 1)); + const [r, g, b] = wedge < 1 ? [chroma, second, 0] : wedge < 2 ? [second, chroma, 0] + : wedge < 3 ? [0, chroma, second] : wedge < 4 ? [0, second, chroma] + : wedge < 5 ? [second, 0, chroma] : [chroma, 0, second]; + const lift = lightness - chroma / 2; + return [r, g, b].map((value) => Math.round((value + lift) * 255)); +} + function remember(player, button) { player.lastButton = buttonLabel(button); player.lastButtonAt = runtime().monotonicUs; + player.lastButtonKeys = moveKeys(player.lastButton, + inputPads[player.pad]?.down || []); const command = { A: "/", B: "*", X: ")", Y: "+", LeftShoulder: "+", RightShoulder: "+" }[button] || player.lastButton; if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", @@ -8649,6 +8765,8 @@ function updateBall(ball, dt, now) { // Returns whether the tap was spent on a double-tap move, so a single tap can // still mean something else to the caller. +const headDashSpeed = 2600; +const headDashHop = 380; function directionTap(player, direction, now) { const previousTap = player.lastTap[direction] || -10000000; const releasedAt = player.lastRelease[direction] || -10000000; @@ -8729,6 +8847,23 @@ function directionTap(player, direction, now) { } } else { player.facing = direction === "RIGHT" ? 1 : -1; + // Every sideways dash streaks: the lines trail the body for a beat. + player.dashLinesUntil = now + 260000; + player.dashLinesFacing = player.facing; + // A head has no legs to dash on; the double-tap boosts the ball instead — + // a shove along the floor with a hop and a spin, the way it travels. + if (isHeadOnly(player)) { + // A head's momentum is its roll (`skateVx`), grounded or airborne, so + // the boost goes there rather than into the on-foot dash lane. + player.skateVx = player.facing * + Math.max(Math.abs(player.skateVx || 0), headDashSpeed); + player.vx = player.skateVx; + if (player.grounded) player.vy = Math.min(player.vy, -headDashHop); + player.grounded = false; + player.headRollRate = player.facing * 6; + emitSignal("dash", player.pad, player.facing, 1); + return true; + } // Out of a vert air, a dash breaks the lock: the rider leaves the pipe's // plane sideways — over the coping onto the deck, or back out across // the pipe. @@ -11266,6 +11401,9 @@ const photoRegions = { gun: [51, 543, 365, 253], skateboard: [460, 611, 412, 136], platform: [900, 606, 418, 162], rocket: [1368, 542, 372, 236], concrete: [970, 625, 270, 43], wall: [930, 698, 350, 55], + // The same material as `wall`, for the near skirt, which is drawn in + // bands so the strip repeats instead of stretching into a curtain. + skirt: [930, 698, 350, 55], }; function photoSprite(region, x, y, width, height, angle = 0, flip = false, depth = triangleDepth) { @@ -13801,6 +13939,26 @@ function drawDiveMotion(player, t) { } } +// Speed lines trail a sideways dash: horizontal streaks behind the body +// (or behind the head, when that is all there is), fading over the beat. +function drawDashMotion(player, t) { + const now = runtime().monotonicUs; + if (now >= (player.dashLinesUntil || 0)) return; + const life = clamp((player.dashLinesUntil - now) / 260000, 0, 1); + const facing = player.dashLinesFacing || player.facing || 1; + const color = mixColor(player.color, [244, 250, 255], .55); + const head = isHeadOnly(player); + const count = head ? 4 : 6; + for (let index = 0; index < count; index++) { + const y = head ? player.y - 8 - index * 9 + : player.y - 24 - index * 22 + Math.sin(t * 17 + index) * 3; + const startX = player.x - facing * (head ? 26 : 34 + index % 2 * 14); + const endX = startX - facing * (56 + index * 12) * life; + worldCapsule(startX, y, player.z + 16, endX, y, player.z + 16, + 3 + life * 3, color, .029); + } +} + function drawDoubleJumpMotion(player, t) { const now = runtime().monotonicUs; if (now >= (player.doubleJumpLinesUntil || 0)) return; @@ -14553,7 +14711,25 @@ function terrainVertex(slot, index, x, y, z) { // `bottomY` null: the bottom vertex shares the profile's y (the surface // pass, top at z `zTop`, bottom at z `zBottom`). Otherwise a wall: both // vertices at `zTop`, the bottom one dropped to `bottomY`. -function terrainPass(left, right, zTop, zBottom, bottomY, shadeOf) { +// The near skirt fills the bottom of the frame at the TV lens, and a flat +// shade there read as a blank slab under a photographed floor. Split each +// segment's drop into `rows` bands, each wearing the whole wall strip, so +// the material repeats down the face at about the strip's own proportion. +// Falls back to the shaded quad when the theme cannot draw (no atlas, or a +// corner off the frame), so the wall is never missing. +function terrainSkirtTiles(topLeft, topRight, bottomRight, bottomLeft, rows) { + for (let row = 0; row < rows; row++) { + const from = row / rows, to = (row + 1) / rows; + const corner = (top, bottom, mix) => projectPoint(top.x, + top.y + (bottom.y - top.y) * mix, top.z); + if (!photoSurface("skirt", corner(topLeft, bottomLeft, from), + corner(topRight, bottomRight, from), corner(topRight, bottomRight, to), + corner(topLeft, bottomLeft, to))) return row > 0; + } + return true; +} + +function terrainPass(left, right, zTop, zBottom, bottomY, shadeOf, tileRows = 1) { const { top, bottom } = terrainScratch; const wall = bottomY !== null; const terrainProfile = terrainDrawProfile; @@ -14574,6 +14750,11 @@ function terrainPass(left, right, zTop, zBottom, bottomY, shadeOf) { const a1 = top[previous], b1 = bottom[previous]; const shade = shadeOf(previous); if (a1.front && a.front && b.front && b1.front) { + if (wall && tileRows > 1 && photoThemeActive && + terrainSkirtTiles(a1, a, b, b1, tileRows)) { + previous = index; + continue; + } if (photoSurface(wall ? "wall" : "concrete", a1.screen, a.screen, b.screen, b1.screen)) { previous = index; continue; @@ -14636,7 +14817,9 @@ function drawTerrainFrontWall(left, right, near, color) { const wall = mixColor(color, [63, 54, 46], .36); const wallZ = reel ? 55 : near - 2; const wallBottom = parkDeepest + (reel ? 9000 : 720); - terrainPass(left, right, wallZ, wallZ, wallBottom, () => wall); + // Four bands down the 720 drop keeps the strip near its own proportion; + // the reel's deep skirt stays shaded, its framing is test-enshrined. + terrainPass(left, right, wallZ, wallZ, wallBottom, () => wall, reel ? 1 : 4); } function drawTerrainBackWall(left, right, far, color) { @@ -14973,7 +15156,7 @@ function drawPoseShadow(player, t, color) { function updateSceneLighting(now) { if (typeof themeLighting !== "function") return; - if (!photoThemeActive || renderFlags.lighting === false) { themeLighting([]); return; } + if (!photoThemeActive || renderFlags.lighting === false) { themeLighting([], visualTheme.light); return; } const span = worldRight - worldLeft; const lights = [.18,.82].map((part) => { const x = worldLeft + span * part; @@ -14992,7 +15175,7 @@ function updateSceneLighting(now) { const point = projectPoint(pose.muzzle.x,pose.muzzle.y,pose.muzzle.z); lights.push({x:point.x,y:point.y,radius:Math.max(60,450*cameraScale()),strength:.9}); } - themeLighting(lights); + themeLighting(lights, visualTheme.light); } function drawSpotShadow(x, y, z, radius, color) { @@ -16839,6 +17022,7 @@ function gamePaint() { else { drawDiveMotion(renderable.item, t); drawDoubleJumpMotion(renderable.item, t); + drawDashMotion(renderable.item, t); drawRunner(renderable.item, t, showRunnerLabels); } } @@ -17059,10 +17243,20 @@ function drawVersusHud(t, ink, run) { const moveWidth = handleWidth(player.lastButton, moveSize); const moveX = viewCenterX() - moveWidth / 2; const moveY = Math.round(viewHeight * .62) - Math.round((1 - fade) * 26); - const moveInk = fade > .4 ? ink : mixColor(contrastShadow(ink), ink, fade / .4); + // The move's own color, and under it the keys that made it. + const tint = moveColor(player.lastButton); + const moveInk = fade > .4 ? tint : mixColor(contrastShadow(ink), tint, fade / .4); typeWrite(player.lastButton, moveX + 3, moveY + 3, moveSize, ...contrastShadow(ink)); typeWrite(player.lastButton, moveX, moveY, moveSize, ...moveInk); + const keys = player.lastButtonKeys; + if (!keys) return; + const keySize = Math.round(moveSize * .6); + const keyWidth = handleWidth(keys, keySize); + const keyX = viewCenterX() - keyWidth / 2; + const keyY = moveY + moveSize + 6; + typeWrite(keys, keyX + 2, keyY + 2, keySize, ...contrastShadow(ink)); + typeWrite(keys, keyX, keyY, keySize, ...moveInk); } function boot() { diff --git a/xbox/live/photo-theme.mjs b/xbox/live/photo-theme.mjs index 699c38a331..bc20caac4c 100644 --- a/xbox/live/photo-theme.mjs +++ b/xbox/live/photo-theme.mjs @@ -4,7 +4,7 @@ export default function createPhotoTheme(context, flushFaces, { ImageImpl = globalThis.Image, timeoutMs = 12000, } = {}) { const images = [], highlights = []; - let lights = []; + let lights = [], daylight = null, ambient = 0; function makeHighlight(image) { if (typeof OffscreenCanvas !== 'function') return null; const canvas = new OffscreenCanvas(image.naturalWidth, image.naturalHeight); @@ -22,6 +22,14 @@ export default function createPhotoTheme(context, flushFaces, { try { await image.decode?.(); images[id] = image; + if (id === 0 && typeof OffscreenCanvas === 'function') { + daylight = new OffscreenCanvas(image.naturalWidth,image.naturalHeight); + const sky = daylight.getContext('2d'); + sky.drawImage(image,0,0); + sky.globalCompositeOperation = 'screen'; + sky.fillStyle = 'rgba(190,207,218,.86)'; + sky.fillRect(0,0,daylight.width,daylight.height); + } if (id === 1 || id === 3) highlights[id] = makeHighlight(image); clearTimeout(timeout); resolve(true); @@ -48,9 +56,9 @@ export default function createPhotoTheme(context, flushFaces, { context.translate(x, y); context.rotate(angle); context.scale(flip ? -1 : 1, 1); - context.drawImage(images[id], sx, sy, sw, sh, -width / 2, -height / 2, width, height); - if (highlights[id] && lights.length && id !== 0) { - let intensity = 0; + context.drawImage(id === 0 && ambient >= .5 && daylight ? daylight : images[id], sx, sy, sw, sh, -width / 2, -height / 2, width, height); + if (highlights[id] && (lights.length || ambient) && id !== 0) { + let intensity = ambient * .65; for (const light of lights) { const dx = x-light.x, dy = y-light.y; const attenuation = Math.max(0, 1-(dx*dx+dy*dy)/(light.radius*light.radius)); @@ -91,6 +99,8 @@ export default function createPhotoTheme(context, flushFaces, { return { ready: Promise.all(loads.slice(0, 2)), themeReady: () => Boolean(images[0] && images[1]), themeAssetReady: id => Boolean(images[id]), themeSprite, themeQuad, - themeLighting: values => { lights = values.slice(0,4).filter(light => + themeLighting: (values, light = 0) => { + ambient = Number.isFinite(light) ? Math.max(0,Math.min(1,light)) : 0; + lights = values.slice(0,4).filter(light => [light.x,light.y,light.radius,light.strength].every(Number.isFinite) && light.radius > 0); } }; } diff --git a/xbox/live/tests/oskiewar.test.mjs b/xbox/live/tests/oskiewar.test.mjs index 663b4f2e83..8281122a91 100644 --- a/xbox/live/tests/oskiewar.test.mjs +++ b/xbox/live/tests/oskiewar.test.mjs @@ -134,7 +134,7 @@ function createFight(startImmediately = true, enterGame = true, 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 { configureWorldMap, resetSkateRopes, skateRopes, skateLoops, skateBoosts, skateRopeInput, updateSkateRopes, skateLoopStep, skateBoostStep, spectatorState, netSnapshot, netRestore, netStateHash, get gridCols() { return gridCols; }, get gridWidth() { return gridWidth; }, boot, sim, paint, playDrum, airParticles, countPoseBuilds: (run) => { const original = buildRunnerWorldGeometry; let count = 0; buildRunnerWorldGeometry = (...args) => { count++; return original(...args); }; try { run(); } finally { buildRunnerWorldGeometry = original; } return count; }, skateContact, skateFrame, resetSkate, dismountSkateboard, terrainProfile, terrainSamples, 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, impacts, gunPickups, saberPickups, grenadePickups, bodyTrees, treeFruit, detachedParts, runnerWorldGeometry, fighterAnimationPhase, sampleCombatBoxes, combatBoxContact, jevStrikeOptions, combatRect, buildBoxTree, queryBoxTree, boxesOverlap, 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, frameMeterState: (index) => frameMeterState(players[index], runtime().monotonicUs), frameMeters: () => frameMeters.map((meter) => meter.slice()), windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), killLocal: () => killPlayer(players[0], 0, runtime().monotonicUs, "BLASTED"), 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), startSurvival: (bot = false) => startSurvivalRun(runtime().monotonicUs, bot), survivalState: () => ({ active: survivalActive(), lavaY: survivalLavaY, height: survivalHeight, bestHeight: survivalBestHeight, peakLevel: survivalPeakLevel }), palSelect: () => PAL_SELECT, titleToyState: () => ({ title: titleToys.map((toy) => ({ ...toy })) }), selectionState: () => ({ selecting, step: selectionStep, cursor: selectionCursor, ready: selectionReady.slice() }), selectionOptions: () => selectionOptions().map((option) => ({ kind: option.kind, label: option.fighter.handle, disabled: Boolean(option.disabled) })), cameraInputState: () => ({ yaw: playerCameraYaw, pitch: playerCameraPitch, zoom: playerCameraZoom }), 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, terrainFloorAt, terrainTangentAt, stageGeometry: () => ({ platformY, platformLeft, platformRight, floorY, ceilingY, worldLeft, worldRight, worldNear, worldFar }), platformTable: () => activeLedges().map((rung) => ({ ...rung })), backToTitle: () => returnToTitle(runtime().monotonicUs, "test"), enterLobby: () => beginVersusLobby(runtime().monotonicUs), enterFreeskate: () => beginFreeskate(runtime().monotonicUs), halfpipe, halfpipeRadius, tileCenterX, freeskateState: () => freeskateActive(), lobbyState: () => ({ lane: fightOpponent, lobby: lobbyActive() }), botSceneState: (index = 0) => players[index].botScene, botSceneNow: (index = 0) => botScene(players[index], players[index ? 0 : 1], runtime().monotonicUs), botSubgoalState: (index = 0) => players[index].botSubgoal, frameRect: () => fighterFrameRect(), liveFrameCount: () => liveSequence, roundState: () => ({ roundResult, roundElapsedUs, matchOver, gameplayStarted, roundStartedAt, timed: roundIsTimed(), introAge: runtime().monotonicUs - roundStartedAt, introLimit: roundIntroDurationUs() }), viewerState: () => ({ active: Boolean(roundViewer), mode: roundViewerMode, status: roundViewerStatus, name: matchName }), netHealth, qrBox: spectatorQrBox, mapState: () => ({ name: currentMapName, id: currentMapId, map: workshopSnapshot() }), versusAllowed, versusDoor, setVersusRequiresAccount: (value) => { versusRequiresAccount = Boolean(value); }, wireHealth: () => ({ gapMs: roundViewerGapMs, jitterMs: roundViewerJitterMs, lossPct: roundViewerLossPct, delayMs: roundViewerDelayMs, queued: roundViewerFrames.length }), 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, gameSpeedState: () => gameSpeed, replayActionCurve, replayRampStep, startInstantReplay: (now) => startInstantReplay(now) };` + `${source}\nreturn { configureWorldMap, resetSkateRopes, skateRopes, skateLoops, skateBoosts, skateRopeInput, updateSkateRopes, skateLoopStep, skateBoostStep, spectatorState, netSnapshot, netRestore, netStateHash, get gridCols() { return gridCols; }, get gridWidth() { return gridWidth; }, boot, sim, paint, playDrum, airParticles, countPoseBuilds: (run) => { const original = buildRunnerWorldGeometry; let count = 0; buildRunnerWorldGeometry = (...args) => { count++; return original(...args); }; try { run(); } finally { buildRunnerWorldGeometry = original; } return count; }, skateContact, skateFrame, resetSkate, dismountSkateboard, terrainProfile, terrainSamples, 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, impacts, gunPickups, saberPickups, grenadePickups, bodyTrees, treeFruit, detachedParts, runnerWorldGeometry, fighterAnimationPhase, sampleCombatBoxes, combatBoxContact, jevStrikeOptions, combatRect, buildBoxTree, queryBoxTree, boxesOverlap, 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, frameMeterState: (index) => frameMeterState(players[index], runtime().monotonicUs), frameMeters: () => frameMeters.map((meter) => meter.slice()), windState: () => ({ direction: windDirection, mph: windMph }), nextRound: () => resetRound(runtime().monotonicUs, false), killLocal: () => killPlayer(players[0], 0, runtime().monotonicUs, "BLASTED"), 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), startSurvival: (bot = false) => startSurvivalRun(runtime().monotonicUs, bot), survivalState: () => ({ active: survivalActive(), lavaY: survivalLavaY, height: survivalHeight, bestHeight: survivalBestHeight, peakLevel: survivalPeakLevel }), palSelect: () => PAL_SELECT, titleToyState: () => ({ title: titleToys.map((toy) => ({ ...toy })) }), selectionState: () => ({ selecting, step: selectionStep, cursor: selectionCursor, ready: selectionReady.slice() }), selectionOptions: () => selectionOptions().map((option) => ({ kind: option.kind, label: option.fighter.handle, disabled: Boolean(option.disabled) })), cameraInputState: () => ({ yaw: playerCameraYaw, pitch: playerCameraPitch, zoom: playerCameraZoom }), 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, terrainFloorAt, terrainTangentAt, stageGeometry: () => ({ platformY, platformLeft, platformRight, floorY, ceilingY, worldLeft, worldRight, worldNear, worldFar }), platformTable: () => activeLedges().map((rung) => ({ ...rung })), backToTitle: () => returnToTitle(runtime().monotonicUs, "test"), enterLobby: () => beginVersusLobby(runtime().monotonicUs), enterFreeskate: () => beginFreeskate(runtime().monotonicUs), halfpipe, halfpipeRadius, tileCenterX, freeskateState: () => freeskateActive(), lobbyState: () => ({ lane: fightOpponent, lobby: lobbyActive() }), botSceneState: (index = 0) => players[index].botScene, botSceneNow: (index = 0) => botScene(players[index], players[index ? 0 : 1], runtime().monotonicUs), botSubgoalState: (index = 0) => players[index].botSubgoal, frameRect: () => fighterFrameRect(), liveFrameCount: () => liveSequence, roundState: () => ({ roundResult, roundElapsedUs, matchOver, gameplayStarted, roundStartedAt, timed: roundIsTimed(), introAge: runtime().monotonicUs - roundStartedAt, introLimit: roundIntroDurationUs() }), viewerState: () => ({ active: Boolean(roundViewer), mode: roundViewerMode, status: roundViewerStatus, name: matchName }), netHealth, qrBox: spectatorQrBox, mapState: () => ({ name: currentMapName, id: currentMapId, map: workshopSnapshot() }), versusAllowed, versusDoor, setVersusRequiresAccount: (value) => { versusRequiresAccount = Boolean(value); }, wireHealth: () => ({ gapMs: roundViewerGapMs, jitterMs: roundViewerJitterMs, lossPct: roundViewerLossPct, delayMs: roundViewerDelayMs, queued: roundViewerFrames.length }), 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, gameSpeedState: () => gameSpeed, replayActionCurve, replayRampStep, startInstantReplay: (now) => startInstantReplay(now), blendReplayGeometry, syncReplayCollection, moveKeys, moveColor, isHeadOnly, photoRegions, terrainSkirtTiles };` )( () => ({ monotonicUs: now, unixMs: 1785870000000 + Math.floor(now / 1000), simCount: Math.floor(now / 16667), paintCount: 0, @@ -6266,12 +6266,127 @@ test("self-death replays recent demo frames and returns a playable body", () => for (let frame = 0; frame < 20; frame++) tick(33334); assert.equal(fight.instantReplayState().active, true); assert.ok(fight.instantReplayState().frames <= 90); - for (let frame = 0; frame < 300; frame++) tick(33334); + // The death burst is part of the replay: as the playhead crosses the + // frames after the kill the impact comes back with its debris, then ages + // out the way it did live instead of being frozen or missing. + let burstSeen = false, motesMoved = false; + for (let frame = 0; frame < 600 && fight.instantReplayState().active; frame++) { + const before = fight.impacts.find((impact) => impact.death)?.debris?.[0]; + const snapshot = before ? { x: before.x, y: before.y } : null; + tick(33334); + const burst = fight.impacts.find((impact) => impact.death); + if (burst && fight.instantReplayState().active) { + burstSeen = true; + if (snapshot && burst.debris?.[0] && + (burst.debris[0].x !== snapshot.x || burst.debris[0].y !== snapshot.y)) + motesMoved = true; + } + } + assert.equal(burstSeen, true); + assert.equal(motesMoved, true); assert.equal(fight.instantReplayState().active, false); assert.equal(fight.players[0].alive, true); assert.equal(fight.clientErrorState(), ""); }); +test("replay poses blend between neighbouring frames", () => { + const { fight } = createFight(); + const from = { head: { x: 0, y: 0, z: 0, radius: 10 }, + segments: [{ x1: 0, y1: 0, z1: 0, x2: 10, y2: 0, z2: 0, width: 4, part: "torso", hitboxOnly: false }] }; + const to = { head: { x: 20, y: 10, z: 0, radius: 10 }, + segments: [{ x1: 20, y1: 10, z1: 0, x2: 30, y2: 10, z2: 0, width: 4, part: "torso", hitboxOnly: false }] }; + const mid = fight.blendReplayGeometry(from, to, .5); + assert.deepEqual(mid.head, { x: 10, y: 5, z: 0, radius: 10 }); + assert.equal(mid.segments[0].x1, 10); + assert.equal(mid.segments[0].y2, 5); + assert.equal(mid.segments[0].part, "torso"); + // A body that changed shape (a lost limb) is read off the nearer frame. + const other = { head: to.head, segments: [] }; + assert.equal(fight.blendReplayGeometry(from, other, .5), from); + assert.equal(fight.blendReplayGeometry(from, to, 0), from); +}); + +test("replay collections keep surviving objects for render interpolation", () => { + const { fight } = createFight(); + const live = [{ id: 1, x: 0, life: 1, debris: [{ x: 0, y: 0 }] }, + { id: 2, x: 5, life: .5, debris: [{ x: 5, y: 5 }] }]; + const first = live[0]; + const mote = first.debris[0]; + const frame = [{ id: 1, x: 3, life: .8, debris: [{ x: 9, y: 9 }] }, + { id: 3, x: 7, life: .3, debris: [{ x: 1, y: 1 }] }]; + fight.syncReplayCollection(live, frame, "id"); + assert.equal(live.length, 2); + assert.equal(live[0], first); + assert.equal(live[0].debris[0], mote); + assert.deepEqual(live[0].debris[0], { x: 9, y: 9 }); + assert.equal(live[0].life, .8); + assert.equal(live[1].id, 3); + assert.notEqual(live[1], frame[1]); + fight.syncReplayCollection(live, [], "id"); + assert.equal(live.length, 0); +}); + +test("a move caption names its keys and wears its own color", () => { + const { fight, tap } = createFight(); + tap(0, "ArrowRight"); + tap(0, "ArrowRight"); + assert.equal(fight.players[0].lastButton, "DASH RIGHT"); + assert.equal(fight.players[0].lastButtonKeys, "> >"); + assert.equal(fight.moveKeys("DASH LEFT"), "< <"); + assert.equal(fight.moveKeys("ULTRA AIR"), "^ ^"); + assert.equal(fight.moveKeys("GROUND POUND 2"), "v v"); + assert.equal(fight.moveKeys("KICK", ["A", "ArrowRight"]), "> /"); + assert.equal(fight.moveKeys("KICK", []), ""); + const seen = new Set(["DASH RIGHT", "DASH LEFT", "PUNCH", "KICK", "SHIELD", + "ULTRA AIR", "GROUND POUND", "GRENADE 2", "ROCKET 1"].map((label) => + fight.moveColor(label).join(","))); + assert.equal(seen.size, 9); + assert.deepEqual(fight.moveColor("punch"), fight.moveColor("PUNCH")); + for (const channel of fight.moveColor("PUNCH")) + assert.ok(channel >= 0 && channel <= 255); + assert.match(source, /moveColor\(player\.lastButton\)/); + assert.match(source, /const keys = player\.lastButtonKeys/); +}); + +test("the near terrain skirt wears the wall material in bands", () => { + const { fight } = createFight(); + assert.deepEqual(fight.photoRegions.skirt, fight.photoRegions.wall); + // The TV skirt is tiled four deep; the reel's deep skirt keeps its shade. + assert.match(source, /terrainPass\(left, right, wallZ, wallZ, wallBottom, \(\) => wall, reel \? 1 : 4\)/); + assert.match(source, /if \(wall && tileRows > 1 && photoThemeActive &&\s*terrainSkirtTiles\(a1, a, b, b1, tileRows\)\)/); + // Without the photo atlas the bands decline and the shaded quad still draws. + const corner = (x, y) => ({ x, y, z: 0 }); + assert.equal(fight.terrainSkirtTiles(corner(0, 0), corner(90, 0), + corner(90, 720), corner(0, 720), 4), false); + // Tall structural walls still refuse the stretched strip. + assert.match(source, /if \(region === "wall"\) return false;/); +}); + +test("a sideways dash streaks motion lines and boosts a lone head", () => { + const { fight, tap, tick, now } = createFight(); + tap(0, "ArrowRight"); + tap(0, "ArrowRight"); + assert.equal(fight.players[0].lastButton, "DASH RIGHT"); + assert.ok(fight.players[0].dashLinesUntil > now()); + assert.equal(fight.players[0].dashLinesFacing, 1); + tick(300000); + assert.ok(fight.players[0].dashLinesUntil <= now()); + assert.match(source, /drawDashMotion\(renderable\.item, t\)/); + // Everything but the head gone, the double-tap still moves it: a shove + // along the floor with a hop, rather than nothing. + const player = fight.players[0]; + player.removedParts = ["torso", "left-arm", "right-arm", "left-leg", "right-leg"]; + assert.equal(fight.isHeadOnly(player), true); + tick(40000); + player.vx = 0; + tap(0, "ArrowLeft"); + tap(0, "ArrowLeft"); + assert.equal(player.lastButton, "DASH LEFT"); + assert.ok(player.vx < -2000); + assert.ok(player.dashLinesUntil > now()); + assert.equal(player.dashLinesFacing, -1); +}); + test("a completed demo plays one modem receipt only after upload", async () => { const { fight, drums, tick } = createFight(); fight.players[0].score = 1; diff --git a/xbox/live/tests/photo-theme.test.mjs b/xbox/live/tests/photo-theme.test.mjs index a370868975..210ffc93c2 100644 --- a/xbox/live/tests/photo-theme.test.mjs +++ b/xbox/live/tests/photo-theme.test.mjs @@ -50,7 +50,7 @@ test('lighting caches highlights once and adds no pass outside a light', async() try { const {theme,calls}=fixture(); await theme.ready; const count=cached; - assert.equal(count,2, 'only props and weapons get a highlight atlas'); + assert.equal(count,3, 'daylight backdrop plus props and weapon highlights'); theme.themeLighting([{x:100,y:200,radius:100,strength:1}]); theme.themeSprite(1,80,104,285,285,100,200,60,70); assert.equal(calls.filter(c=>c[0]==='drawImage').length,2); -- 2.51.2