// ═══════════════════════════════════════════════════════════════════════════════ // GunCircle.io — Canvas 2D Rendering Engine // ═══════════════════════════════════════════════════════════════════════════════ import { COLORS, ARENA_WIDTH, ARENA_HEIGHT, GUN_BARREL_WIDTH, GUN_BARREL_LENGTH, GUN_BODY_SIZE, lerp, } from '@guncircle/shared'; import type { RoomState, Player, Bullet, XPOrb, Obstacle } from '@guncircle/shared'; import type { Camera } from './camera.js'; // ─── Grid Constants ────────────────────────────────────────────────────────── const GRID_SIZE = 100; const XP_ORB_SIZE = 6; const XP_ORB_PULSE_SPEED = 3; const XP_ORB_PULSE_AMP = 1.5; const TRAIL_COUNT = 3; const NAME_TAG_OFFSET = 28; const HP_BAR_HEIGHT = 4; const HP_BAR_OFFSET = NAME_TAG_OFFSET + 14; const STAT_TEXT_SIZE = 12; /** Bullet trail history entry */ interface BulletTrail { positions: Array<{ x: number; y: number }>; } export class Renderer { private readonly ctx: CanvasRenderingContext2D; private readonly canvas: HTMLCanvasElement; /** Bullet trail history: bulletId → ring buffer of positions */ private bulletTrails = new Map(); private readonly maxTrailLen = 5; /** Time accumulator for animations */ private animTime = 0; constructor(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) { this.ctx = ctx; this.canvas = canvas; } // ─── Main Render ─────────────────────────────────────────────────────────── /** * Main render entry point. * @param state - Current Colyseus room state * @param camera - Camera system * @param localPlayerId - Session ID of the local player * @param dt - Delta time in seconds */ render( state: RoomState, camera: Camera, localPlayerId: string | null, dt: number ): void { this.animTime += dt; const ctx = this.ctx; const w = this.canvas.width; const h = this.canvas.height; // Clear canvas ctx.clearRect(0, 0, w, h); // Apply camera transform ctx.save(); ctx.translate(w / 2, h / 2); ctx.translate(-camera.x, -camera.y); // Render world this.renderArenaBackground(camera); this.renderObstacles(state, camera); this.renderXPOrbs(state, camera); this.renderBullets(state, camera, localPlayerId, dt); this.renderPlayers(state, camera, localPlayerId, dt); ctx.restore(); } // ─── Arena Background ────────────────────────────────────────────────────── private renderArenaBackground(camera: Camera): void { const ctx = this.ctx; // Solid background fill for visible area const startX = Math.max(0, camera.x - camera.width / 2); const startY = Math.max(0, camera.y - camera.height / 2); const endX = Math.min(ARENA_WIDTH, camera.x + camera.width / 2); const endY = Math.min(ARENA_HEIGHT, camera.y + camera.height / 2); ctx.fillStyle = COLORS.arenaBg; ctx.fillRect(startX, startY, endX - startX, endY - startY); // Grid lines ctx.strokeStyle = COLORS.arenaGrid; ctx.lineWidth = 1; ctx.beginPath(); // Vertical lines const firstGridX = Math.floor(startX / GRID_SIZE) * GRID_SIZE; for (let x = firstGridX; x <= endX; x += GRID_SIZE) { ctx.moveTo(x, startY); ctx.lineTo(x, endY); } // Horizontal lines const firstGridY = Math.floor(startY / GRID_SIZE) * GRID_SIZE; for (let y = firstGridY; y <= endY; y += GRID_SIZE) { ctx.moveTo(startX, y); ctx.lineTo(endX, y); } ctx.stroke(); // Arena border ctx.strokeStyle = '#34495e'; ctx.lineWidth = 3; ctx.strokeRect(0, 0, ARENA_WIDTH, ARENA_HEIGHT); } // ─── Obstacles ───────────────────────────────────────────────────────────── private renderObstacles(state: RoomState, camera: Camera): void { for (const obs of state.obstacles.values()) { if (!camera.isInView(obs.x + obs.width / 2, obs.y + obs.height / 2, Math.max(obs.width, obs.height))) { continue; } this.renderObstacle(obs); } } private renderObstacle(obs: Obstacle): void { const ctx = this.ctx; switch (obs.type) { case 0: { // Wall ctx.fillStyle = COLORS.wall; ctx.fillRect(obs.x, obs.y, obs.width, obs.height); ctx.strokeStyle = '#6c7a7d'; ctx.lineWidth = 1; ctx.strokeRect(obs.x, obs.y, obs.width, obs.height); break; } case 1: { // Crate ctx.fillStyle = COLORS.crate; ctx.fillRect(obs.x, obs.y, obs.width, obs.height); ctx.strokeStyle = '#6d3a10'; ctx.lineWidth = 1; ctx.strokeRect(obs.x, obs.y, obs.width, obs.height); // Cross pattern ctx.strokeStyle = '#6d3a10'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(obs.x, obs.y); ctx.lineTo(obs.x + obs.width, obs.y + obs.height); ctx.moveTo(obs.x + obs.width, obs.y); ctx.lineTo(obs.x, obs.y + obs.height); ctx.stroke(); break; } case 2: { // Slow zone ctx.fillStyle = 'rgba(52, 152, 219, 0.3)'; ctx.fillRect(obs.x, obs.y, obs.width, obs.height); // Subtle wave pattern ctx.strokeStyle = 'rgba(52, 152, 219, 0.2)'; ctx.lineWidth = 1; for (let i = 0; i < 3; i++) { const y = obs.y + obs.height * (0.25 + i * 0.25); ctx.beginPath(); ctx.moveTo(obs.x, y); for (let x = 0; x < obs.width; x += 5) { ctx.lineTo( obs.x + x, y + Math.sin(x * 0.05 + this.animTime * 2) * 3 ); } ctx.stroke(); } break; } case 3: { // Cover ctx.fillStyle = 'rgba(39, 174, 96, 0.5)'; ctx.fillRect(obs.x, obs.y, obs.width, obs.height); ctx.strokeStyle = 'rgba(39, 174, 96, 0.7)'; ctx.lineWidth = 2; ctx.strokeRect(obs.x, obs.y, obs.width, obs.height); break; } } } // ─── XP Orbs ─────────────────────────────────────────────────────────────── private renderXPOrbs(state: RoomState, camera: Camera): void { for (const orb of state.xpOrbs.values()) { if (!camera.isInView(orb.x, orb.y, XP_ORB_SIZE * 2)) { continue; } this.renderXPOrb(orb); } } private renderXPOrb(orb: XPOrb): void { const ctx = this.ctx; const pulse = Math.sin(this.animTime * XP_ORB_PULSE_SPEED + orb.x * 0.1) * XP_ORB_PULSE_AMP; const size = XP_ORB_SIZE + pulse; ctx.fillStyle = COLORS.xpOrb; ctx.shadowColor = COLORS.xpOrb; ctx.shadowBlur = 6; ctx.fillRect(orb.x - size / 2, orb.y - size / 2, size, size); ctx.shadowBlur = 0; } // ─── Bullets ─────────────────────────────────────────────────────────────── private renderBullets(state: RoomState, camera: Camera, localPlayerId: string | null, dt: number): void { // Update trail history for (const [id, bullet] of state.bullets) { let trail = this.bulletTrails.get(id); if (!trail) { trail = { positions: [] }; this.bulletTrails.set(id, trail); } // Add current position to trail trail.positions.unshift({ x: bullet.x, y: bullet.y }); if (trail.positions.length > this.maxTrailLen) { trail.positions.pop(); } } // Clean up trails for removed bullets const activeIds = new Set(state.bullets.keys()); for (const id of this.bulletTrails.keys()) { if (!activeIds.has(id)) { this.bulletTrails.delete(id); } } // Render bullets for (const [bulletId, bullet] of state.bullets) { if (!camera.isInView(bullet.x, bullet.y, bullet.size * 3)) { continue; } const isOwn = bullet.ownerId === localPlayerId; this.renderBullet(bullet, bulletId, isOwn); } } private renderBullet(bullet: Bullet, bulletId: string, isOwn: boolean): void { const ctx = this.ctx; const trail = this.bulletTrails.get(bulletId); const bulletColor = isOwn ? COLORS.self : COLORS.enemy; // Render trail if (trail && trail.positions.length > 1) { for (let i = 1; i < Math.min(trail.positions.length, TRAIL_COUNT + 1); i++) { const pos = trail.positions[i]; const t = i / (TRAIL_COUNT + 1); const alpha = 0.3 * (1 - t); const r = bullet.size * (1 - t * 0.5); ctx.globalAlpha = alpha; ctx.fillStyle = bulletColor; ctx.beginPath(); ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2); ctx.fill(); } ctx.globalAlpha = 1; } // Main bullet circle ctx.fillStyle = bulletColor; ctx.shadowColor = bulletColor; ctx.shadowBlur = 4; ctx.beginPath(); ctx.arc(bullet.x, bullet.y, bullet.size, 0, Math.PI * 2); ctx.fill(); ctx.shadowBlur = 0; // Crit outline if (bullet.isCritical) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(bullet.x, bullet.y, bullet.size + 2, 0, Math.PI * 2); ctx.stroke(); } } // ─── Players ───────────────────────────────────────────────────────────────── private renderPlayers( state: RoomState, camera: Camera, localPlayerId: string | null, _dt: number ): void { for (const [playerId, player] of state.players) { if (!player.alive) continue; if (!camera.isInView(player.x, player.y, player.radius + 30)) { continue; } const isSelf = playerId === localPlayerId; this.renderPlayer(player, isSelf); } } private renderPlayer(player: Player, isSelf: boolean): void { const ctx = this.ctx; const x = player.x; const y = player.y; const r = player.radius; // ─── Gun rendering (behind player circle) ─── ctx.save(); ctx.translate(x, y); ctx.rotate(player.angle + player.recoilOffset); // Barrel ctx.fillStyle = '#34495e'; ctx.fillRect( 0, -GUN_BARREL_WIDTH / 2, GUN_BARREL_LENGTH, GUN_BARREL_WIDTH ); // Gun body (square behind barrel) ctx.fillStyle = '#2c3e50'; ctx.fillRect( -GUN_BODY_SIZE / 2, -GUN_BODY_SIZE / 2, GUN_BODY_SIZE, GUN_BODY_SIZE ); ctx.restore(); // ─── Player circle ─── ctx.fillStyle = isSelf ? COLORS.self : COLORS.enemy; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill(); // Darker border stroke ctx.strokeStyle = isSelf ? '#2980b9' : '#c0392b'; ctx.lineWidth = 2; ctx.stroke(); // ─── Name tag ─── if (player.name) { ctx.fillStyle = COLORS.nameTag; ctx.font = `${STAT_TEXT_SIZE}px sans-serif`; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3; ctx.fillText(player.name, x, y - NAME_TAG_OFFSET); ctx.shadowBlur = 0; } // ─── HP bar ─── const hpBarWidth = r * 2; const hpBarX = x - hpBarWidth / 2; const hpBarY = y - HP_BAR_OFFSET; const hpPct = player.hp / player.maxHp; // Background ctx.fillStyle = COLORS.hpBarBg; ctx.fillRect(hpBarX, hpBarY, hpBarWidth, HP_BAR_HEIGHT); // Fill const hpFillWidth = hpBarWidth * hpPct; ctx.fillStyle = COLORS.hpBar; ctx.fillRect(hpBarX, hpBarY, hpFillWidth, HP_BAR_HEIGHT); // Border ctx.strokeStyle = '#1a252f'; ctx.lineWidth = 1; ctx.strokeRect(hpBarX, hpBarY, hpBarWidth, HP_BAR_HEIGHT); } // ─── Resize ──────────────────────────────────────────────────────────────── resize(width: number, height: number): void { this.canvas.width = width; this.canvas.height = height; } }