Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
263 changes: 263 additions & 0 deletions src/client/components/GameRecapViewer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import {
RecapFrame,
RecapFrameStore,
} from "../graphics/recapCapture/RecapFrameStore";

@customElement("game-recap-viewer")
export class GameRecapViewer extends LitElement {
@property({ attribute: false })
frameStore: RecapFrameStore | null = null;

@property({ type: Boolean })
autoplay: boolean = true;

@state()
private frames: readonly RecapFrame[] = [];

@state()
private currentIndex = 0;

@state()
private isPlaying = false;

private unsubscribe: (() => void) | null = null;
private playbackHandle: number | null = null;
private lastFrameTime = 0;
private canvas: HTMLCanvasElement | null = null;
private readonly frameIntervalMs = 60;
private loopPauseMs = 0;
private loopHoldActive = false;
private loopHoldConsumed = false;
private loopHoldStart = 0;

createRenderRoot() {
return this;
}

connectedCallback() {
super.connectedCallback();
this.attachStore();
}

disconnectedCallback() {
super.disconnectedCallback();
this.detachStore();
this.stopPlayback();
}

updated(changed: Map<string, unknown>) {
if (changed.has("frameStore")) {
this.attachStore();
}
if (changed.has("frames")) {
this.onFramesUpdated();
}
}

render() {
const hasFrames = this.frames.length > 0;
const firstFrame = this.frames[0];
const aspect = firstFrame
? `${firstFrame.width} / ${firstFrame.height}`
: "16 / 9";
const containerStyle = `width: 100%; aspect-ratio: ${aspect}; max-height: min(60vh, 420px);`;
const showDownloadButton = hasFrames;
return html`
<div
class="relative bg-black rounded overflow-hidden mb-4"
style="${containerStyle}"
>
<canvas class="w-full h-full bg-black block" translate="no"></canvas>
${showDownloadButton
? html`<button
class="absolute top-2 right-2 flex items-center justify-center w-9 h-9 bg-blue-500/80 hover:bg-blue-500 text-white rounded shadow"
aria-label="Download recap"
@click=${this.downloadAutoRecording}
>
<span class="text-lg leading-none" aria-hidden="true">⬇️</span>
</button>`
: null}
${!hasFrames
? html`<div
class="absolute inset-0 flex items-center justify-center text-sm text-white/70"
>
${"Recap capture warming up"}
</div>`
: null}
Comment on lines +82 to +88
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Localize the warming message

Hardcoded text breaks i18n. Use translateText (and add a key in en.json) or accept a property for custom text.

Apply:

+import { translateText } from "../Utils";
 ...
-              ${"Recap capture warming up"}
+              ${translateText("recap.warming")}

Also add "recap.warming": "Recap capture warming up" to en.json.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
${!hasFrames
? html`<div
class="absolute inset-0 flex items-center justify-center text-sm text-white/70"
>
${"Recap capture warming up"}
</div>`
: null}
${!hasFrames
? html`<div
class="absolute inset-0 flex items-center justify-center text-sm text-white/70"
>
${translateText("recap.warming")}
</div>`
: null}
🤖 Prompt for AI Agents
In src/client/components/GameRecapViewer.ts around lines 82-88 the "Recap
capture warming up" string is hardcoded which breaks i18n; replace the literal
with a call to translateText('recap.warming') (or accept a prop like warmingText
and default it to translateText('recap.warming')) and add the key
"recap.warming": "Recap capture warming up" to en.json; ensure imports include
translateText if needed and update the component prop types/defaults when using
a prop.

</div>
`;
}

private attachStore() {
this.detachStore();
if (!this.frameStore) {
return;
}
this.unsubscribe = this.frameStore.subscribe((frames) => {
this.loopPauseMs = this.frameStore?.getLoopPauseMs() ?? 0;
this.frames = [...frames];
});
}

private detachStore() {
this.unsubscribe?.();
this.unsubscribe = null;
this.loopPauseMs = 0;
}

private onFramesUpdated() {
if (this.frames.length === 0) {
this.currentIndex = 0;
this.stopPlayback();
this.paintFrame();
return;
}

if (this.currentIndex >= this.frames.length) {
this.currentIndex = Math.max(0, this.frames.length - 1);
}

const nextPause = this.frameStore?.getLoopPauseMs() ?? this.loopPauseMs;
if (nextPause !== this.loopPauseMs) {
this.loopPauseMs = nextPause;
this.loopHoldConsumed = nextPause <= 0 ? true : false;
if (nextPause <= 0) {
this.loopHoldActive = false;
}
}

this.paintFrame();

if (this.autoplay && !this.isPlaying) {
this.startPlayback();
}
}

private startPlayback() {
if (this.frames.length === 0) {
return;
}
this.isPlaying = true;
this.lastFrameTime = performance.now();
this.loopHoldActive = false;
this.loopHoldConsumed = this.loopPauseMs <= 0;
this.playbackHandle = requestAnimationFrame((time) =>
this.advanceLoop(time),
);
}

private stopPlayback() {
if (this.playbackHandle !== null) {
cancelAnimationFrame(this.playbackHandle);
this.playbackHandle = null;
}
this.isPlaying = false;
}

private advanceLoop(time: number) {
if (!this.isPlaying || this.frames.length === 0) {
return;
}

if (this.loopHoldActive) {
if (time - this.loopHoldStart < this.loopPauseMs) {
this.playbackHandle = requestAnimationFrame((nextTime) =>
this.advanceLoop(nextTime),
);
return;
}
this.loopHoldActive = false;
this.lastFrameTime = time - this.frameIntervalMs;
}

if (time - this.lastFrameTime >= this.frameIntervalMs) {
const atLastFrame = this.currentIndex === this.frames.length - 1;
if (
atLastFrame &&
this.loopPauseMs > 0 &&
!this.loopHoldActive &&
!this.loopHoldConsumed
) {
this.loopHoldActive = true;
this.loopHoldConsumed = true;
this.loopHoldStart = time;
this.playbackHandle = requestAnimationFrame((nextTime) =>
this.advanceLoop(nextTime),
);
return;
}

const wrapped = atLastFrame;
this.stepFrame(1);
this.lastFrameTime = time;
if (wrapped) {
this.handleLoopCompleted();
}
}
this.playbackHandle = requestAnimationFrame((nextTime) =>
this.advanceLoop(nextTime),
);
}

private stepFrame(delta: number) {
if (this.frames.length === 0) {
return;
}
const nextIndex =
(this.currentIndex + delta + this.frames.length) % this.frames.length;
this.currentIndex = nextIndex;
this.paintFrame();
}

private paintFrame() {
this.canvas =
this.canvas ?? (this.querySelector("canvas") as HTMLCanvasElement | null);
if (!this.canvas) {
return;
}
const context = this.canvas.getContext("2d");
if (!context) {
return;
}

if (this.frames.length === 0) {
context.clearRect(0, 0, this.canvas.width, this.canvas.height);
return;
}

const frame = this.frames[this.currentIndex];
if (
this.canvas.width !== frame.width ||
this.canvas.height !== frame.height
) {
this.canvas.width = frame.width;
this.canvas.height = frame.height;
}
this.canvas.style.width = "100%";
this.canvas.style.height = "100%";
context.clearRect(0, 0, this.canvas.width, this.canvas.height);

if (frame.imageBitmap) {
context.drawImage(frame.imageBitmap, 0, 0, frame.width, frame.height);
return;
}

const image = new Image();
image.onload = () => {
context.drawImage(image, 0, 0, frame.width, frame.height);
};
image.src = frame.objectUrl;
}

private handleLoopCompleted() {
this.loopHoldConsumed = this.loopPauseMs <= 0;
}

private downloadAutoRecording = () => {
this.dispatchEvent(
new CustomEvent("recap-request-export", { bubbles: true }),
);
};
}
27 changes: 27 additions & 0 deletions src/client/graphics/GameRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { UILayer } from "./layers/UILayer";
import { UnitDisplay } from "./layers/UnitDisplay";
import { UnitLayer } from "./layers/UnitLayer";
import { WinModal } from "./layers/WinModal";
import { GameRecapCapture } from "./recapCapture/GameRecapCapture";

export function createRenderer(
canvas: HTMLCanvasElement,
Expand Down Expand Up @@ -274,6 +275,23 @@ export function createRenderer(
fpsDisplay,
];

const captureLayers = layers.filter(
(layer) =>
layer instanceof TerrainLayer ||
layer instanceof TerritoryLayer ||
layer instanceof RailroadLayer ||
layer instanceof StructureIconsLayer ||
layer instanceof UnitLayer,
);

const recapCapture = new GameRecapCapture(
game,
transformHandler,
captureLayers,
);
Comment on lines +287 to +291
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify recap capture disposal.

GameRecapCapture has a dispose() method that cleans up resources (surface, frameStore), but I don't see it being called anywhere in the GameRenderer lifecycle. Consider adding disposal logic when the renderer is destroyed to prevent memory leaks.

Run the following to check if disposal is handled elsewhere:


🏁 Script executed:

#!/bin/bash
# Search for recapCapture disposal calls
rg -n 'recapCapture.*dispose' --type ts -C3

# Check for GameRenderer cleanup/destroy methods
rg -n 'class GameRenderer' --type ts -A50 | rg -P '(dispose|destroy|cleanup|teardown)\s*\('

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Search for GameRenderer class structure and any cleanup patterns
rg -n 'class GameRenderer' --type ts -A100 | head -120

# Search for dispose method in GameRecapCapture
rg -n 'dispose\s*\(' --type ts -B5 -A10 | grep -A15 'GameRecapCapture'

# Check if recapCapture is stored as a field in GameRenderer
rg -n 'this\.recapCapture' --type ts -C2

Length of output: 12022


Add disposal cleanup to GameRenderer lifecycle.

recapCapture is stored as a private field and started in initialize() (line 328), but GameRecapCapture.dispose() is never called. This leaks the off-screen surface and frame storage. Add a cleanup method to GameRenderer that calls this.recapCapture?.dispose() and invoke it when the renderer is destroyed.

🤖 Prompt for AI Agents
In src/client/graphics/GameRenderer.ts around lines 287–291, the
GameRecapCapture instance created and stored on the renderer is started in
initialize() (around line 328) but never disposed; add a cleanup step by
implementing a GameRenderer.dispose() or destroy() method that calls
this.recapCapture?.dispose() and sets this.recapCapture = undefined/null, then
ensure that method is invoked whenever the renderer is torn down (e.g., in
existing destroy/unmount logic or where the renderer is removed) so the
off-screen surface and frames are released.


winModal.attachRecapCapture(recapCapture);

return new GameRenderer(
game,
eventBus,
Expand All @@ -282,6 +300,7 @@ export function createRenderer(
uiState,
layers,
fpsDisplay,
recapCapture,
);
}

Expand All @@ -296,6 +315,7 @@ export class GameRenderer {
public uiState: UIState,
private layers: Layer[],
private fpsDisplay: FPSDisplay,
private recapCapture?: GameRecapCapture,
) {
const context = canvas.getContext("2d");
if (context === null) throw new Error("2d context not supported");
Expand All @@ -305,6 +325,7 @@ export class GameRenderer {
initialize() {
this.eventBus.on(RedrawGraphicsEvent, () => this.redraw());
this.layers.forEach((l) => l.init?.());
this.recapCapture?.start();

document.body.appendChild(this.canvas);
window.addEventListener("resize", () => this.resizeCanvas());
Expand All @@ -327,6 +348,7 @@ export class GameRenderer {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.transformHandler.updateCanvasBoundingRect();
this.recapCapture?.onViewportResize();
//this.redraw()
}

Expand All @@ -340,6 +362,10 @@ export class GameRenderer {

renderGame() {
const start = performance.now();
if (this.recapCapture?.isCapturing()) {
requestAnimationFrame(() => this.renderGame());
return;
}
// Set background
this.context.fillStyle = this.game
.config()
Expand Down Expand Up @@ -390,6 +416,7 @@ export class GameRenderer {

tick() {
this.layers.forEach((l) => l.tick?.());
this.recapCapture?.tick();
}

resize(width: number, height: number): void {
Expand Down
Loading
Loading