398 lines
13 KiB
TypeScript
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;
|
|
}
|
|
}
|