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