herd / commit
Add Canvas wrapper and fixed-timestep game loop
src/game/Canvas.ts | 74 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/game/GameLoop.ts | 43 ++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/game/Canvas.ts b/src/game/Canvas.ts new file mode 100644 index 0000000..43e9d07 --- /dev/null +++ b/src/game/Canvas.ts @@ -0,0 +1,74 @@ +const GRASS_STEP = 40 + +export class Canvas { + readonly el: HTMLCanvasElement + readonly ctx: CanvasRenderingContext2D + private _width = 0 + private _height = 0 + private _dpr = 1 + + // offscreen canvas for the static grass texture — regenerated on resize + private grassCanvas: HTMLCanvasElement | null = null + + constructor(id: string) { + const el = document.getElementById(id) + if (!(el instanceof HTMLCanvasElement)) throw new Error(`#${id} is not a canvas`) + this.el = el + this.ctx = el.getContext('2d')! + this.resize() + window.addEventListener('resize', () => this.resize()) + } + + get width() { return this._width } + get height() { return this._height } + get dpr() { return this._dpr } + + private resize() { + this._dpr = window.devicePixelRatio || 1 + this._width = window.innerWidth + this._height = window.innerHeight + this.el.width = this._width * this._dpr + this.el.height = this._height * this._dpr + this.el.style.width = `${this._width}px` + this.el.style.height = `${this._height}px` + this.ctx.setTransform(this._dpr, 0, 0, this._dpr, 0, 0) + this.buildGrassTexture() + } + + private buildGrassTexture() { + const oc = document.createElement('canvas') + oc.width = this._width + oc.height = this._height + const ctx = oc.getContext('2d')! + + ctx.strokeStyle = 'rgba(60,110,30,0.3)' + ctx.lineWidth = 1 + for (let x = 0; x < this._width; x += GRASS_STEP) { + for (let y = 0; y < this._height; y += GRASS_STEP) { + // two blades per cell, randomized once at build time + for (let b = 0; b < 2; b++) { + const bx = x + Math.random() * GRASS_STEP + const by = y + Math.random() * GRASS_STEP + ctx.beginPath() + ctx.moveTo(bx, by) + ctx.lineTo(bx + (Math.random() - 0.5) * 6, by - 4 - Math.random() * 5) + ctx.stroke() + } + } + } + this.grassCanvas = oc + } + + drawField() { + const { ctx } = this + ctx.fillStyle = '#4a8c2a' + ctx.fillRect(0, 0, this._width, this._height) + if (this.grassCanvas) { + ctx.drawImage(this.grassCanvas, 0, 0) + } + } + + clear() { + this.ctx.clearRect(0, 0, this._width, this._height) + } +} diff --git a/src/game/GameLoop.ts b/src/game/GameLoop.ts new file mode 100644 index 0000000..b3976d0 --- /dev/null +++ b/src/game/GameLoop.ts @@ -0,0 +1,43 @@ +const FIXED_STEP_MS = 16 // ~60 Hz physics + +export class GameLoop { + private rafId = 0 + private lastTime = 0 + private accumulator = 0 + private running = false + private onUpdate: (dt: number) => void + private onRender: (alpha: number) => void + + constructor(onUpdate: (dt: number) => void, onRender: (alpha: number) => void) { + this.onUpdate = onUpdate + this.onRender = onRender + } + + start() { + if (this.running) return + this.running = true + this.lastTime = performance.now() + this.rafId = requestAnimationFrame(this.tick) + } + + stop() { + this.running = false + cancelAnimationFrame(this.rafId) + } + + private tick = (now: number) => { + if (!this.running) return + this.rafId = requestAnimationFrame(this.tick) + + const elapsed = Math.min(now - this.lastTime, 200) // cap at 200ms to avoid spiral of death + this.lastTime = now + this.accumulator += elapsed + + while (this.accumulator >= FIXED_STEP_MS) { + this.onUpdate(FIXED_STEP_MS / 1000) // pass seconds + this.accumulator -= FIXED_STEP_MS + } + + this.onRender(this.accumulator / FIXED_STEP_MS) + } +}