-
Notifications
You must be signed in to change notification settings - Fork 645
feature: game recap #2230
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feature: game recap #2230
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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} | ||
| </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 }), | ||
| ); | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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, | ||
| ); | ||
|
|
||
NewYearNewPhil marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| const recapCapture = new GameRecapCapture( | ||
| game, | ||
| transformHandler, | ||
| captureLayers, | ||
| ); | ||
|
Comment on lines
+287
to
+291
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chainVerify recap capture disposal.
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 -C2Length of output: 12022 Add disposal cleanup to GameRenderer lifecycle.
🤖 Prompt for AI Agents |
||
|
|
||
| winModal.attachRecapCapture(recapCapture); | ||
|
|
||
| return new GameRenderer( | ||
| game, | ||
| eventBus, | ||
|
|
@@ -282,6 +300,7 @@ export function createRenderer( | |
| uiState, | ||
| layers, | ||
| fpsDisplay, | ||
| recapCapture, | ||
| ); | ||
| } | ||
|
|
||
|
|
@@ -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"); | ||
|
|
@@ -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()); | ||
|
|
@@ -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() | ||
| } | ||
|
|
||
|
|
@@ -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() | ||
|
|
@@ -390,6 +416,7 @@ export class GameRenderer { | |
|
|
||
| tick() { | ||
| this.layers.forEach((l) => l.tick?.()); | ||
| this.recapCapture?.tick(); | ||
| } | ||
|
|
||
| resize(width: number, height: number): void { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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:
Also add "recap.warming": "Recap capture warming up" to en.json.
📝 Committable suggestion
🤖 Prompt for AI Agents