Files
Guncircle.io/packages/client/src/renderer.ts
T

398 lines
13 KiB
TypeScript

// ═══════════════════════════════════════════════════════════════════════════════
// 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<string, BulletTrail>();
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;
}
}