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);