From c1f56c69aae8f575e9779d00a2f400d1a9e8d2ac Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 10 May 2025 12:37:37 +0200 Subject: [PATCH] unit interpolation --- src/app/components/game/game.component.ts | 76 +++++++++++++++------ src/app/components/game/simulationEngine.ts | 35 +++++++--- src/assets/manifest.json | 2 +- 3 files changed, 85 insertions(+), 28 deletions(-) diff --git a/src/app/components/game/game.component.ts b/src/app/components/game/game.component.ts index f4e082d..15505ff 100644 --- a/src/app/components/game/game.component.ts +++ b/src/app/components/game/game.component.ts @@ -27,12 +27,12 @@ export class GameComponent implements OnInit { async ngOnInit() { const initialState: GameState = { - tick: 0, - units: [{ id: 'u1', x: 0, y: 0 }], - commandHistory: [] - }; + tick: 0, + units: [{ id: 'u1', x: 0, y: 0 }], + commandHistory: [], + }; - const json = await fetch('/assets/gameData.json').then(r => r.json()); + const json = await fetch('/assets/gameData.json').then((r) => r.json()); this.engine = new SimulationEngine(initialState, GameRules, json); requestAnimationFrame(this.gameLoop.bind(this)); } @@ -57,26 +57,64 @@ export class GameComponent implements OnInit { this.engine.fastForwardToEnd(); } - async reloadGameData() { - const json = await fetch('/assets/gameData.json').then(r => r.json()); - this.engine.reinitializeWithNewData(json); - } - - issueMove() { - const cmd: Command = { - step: this.engine.currentStep + 1, - type: 'move', - payload: { id: 'u1', dx: 1, dy: 0 } - }; - this.engine.issueCommand(cmd); - } - + async reloadGameData() { + const json = await fetch('/assets/gameData.json').then((r) => r.json()); + this.engine.reinitializeWithNewData(json); + } + + issueMove() { + const cmd: Command = { + step: this.engine.currentStep + 1, + type: 'move', + payload: { id: 'u1', dx: 1, dy: 0 }, + }; + this.engine.issueCommand(cmd); + } ngAfterViewInit(): void { this.resizeCanvas(); + requestAnimationFrame(this.render); window.addEventListener('resize', this.resizeCanvas.bind(this)); } + render = (now: number) => { + if (!this.engine) return; + + const [prevState, currState] = this.engine.getRenderStates(); + + // Compute interpolation factor (0..1) + const lastStepTime = this.engine.isRunning ? this.engine.lastStepTime : now; + const t = this.engine.isRunning + ? Math.max(Math.min((now - lastStepTime) / this.engine.interval, 1), 0) + : 1; // fully render the last known state + + this.renderFrame(prevState, currState, t); + + requestAnimationFrame(this.render); + }; + + renderFrame(prev: GameState, curr: GameState, t: number) { + const ctx = this.canvasRef.nativeElement.getContext('2d'); + if (!ctx) return; + + const canvas = this.canvasRef.nativeElement; + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (let i = 0; i < curr.units.length; i++) { + const currUnit = curr.units[i]; + const prevUnit = + prev.units.find((u) => u.id === currUnit.id) ?? currUnit; + + const x = prevUnit.x + (currUnit.x - prevUnit.x) * t; + const y = prevUnit.y + (currUnit.y - prevUnit.y) * t; + + ctx.fillStyle = 'cyan'; + ctx.beginPath(); + ctx.arc(x * 10, y * 10, 5, 0, Math.PI * 2); + ctx.fill(); + } + } + resizeCanvas(): void { const wrapper = this.wrapperRef.nativeElement; const canvas = this.canvasRef.nativeElement; diff --git a/src/app/components/game/simulationEngine.ts b/src/app/components/game/simulationEngine.ts index b9e78f3..bbe232f 100644 --- a/src/app/components/game/simulationEngine.ts +++ b/src/app/components/game/simulationEngine.ts @@ -4,23 +4,25 @@ import { GameRules } from './gameRules'; import { GameState } from './gameState'; export class SimulationEngine { - private interval = 50; // ms per step - private lastTime = 0; + interval: number = 50; // ms per step + lastTime = 0; private frameRequestId: number | null = null; private data: GameData; + private previousState: GameState | null = null; currentStep = 0; initialState: GameState; state: GameState; isRunning = false; + lastStepTime: number = 0; constructor( initialState: GameState, private rules: typeof GameRules, initialData: GameData ) { - this.initialState = JSON.parse(JSON.stringify(initialState)); - this.state = JSON.parse(JSON.stringify(initialState)); + this.initialState = initialState; + this.state = this.cloneState(this.initialState); this.data = initialData; } @@ -40,7 +42,9 @@ export class SimulationEngine { } fastForwardToEnd() { - this.fastForwardTo(this.state.commandHistory[this.state.commandHistory.length - 1].step); + this.fastForwardTo( + this.state.commandHistory[this.state.commandHistory.length - 1].step + ); } private fastForwardTo(target: number) { @@ -51,8 +55,10 @@ export class SimulationEngine { } step() { - this.state = this.rules.step(this.state, this.data); - this.currentStep++; + this.previousState = this.cloneState(this.state); + this.rules.step(this.state, this.data); + this.currentStep = this.state.tick; + this.lastStepTime = performance.now(); } start() { @@ -72,7 +78,8 @@ export class SimulationEngine { rewindToZero() { this.stop(); - this.state = JSON.parse(JSON.stringify(this.initialState)); + this.state = this.cloneState(this.initialState); + this.previousState = this.cloneState(this.state); this.currentStep = 0; } @@ -84,6 +91,18 @@ export class SimulationEngine { this.state.commandHistory.push(command); } + getRenderStates(): [GameState, GameState] { + return [this.previousState ?? this.state, this.state]; + } + + cloneState(state: GameState): GameState { + return { + tick: state.tick, + commandHistory: [...state.commandHistory], + units: state.units.map((u) => ({ ...u })), + }; + } + private loop = () => { const now = performance.now(); while (now - this.lastTime >= this.interval) { diff --git a/src/assets/manifest.json b/src/assets/manifest.json index bf7f4c8..1a304f9 100644 --- a/src/assets/manifest.json +++ b/src/assets/manifest.json @@ -437,6 +437,6 @@ { "path": "manifest.json", "type": "other", - "size": 7721 + "size": 7722 } ] \ No newline at end of file