From 31408cb64abe6d0d0c6973c316ff0a4f126f33ac Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Fri, 25 Sep 2026 19:07:14 -0700 Subject: [PATCH] oskiewar: replays carry projectiles and wind back; a large result headline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replay frames now record bullets, grenades and rockets in flight, so a replayed kill shows what made it. A replay plays through, winds back at 2.5x to just before its biggest moment and plays through it again. The round result is one large headline a fifth of the way down — you win / you lose to a player, the winner's name between bots, tie for a draw — instead of a small mark in the top lane where pickups and decks drew over it; the timer lane no longer repeats "tie!". Co-Authored-By: Claude Opus 5.5 --- .../oskiewar.app/Contents/MacOS/oskiewar | Bin 373728 -> 373728 bytes .../Contents/Resources/live/oskiewar.js | 77 ++++++++++++++---- .../Contents/_CodeSignature/CodeResources | 4 +- xbox/live/oskiewar.js | 75 +++++++++++++---- xbox/live/tests/oskiewar.test.mjs | 30 +++++++ 5 files changed, 155 insertions(+), 31 deletions(-) diff --git a/xbox/builds/macos/oskiewar.app/Contents/MacOS/oskiewar b/xbox/builds/macos/oskiewar.app/Contents/MacOS/oskiewar index fcab9cba11d4945a6c1977f5c75178f47c33407c..db330378b40f503aadf171dd22694505d3608530 100755 GIT binary patch delta 58 zcmaFxTF7M3ln28$JDUUW)pzsY|m)v0FL@yMe;1sA?yH{Ew>pYW8^ Qb&}IOgtrGQW}P4a02ja-b^rhX delta 58 zcmaFxTF7M3ln28$JH_OfS9@iM8cJ@_^@F0o;o($l#Y8MeG-_ ({ x: item.x, y: item.y, z: item.z, vx: item.vx, vy: item.vy, active: item.active })), + // What was in the air: shots, grenades and rockets, so a replay of a + // kill shows the thing that made it. + bullets: structuredClone(bullets), + grenades: structuredClone(grenades), camera: { center: cameraCenter, centerY: cameraCenterY, width: cameraWidth }, }; } @@ -2912,6 +2916,8 @@ 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)); cameraCenter = frame.camera.center; cameraCenterY = frame.camera.centerY; cameraWidth = frame.camera.width; @@ -2988,7 +2994,16 @@ function startInstantReplay(now, respawn = null) { respawn, previous: padSnapshots[0]?.down?.slice() || [], action: replayActionCurve(frames), speed: 1, + // Played through once, then wound back to just before the biggest + // moment and played through it again — the way a replay is watched. + direction: 1, pass: 1, endFrame: frames[frames.length - 1] }; + const curve = instantReplay.action || []; + let peak = -1, peakAction = 0; + for (let index = 0; index < curve.length; index++) + if (curve[index] > peakAction) { peakAction = curve[index]; peak = index; } + instantReplay.rewindTo = peak >= 0 && frames.length > 30 + ? Math.max(0, peak - 24) : -1; impacts.length = 0; detachedParts.length = 0; applyRoundReplayFrame(frames[0], now); @@ -3023,15 +3038,29 @@ function updateInstantReplay(now, dt) { const elapsed = Math.max(0, now - instantReplay.lastAt) / 1000000; instantReplay.speed = replayRampStep(instantReplay.speed, instantReplay.action?.[Math.floor(instantReplay.cursor)] || 0, elapsed); - if (!instantReplay.paused) - instantReplay.cursor += (now - instantReplay.lastAt) / - instantReplayStepUs * instantReplay.speed; + if (!instantReplay.paused) { + const step = (now - instantReplay.lastAt) / instantReplayStepUs; + // The rewind runs at a steady 2.5× so it reads as a rewind. + instantReplay.cursor += instantReplay.direction < 0 + ? -step * 2.5 : step * instantReplay.speed; + } instantReplay.lastAt = now; + if (instantReplay.direction < 0 && instantReplay.cursor <= instantReplay.rewindTo) { + instantReplay.cursor = instantReplay.rewindTo; + instantReplay.direction = 1; + } instantReplay.cursor = clamp(instantReplay.cursor, 0, instantReplay.frames.length); if (instantReplay.cursor >= instantReplay.frames.length) { - finishInstantReplay(now); - return; + if (instantReplay.pass === 1 && instantReplay.rewindTo >= 0 && !instantReplay.paused) { + instantReplay.pass = 2; + instantReplay.direction = -1; + instantReplay.cursor = instantReplay.frames.length - 1; + emitSignal("replay-rewind", -1, instantReplay.rewindTo, 0); + } else { + finishInstantReplay(now); + return; + } } applyRoundReplayFrame(instantReplay.frames[Math.floor(instantReplay.cursor)], now); instantReplay.previous = down.slice(); @@ -7326,6 +7355,20 @@ function finishRound(now) { if (matchOver && !netSession) finishReplay(); } +function resultHeadline(result = resultCardText()) { + if (roundResult === "TIE") return "tie"; + const local = players[0]; + const playing = !selfPlay && !local.npc && !local.bot && !replayOvenActive(); + if (playing) { + const localWon = String(local.name || "").toLowerCase() === result.winner || + (!local.name && local.score >= players[1].score); + return localWon ? "you win" : "you lose"; + } + return (result.winner || "nobody") + " wins"; +} +const replayOvenActive = () => typeof capabilities === "function" && + capabilities().replayOven === true; + function resultCardText() { if (roundResult === "TIE") return { winner: "tie", action: "" }; const encoded = roundResult.match(/^(@\S+)\s+WINS\b/i); @@ -16694,8 +16737,8 @@ function gamePaint() { const timedRound = roundIsTimed(); const remainingSeconds = roundResult || !timedRound ? 0 : Math.max(0, Math.ceil((roundDurationUs - roundElapsedUs) / 1000000)); - const timerText = roundResult - ? roundResult === "TIE" ? "tie!" : "" + // The result has its own headline now; the timer lane stays quiet. + const timerText = roundResult ? "" : timedRound ? String(remainingSeconds).padStart(2, "0") : ""; const hud = hudSafeRect(); const timerSize = hudTypeSize; @@ -16881,14 +16924,18 @@ function gamePaint() { 940, 19, inputPads[0]?.down || [], titleInk); } else { const result = resultCardText(); - // The result is a small ownership mark, not a second title screen. - // Character reactions carry the emotional result in the arena. - const winnerSize = compactLayout() ? 30 : 42; - const winnerWidth = handleWidth(result.winner, winnerSize); - const resultY = hudSafeRect().top + 62; - typeWrite(result.winner, viewCenterX() - winnerWidth / 2 + 3, resultY + 4, + // The round's outcome, said plainly and large: a small mark up in the + // top lane was lost under the pickups and decks that live there. It + // sits a fifth of the way down, below that furniture, in the title's + // ink on a heavy shadow — you win / you lose to a player, the winner's + // name when bots fight, tie for a draw. + const winnerSize = compactLayout() ? 66 : 112; + const headline = resultHeadline(result); + const winnerWidth = handleWidth(headline, winnerSize); + const resultY = Math.round(viewHeight * .17); + typeWrite(headline, viewCenterX() - winnerWidth / 2 + 5, resultY + 6, winnerSize, ...statusShadow); - typeWrite(result.winner, viewCenterX() - winnerWidth / 2, resultY, + typeWrite(headline, viewCenterX() - winnerWidth / 2, resultY, winnerSize, ...titleInk); if (result.action) { const actionSize = Math.min(44, winnerSize * .54); diff --git a/xbox/builds/macos/oskiewar.app/Contents/_CodeSignature/CodeResources b/xbox/builds/macos/oskiewar.app/Contents/_CodeSignature/CodeResources index 5c41139ae3..4fb50ff00d 100644 --- a/xbox/builds/macos/oskiewar.app/Contents/_CodeSignature/CodeResources +++ b/xbox/builds/macos/oskiewar.app/Contents/_CodeSignature/CodeResources @@ -14,7 +14,7 @@ Resources/live/oskiewar.js - UxA7Oh0jcqv9OwbkVfOKmqhnR5s= + wSSpA0Ur7AqrShqod+ipPTDrXLU= Resources/live/qr.mjs @@ -57,7 +57,7 @@ hash2 - v4U+vZszzcYJfXQr0T0aryVPKjd8tCT1mwEnUcmmjFI= + yKOto3Tq7UeIamwoNzDklXUMyj3ucStZJZ+kvGfkqwg= Resources/live/qr.mjs diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index a363a4b946..ba7b97ccbf 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -2854,6 +2854,10 @@ function makeRoundReplayFrame(now) { active: ball.active }, balls: balls.map((item) => ({ x: item.x, y: item.y, z: item.z, vx: item.vx, vy: item.vy, active: item.active })), + // What was in the air: shots, grenades and rockets, so a replay of a + // kill shows the thing that made it. + bullets: structuredClone(bullets), + grenades: structuredClone(grenades), camera: { center: cameraCenter, centerY: cameraCenterY, width: cameraWidth }, }; } @@ -2912,6 +2916,8 @@ 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)); cameraCenter = frame.camera.center; cameraCenterY = frame.camera.centerY; cameraWidth = frame.camera.width; @@ -2988,7 +2994,16 @@ function startInstantReplay(now, respawn = null) { respawn, previous: padSnapshots[0]?.down?.slice() || [], action: replayActionCurve(frames), speed: 1, + // Played through once, then wound back to just before the biggest + // moment and played through it again — the way a replay is watched. + direction: 1, pass: 1, endFrame: frames[frames.length - 1] }; + const curve = instantReplay.action || []; + let peak = -1, peakAction = 0; + for (let index = 0; index < curve.length; index++) + if (curve[index] > peakAction) { peakAction = curve[index]; peak = index; } + instantReplay.rewindTo = peak >= 0 && frames.length > 30 + ? Math.max(0, peak - 24) : -1; impacts.length = 0; detachedParts.length = 0; applyRoundReplayFrame(frames[0], now); @@ -3023,15 +3038,29 @@ function updateInstantReplay(now, dt) { const elapsed = Math.max(0, now - instantReplay.lastAt) / 1000000; instantReplay.speed = replayRampStep(instantReplay.speed, instantReplay.action?.[Math.floor(instantReplay.cursor)] || 0, elapsed); - if (!instantReplay.paused) - instantReplay.cursor += (now - instantReplay.lastAt) / - instantReplayStepUs * instantReplay.speed; + if (!instantReplay.paused) { + const step = (now - instantReplay.lastAt) / instantReplayStepUs; + // The rewind runs at a steady 2.5× so it reads as a rewind. + instantReplay.cursor += instantReplay.direction < 0 + ? -step * 2.5 : step * instantReplay.speed; + } instantReplay.lastAt = now; + if (instantReplay.direction < 0 && instantReplay.cursor <= instantReplay.rewindTo) { + instantReplay.cursor = instantReplay.rewindTo; + instantReplay.direction = 1; + } instantReplay.cursor = clamp(instantReplay.cursor, 0, instantReplay.frames.length); if (instantReplay.cursor >= instantReplay.frames.length) { - finishInstantReplay(now); - return; + if (instantReplay.pass === 1 && instantReplay.rewindTo >= 0 && !instantReplay.paused) { + instantReplay.pass = 2; + instantReplay.direction = -1; + instantReplay.cursor = instantReplay.frames.length - 1; + emitSignal("replay-rewind", -1, instantReplay.rewindTo, 0); + } else { + finishInstantReplay(now); + return; + } } applyRoundReplayFrame(instantReplay.frames[Math.floor(instantReplay.cursor)], now); instantReplay.previous = down.slice(); @@ -7326,6 +7355,20 @@ function finishRound(now) { if (matchOver && !netSession) finishReplay(); } +function resultHeadline(result = resultCardText()) { + if (roundResult === "TIE") return "tie"; + const local = players[0]; + const playing = !selfPlay && !local.npc && !local.bot && !replayOvenActive(); + if (playing) { + const localWon = String(local.name || "").toLowerCase() === result.winner || + (!local.name && local.score >= players[1].score); + return localWon ? "you win" : "you lose"; + } + return (result.winner || "nobody") + " wins"; +} +const replayOvenActive = () => typeof capabilities === "function" && + capabilities().replayOven === true; + function resultCardText() { if (roundResult === "TIE") return { winner: "tie", action: "" }; const encoded = roundResult.match(/^(@\S+)\s+WINS\b/i); @@ -16694,8 +16737,8 @@ function gamePaint() { const timedRound = roundIsTimed(); const remainingSeconds = roundResult || !timedRound ? 0 : Math.max(0, Math.ceil((roundDurationUs - roundElapsedUs) / 1000000)); - const timerText = roundResult - ? roundResult === "TIE" ? "tie!" : "" + // The result has its own headline now; the timer lane stays quiet. + const timerText = roundResult ? "" : timedRound ? String(remainingSeconds).padStart(2, "0") : ""; const hud = hudSafeRect(); const timerSize = hudTypeSize; @@ -16881,14 +16924,18 @@ function gamePaint() { 940, 19, inputPads[0]?.down || [], titleInk); } else { const result = resultCardText(); - // The result is a small ownership mark, not a second title screen. - // Character reactions carry the emotional result in the arena. - const winnerSize = compactLayout() ? 30 : 42; - const winnerWidth = handleWidth(result.winner, winnerSize); - const resultY = hudSafeRect().top + 62; - typeWrite(result.winner, viewCenterX() - winnerWidth / 2 + 3, resultY + 4, + // The round's outcome, said plainly and large: a small mark up in the + // top lane was lost under the pickups and decks that live there. It + // sits a fifth of the way down, below that furniture, in the title's + // ink on a heavy shadow — you win / you lose to a player, the winner's + // name when bots fight, tie for a draw. + const winnerSize = compactLayout() ? 66 : 112; + const headline = resultHeadline(result); + const winnerWidth = handleWidth(headline, winnerSize); + const resultY = Math.round(viewHeight * .17); + typeWrite(headline, viewCenterX() - winnerWidth / 2 + 5, resultY + 6, winnerSize, ...statusShadow); - typeWrite(result.winner, viewCenterX() - winnerWidth / 2, resultY, + typeWrite(headline, viewCenterX() - winnerWidth / 2, resultY, winnerSize, ...titleInk); if (result.action) { const actionSize = Math.min(44, winnerSize * .54); diff --git a/xbox/live/tests/oskiewar.test.mjs b/xbox/live/tests/oskiewar.test.mjs index a40c34ddca..663b4f2e83 100644 --- a/xbox/live/tests/oskiewar.test.mjs +++ b/xbox/live/tests/oskiewar.test.mjs @@ -7702,3 +7702,33 @@ test("loops click like the ground does, and a lone head can ride one", () => { } assert.ok(best >= Math.PI * 2, `head went ${(best / Math.PI / 2).toFixed(2)} of the way round`); }); + +test("replays show projectiles, and wind back to play the big moment again", () => { + const { fight, tick, now } = createFight(); + for (let frame = 0; frame < 30; frame++) tick(); + // A shot in flight across the middle of the recording, and a hit on it. + fight.bullets.push({ x: 1500, y: 1700, z: 0, previousX: 1480, previousY: 1700, + vx: 2400, vy: 0, owner: 0, alive: true, life: 1 }); + for (let frame = 0; frame < 20; frame++) { + tick(); + if (frame === 10) fight.players[1].hit = 1; + } + fight.players[1].hit = 0; + for (let frame = 0; frame < 30; frame++) tick(); + // Replays play on the result screen, so end the round first. + fight.knockOut(); + for (let frame = 0; frame < 5; frame++) tick(); + assert.ok(fight.roundState().roundResult, "round over"); + assert.ok(fight.startInstantReplay(now())); + let sawShot = false, wentBack = false, last = -1; + for (let frame = 0; frame < 1200 && fight.instantReplayState().active; frame++) { + tick(); + if (fight.bullets.length) sawShot = true; + const cursor = fight.instantReplayState().cursor; + if (cursor !== undefined && last >= 0 && cursor < last - .5) wentBack = true; + if (cursor !== undefined) last = cursor; + } + assert.ok(sawShot, "the shot is in the replay"); + assert.ok(wentBack, "the replay wound back"); + assert.equal(fight.instantReplayState().active, false, "and then finished"); +}); -- 2.51.2