import { GRID_SIZE, type GameState, type PeerState, type WorldItem } from '../state/gameState'; export class CanvasRenderer { private readonly ctx: CanvasRenderingContext2D; private squarePixelSize: number; private gridSize: number; constructor(private readonly canvas: HTMLCanvasElement) { const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Unable to create 2D context'); } this.ctx = ctx; this.gridSize = GRID_SIZE; this.squarePixelSize = canvas.width / this.gridSize; } setGridSize(gridSize: number): void { if (!Number.isInteger(gridSize) || gridSize <= 0) return; this.gridSize = gridSize; this.squarePixelSize = this.canvas.width / this.gridSize; } draw(state: GameState): void { const { ctx } = this; ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); ctx.strokeStyle = '#374151'; for (let i = 0; i <= this.gridSize; i += 1) { ctx.beginPath(); ctx.moveTo(i * this.squarePixelSize, 0); ctx.lineTo(i * this.squarePixelSize, this.canvas.height); ctx.moveTo(0, i * this.squarePixelSize); ctx.lineTo(this.canvas.width, i * this.squarePixelSize); ctx.stroke(); } for (const peer of state.peers.values()) { this.drawObject(peer, '#f87171', peer.nickname); } for (const item of state.items.values()) { if (item.carrierId) continue; this.drawItem(item); } this.drawObject(state.player, '#34d399', state.player.nickname); if (state.mode === 'nickname' || state.mode === 'chat' || state.mode === 'itemPropertyEdit') { const label = state.mode === 'nickname' ? 'New Nickname' : state.mode === 'chat' ? 'Message' : 'Property Value'; this.drawTextOverlay(state, label); } } private drawObject(obj: Pick, color: string, name: string): void { const drawX = obj.x * this.squarePixelSize; const drawY = this.canvas.height - (obj.y * this.squarePixelSize) - this.squarePixelSize; this.ctx.fillStyle = color; this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize); this.ctx.fillStyle = 'white'; this.ctx.font = '12px Courier New'; this.ctx.textAlign = 'center'; this.ctx.fillText(name, drawX + this.squarePixelSize / 2, drawY - 5); } private drawTextOverlay(state: GameState, label: string): void { const { ctx } = this; ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(0, this.canvas.height / 2 - 30, this.canvas.width, 60); ctx.fillStyle = 'white'; ctx.font = '24px Courier New'; ctx.textAlign = 'center'; const text = `${label}: ${state.nicknameInput}`; const textMetrics = ctx.measureText(text); const preCursorText = `${label}: ${state.nicknameInput.substring(0, state.cursorPos)}`; const preCursorWidth = ctx.measureText(preCursorText).width; const textX = this.canvas.width / 2; ctx.fillText(text, textX, this.canvas.height / 2); if (state.cursorVisible) { ctx.fillRect(textX - textMetrics.width / 2 + preCursorWidth, this.canvas.height / 2 - 20, 2, 24); } } private drawItem(item: WorldItem): void { const drawX = item.x * this.squarePixelSize; const drawY = this.canvas.height - (item.y * this.squarePixelSize) - this.squarePixelSize; this.ctx.fillStyle = item.type === 'radio_station' ? '#fbbf24' : item.type === 'wheel' ? '#f97316' : item.type === 'piano' ? '#c4b5fd' : item.type === 'clock' ? '#86efac' : item.type === 'widget' ? '#22d3ee' : '#60a5fa'; this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize); this.ctx.fillStyle = '#111827'; this.ctx.font = 'bold 12px Courier New'; this.ctx.textAlign = 'center'; this.ctx.fillText( item.type === 'radio_station' ? 'R' : item.type === 'wheel' ? 'W' : item.type === 'piano' ? 'P' : item.type === 'clock' ? 'C' : item.type === 'widget' ? 'B' : 'D', drawX + this.squarePixelSize / 2, drawY + 13, ); } }