Initial commit: GunCircle.io multiplayer arena shooter

This commit is contained in:
emil
2026-05-11 15:39:56 +03:00
commit 836441fc2c
45 changed files with 9391 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/client/package.json packages/client/
RUN pnpm install --frozen-lockfile
COPY packages/shared/ packages/shared/
COPY packages/client/ packages/client/
RUN pnpm --filter @guncircle/shared build
RUN pnpm --filter @guncircle/client build
FROM nginx:alpine
COPY --from=builder /app/packages/client/dist /usr/share/nginx/html
COPY packages/client/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+31
View File
@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GunCircle.io</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #1a1a2e; }
canvas { display: block; }
#ui { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
#ui > * { pointer-events: auto; }
#menu { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(26,26,46,0.95); z-index: 100; }
#hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; display: none; z-index: 10; }
.hidden { display: none !important; }
</style>
</head>
<body>
<canvas id="game" tabindex="0" style="outline:none;"></canvas>
<div id="ui">
<div id="menu">
<h1 style="color:#ecf0f1;font-family:sans-serif;font-size:48px;margin-bottom:10px;text-shadow:0 0 20px rgba(52,152,219,0.5);">GunCircle.io</h1>
<p style="color:#95a5a6;font-family:sans-serif;margin-bottom:30px;">Top-down arena shooter. WASD to move. Mouse to aim. Click to shoot.</p>
<input id="nameInput" type="text" placeholder="Enter name (max 16)" maxlength="16" style="padding:12px 20px;font-size:18px;border:2px solid #34495e;border-radius:8px;background:#16213e;color:#ecf0f1;outline:none;width:280px;text-align:center;margin-bottom:16px;pointer-events:auto;" />
<button id="playBtn" style="padding:12px 40px;font-size:20px;border:none;border-radius:8px;background:#3498db;color:#fff;cursor:pointer;font-weight:bold;transition:background 0.2s;pointer-events:auto;">PLAY</button>
</div>
<div id="hud"></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+18
View File
@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /ws {
proxy_pass http://server:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@guncircle/client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@colyseus/schema": "^3.0.0",
"colyseus.js": "^0.16.0",
"@guncircle/shared": "workspace:*"
},
"devDependencies": {
"typescript": "^5.6.0",
"vite": "^5.0.0"
}
}
+89
View File
@@ -0,0 +1,89 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Camera System
// ═══════════════════════════════════════════════════════════════════════════════
import {
ARENA_WIDTH,
ARENA_HEIGHT,
lerp,
} from '@guncircle/shared';
/** Camera padding — keep this much arena visible beyond camera edge */
const CAMERA_PADDING = 100;
/** Camera follow lerp rate (units per second toward target) */
const CAMERA_FOLLOW_RATE = 8;
export class Camera {
/** Camera center X in world space */
x = ARENA_WIDTH / 2;
/** Camera center Y in world space */
y = ARENA_HEIGHT / 2;
/** Viewport width in pixels */
width = 0;
/** Viewport height in pixels */
height = 0;
constructor(width: number, height: number) {
this.width = width;
this.height = height;
}
/** Resize the camera viewport */
resize(width: number, height: number): void {
this.width = width;
this.height = height;
this.clampToArena();
}
/**
* Smoothly follow a target position.
* @param target - Target world position {x, y}
* @param dt - Delta time in seconds
*/
follow(target: { x: number; y: number }, dt: number): void {
const rate = Math.min(CAMERA_FOLLOW_RATE * dt, 1);
this.x = lerp(this.x, target.x, rate);
this.y = lerp(this.y, target.y, rate);
this.clampToArena();
}
/** Clamp camera center so viewport stays within arena bounds + padding */
clampToArena(): void {
const halfW = this.width / 2;
const halfH = this.height / 2;
const minX = halfH - CAMERA_PADDING;
const maxX = ARENA_WIDTH - halfW + CAMERA_PADDING;
const minY = halfH - CAMERA_PADDING;
const maxY = ARENA_HEIGHT - halfH + CAMERA_PADDING;
this.x = Math.max(minX, Math.min(maxX, this.x));
this.y = Math.max(minY, Math.min(maxY, this.y));
}
/** Convert world coordinates to screen coordinates */
worldToScreen(worldX: number, worldY: number): { sx: number; sy: number } {
return {
sx: worldX - this.x + this.width / 2,
sy: worldY - this.y + this.height / 2,
};
}
/** Convert screen coordinates to world coordinates */
screenToWorld(screenX: number, screenY: number): { wx: number; wy: number } {
return {
wx: screenX + this.x - this.width / 2,
wy: screenY + this.y - this.height / 2,
};
}
/** Check if an entity with given radius is within the camera viewport */
isInView(worldX: number, worldY: number, radius: number): boolean {
const halfW = this.width / 2 + radius;
const halfH = this.height / 2 + radius;
const dx = worldX - this.x;
const dy = worldY - this.y;
return Math.abs(dx) <= halfW && Math.abs(dy) <= halfH;
}
}
+250
View File
@@ -0,0 +1,250 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Main Game Controller
// ═══════════════════════════════════════════════════════════════════════════════
import {
RoomState,
ARENA_WIDTH,
ARENA_HEIGHT,
PLAYER_BASE_SPEED,
lerp,
} from '@guncircle/shared';
import type { PlayerInput } from '@guncircle/shared';
import { Camera } from './camera.js';
import { InputHandler } from './input.js';
import { Renderer } from './renderer.js';
import { NetworkManager } from './network.js';
import { HUD } from './hud.js';
/** Maximum delta time to prevent spiral of death */
const MAX_DT = 0.1;
/** Camera follow lerp factor per second */
const CAMERA_LERP_SPEED = 8;
/** Local player visual recoil recovery rate */
const RECOIL_RECOVERY_RATE = 0.15;
/** Game state container */
interface GameState {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
camera: Camera;
input: InputHandler;
renderer: Renderer;
network: NetworkManager;
hud: HUD;
localPlayerId: string | null;
lastTime: number;
running: boolean;
/** Visual recoil offset for local player (purely visual) */
localRecoilOffset: number;
/** Animation time accumulator */
animTime: number;
}
/** Active game state instance */
let game: GameState | null = null;
/** RAF handle for cleanup */
let rafHandle = 0;
// ─── Initialization ──────────────────────────────────────────────────────────
/**
* Start the game with the given player name.
* This is the main entry point called from main.ts.
*/
export async function startGame(playerName: string): Promise<void> {
// Clean up any existing game
if (game) {
stopGame();
}
// Get canvas
const canvas = document.getElementById('game') as HTMLCanvasElement | null;
if (!canvas) {
throw new Error('Game canvas element not found');
}
// Size canvas to window
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
// Get 2D context with alpha disabled for performance
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) {
throw new Error('Could not get 2D canvas context');
}
// Initialize subsystems
const camera = new Camera(canvas.width, canvas.height);
const input = new InputHandler(canvas);
const renderer = new Renderer(ctx, canvas);
const network = new NetworkManager();
const hud = new HUD();
// Create game state
game = {
canvas,
ctx,
camera,
input,
renderer,
network,
hud,
localPlayerId: null,
lastTime: performance.now(),
running: true,
localRecoilOffset: 0,
animTime: 0,
};
// Handle window resize
const handleResize = (): void => {
if (!game) return;
const w = window.innerWidth;
const h = window.innerHeight;
game.canvas.width = w;
game.canvas.height = h;
game.camera.resize(w, h);
game.renderer.resize(w, h);
};
window.addEventListener('resize', handleResize);
// Store resize handler on game for cleanup
(game as unknown as Record<string, unknown>)['_resizeHandler'] = handleResize;
// Setup HUD upgrade callback
hud.onUpgrade((statIdx: number) => {
if (game?.network) {
game.network.sendUpgrade(statIdx);
}
});
// Connect to server
await network.connect('arena', { name: playerName });
game.localPlayerId = network.localPlayerId;
hud.show();
// Setup disconnect handling
network.onDisconnect(() => {
if (game) {
game.running = false;
hud.hide();
}
});
// Start sending input at 60Hz
input.startSending((inputData: PlayerInput) => {
if (game?.network) {
game.network.sendInput(inputData);
}
});
// Start the game loop
game.lastTime = performance.now();
gameLoop(performance.now());
}
// ─── Game Loop ───────────────────────────────────────────────────────────────
function gameLoop(now: number): void {
if (!game || !game.running) return;
// Calculate delta time
let dt = (now - game.lastTime) / 1000;
game.lastTime = now;
// Cap dt to prevent spiral of death
if (dt > MAX_DT) {
dt = MAX_DT;
}
game.animTime += dt;
// Get server state
const roomState = game.network.state;
if (roomState && game.localPlayerId) {
// ── Process input & client-side prediction ──
const inputState = game.input.getInput();
game.network.applyPrediction(inputState, dt);
// ── Update camera to follow local player ──
updateCamera(game, roomState, dt);
// ── Update local visual recoil ──
updateRecoil(game, dt);
// ── Update HUD ──
game.hud.update(roomState, game.localPlayerId);
}
// ── Render frame ──
if (roomState) {
game.renderer.render(
roomState,
game.camera,
game.localPlayerId,
dt
);
}
// Queue next frame
rafHandle = requestAnimationFrame(gameLoop);
}
// ─── Camera Update ───────────────────────────────────────────────────────────
function updateCamera(game: GameState, roomState: RoomState, dt: number): void {
if (!game.localPlayerId) return;
const localPlayer = roomState.players.get(game.localPlayerId);
if (!localPlayer || !localPlayer.alive) return;
// Use predicted position for camera if available, else server position
const predPos = game.network.getLocalPosition();
const targetX = predPos ? predPos.x : localPlayer.x;
const targetY = predPos ? predPos.y : localPlayer.y;
// Lerp camera toward target
const rate = Math.min(CAMERA_LERP_SPEED * dt, 1);
game.camera.x = lerp(game.camera.x, targetX, rate);
game.camera.y = lerp(game.camera.y, targetY, rate);
game.camera.clampToArena();
}
// ─── Recoil Update ───────────────────────────────────────────────────────────
function updateRecoil(game: GameState, _dt: number): void {
// Lerp visual recoil offset toward 0 each frame
game.localRecoilOffset = lerp(
game.localRecoilOffset,
0,
RECOIL_RECOVERY_RATE
);
}
// ─── Cleanup ─────────────────────────────────────────────────────────────────
export function stopGame(): void {
if (!game) return;
game.running = false;
cancelAnimationFrame(rafHandle);
game.input.destroy();
game.network.disconnect();
game.hud.destroy();
// Remove resize listener
const resizeHandler = (game as unknown as Record<string, unknown>)['_resizeHandler'] as
| (() => void)
| undefined;
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
}
game = null;
}
+374
View File
@@ -0,0 +1,374 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — HUD Renderer (HTML Overlays)
// ═══════════════════════════════════════════════════════════════════════════════
import {
COLORS,
STAT_COUNT,
STAT_BONUSES,
Stat,
BRANCH_LEVELS,
} from '@guncircle/shared';
import type { RoomState, Player } from '@guncircle/shared';
/** Stat display names */
const STAT_NAMES: string[] = [
'Max HP',
'HP Regen',
'Move Speed',
'Bullet Dmg',
'Bullet Spd',
'Reload Spd',
'Recoil Stab',
'Crit Chance',
'Crit Dmg',
];
/** HUD bar dimensions */
const BAR_WIDTH_HP = 200;
const BAR_HEIGHT_HP = 16;
const BAR_WIDTH_XP = 200;
const BAR_HEIGHT_XP = 10;
export class HUD {
private readonly hudEl: HTMLDivElement;
private visible = false;
// ─── HUD Element References ────────────────────────────────────────────────
private hpBarEl: HTMLDivElement | null = null;
private hpBarFillEl: HTMLDivElement | null = null;
private hpTextEl: HTMLDivElement | null = null;
private xpBarEl: HTMLDivElement | null = null;
private xpBarFillEl: HTMLDivElement | null = null;
private xpTextEl: HTMLDivElement | null = null;
private ammoEl: HTMLDivElement | null = null;
private leaderboardEl: HTMLDivElement | null = null;
private upgradePopupEl: HTMLDivElement | null = null;
private upgradeGridEl: HTMLDivElement | null = null;
/** Callback when a stat upgrade is chosen */
private upgradeCallback: ((stat: number) => void) | null = null;
constructor() {
const hudEl = document.getElementById('hud') as HTMLDivElement | null;
if (!hudEl) {
throw new Error('HUD: #hud element not found');
}
this.hudEl = hudEl;
this.buildUI();
}
// ─── UI Construction ───────────────────────────────────────────────────────
private buildUI(): void {
this.hudEl.style.position = 'absolute';
this.hudEl.style.top = '0';
this.hudEl.style.left = '0';
this.hudEl.style.width = '100%';
this.hudEl.style.height = '100%';
this.hudEl.style.pointerEvents = 'none';
this.hudEl.style.fontFamily = 'sans-serif';
// ── HP Bar (top-left) ──
const hpContainer = this.createBarContainer('10px', '10px');
this.hpTextEl = this.createTextEl('HP: --/--', '#ecf0f1', '12px');
this.hpBarEl = this.createBar(BAR_WIDTH_HP, BAR_HEIGHT_HP, COLORS.hpBarBg);
this.hpBarFillEl = this.createBarFill(BAR_WIDTH_HP, BAR_HEIGHT_HP, COLORS.hpBar);
this.hpBarEl.appendChild(this.hpBarFillEl);
hpContainer.appendChild(this.hpTextEl);
hpContainer.appendChild(this.hpBarEl);
this.hudEl.appendChild(hpContainer);
// ── XP Bar (below HP) ──
const xpContainer = this.createBarContainer('10px', '36px');
this.xpTextEl = this.createTextEl('Level 1', '#ecf0f1', '12px');
this.xpBarEl = this.createBar(BAR_WIDTH_XP, BAR_HEIGHT_XP, COLORS.hpBarBg);
this.xpBarFillEl = this.createBarFill(BAR_WIDTH_XP, BAR_HEIGHT_XP, '#f1c40f');
this.xpBarEl.appendChild(this.xpBarFillEl);
xpContainer.appendChild(this.xpTextEl);
xpContainer.appendChild(this.xpBarEl);
this.hudEl.appendChild(xpContainer);
// ── Ammo (bottom-right) ──
const ammoContainer = this.createBarContainer('auto', 'auto');
ammoContainer.style.right = '10px';
ammoContainer.style.bottom = '10px';
this.ammoEl = this.createTextEl('Ammo: --/--', '#ecf0f1', '16px');
this.ammoEl.style.fontWeight = 'bold';
ammoContainer.appendChild(this.ammoEl);
this.hudEl.appendChild(ammoContainer);
// ── Leaderboard (top-right) ──
const lbContainer = this.createBarContainer('auto', '10px');
lbContainer.style.right = '10px';
this.leaderboardEl = document.createElement('div');
this.leaderboardEl.style.background = COLORS.uiBg;
this.leaderboardEl.style.border = `1px solid ${COLORS.uiBorder}`;
this.leaderboardEl.style.borderRadius = '6px';
this.leaderboardEl.style.padding = '8px 12px';
this.leaderboardEl.style.minWidth = '200px';
this.leaderboardEl.style.pointerEvents = 'auto';
lbContainer.appendChild(this.leaderboardEl);
this.hudEl.appendChild(lbContainer);
// ── Upgrade Popup (centered, hidden by default) ──
this.upgradePopupEl = document.createElement('div');
this.upgradePopupEl.style.position = 'absolute';
this.upgradePopupEl.style.top = '50%';
this.upgradePopupEl.style.left = '50%';
this.upgradePopupEl.style.transform = 'translate(-50%, -50%)';
this.upgradePopupEl.style.background = 'rgba(0,0,0,0.85)';
this.upgradePopupEl.style.border = `2px solid ${COLORS.uiBorder}`;
this.upgradePopupEl.style.borderRadius = '10px';
this.upgradePopupEl.style.padding = '20px';
this.upgradePopupEl.style.display = 'none';
this.upgradePopupEl.style.pointerEvents = 'auto';
this.upgradePopupEl.style.zIndex = '50';
this.upgradePopupEl.style.minWidth = '340px';
const upgradeTitle = this.createTextEl('Upgrade Points Available', '#f1c40f', '20px');
upgradeTitle.style.textAlign = 'center';
upgradeTitle.style.marginBottom = '12px';
upgradeTitle.style.fontWeight = 'bold';
this.upgradePopupEl.appendChild(upgradeTitle);
this.upgradeGridEl = document.createElement('div');
this.upgradeGridEl.style.display = 'grid';
this.upgradeGridEl.style.gridTemplateColumns = 'repeat(3, 1fr)';
this.upgradeGridEl.style.gap = '8px';
this.upgradePopupEl.appendChild(this.upgradeGridEl);
this.hudEl.appendChild(this.upgradePopupEl);
}
// ─── DOM Helpers ───────────────────────────────────────────────────────────
private createBarContainer(left: string, top: string): HTMLDivElement {
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.left = left;
el.style.top = top;
el.style.pointerEvents = 'auto';
return el;
}
private createTextEl(text: string, color: string, fontSize: string): HTMLDivElement {
const el = document.createElement('div');
el.textContent = text;
el.style.color = color;
el.style.fontSize = fontSize;
el.style.marginBottom = '2px';
el.style.textShadow = '0 1px 2px rgba(0,0,0,0.8)';
return el;
}
private createBar(width: number, height: number, bgColor: string): HTMLDivElement {
const el = document.createElement('div');
el.style.width = `${width}px`;
el.style.height = `${height}px`;
el.style.background = bgColor;
el.style.borderRadius = '3px';
el.style.overflow = 'hidden';
el.style.position = 'relative';
return el;
}
private createBarFill(width: number, height: number, color: string): HTMLDivElement {
const el = document.createElement('div');
el.style.width = '100%';
el.style.height = `${height}px`;
el.style.background = color;
el.style.transition = 'width 0.2s ease';
return el;
}
// ─── Visibility ────────────────────────────────────────────────────────────
show(): void {
this.visible = true;
this.hudEl.style.display = 'block';
}
hide(): void {
this.visible = false;
this.hudEl.style.display = 'none';
}
// ─── Update from State ─────────────────────────────────────────────────────
update(state: RoomState, localPlayerId: string | null): void {
if (!this.visible || !localPlayerId) return;
const player = state.players.get(localPlayerId);
if (!player) return;
this.updateHP(player);
this.updateXP(player);
this.updateAmmo(player);
this.updateLeaderboard(state);
this.updateUpgrades(player);
}
private updateHP(player: Player): void {
if (!this.hpTextEl || !this.hpBarFillEl) return;
const hpPct = Math.max(0, player.hp / player.maxHp);
const hpText = `HP: ${Math.ceil(player.hp)}/${player.maxHp}`;
this.hpTextEl.textContent = hpText;
this.hpBarFillEl.style.width = `${hpPct * 100}%`;
// Change color based on HP level
if (hpPct > 0.5) {
this.hpBarFillEl.style.background = COLORS.hpBar;
} else if (hpPct > 0.25) {
this.hpBarFillEl.style.background = '#f39c12';
} else {
this.hpBarFillEl.style.background = '#e74c3c';
}
}
private updateXP(player: Player): void {
if (!this.xpTextEl || !this.xpBarFillEl) return;
const xpPct = Math.min(1, player.xp / player.xpToNext);
this.xpTextEl.textContent = `Level ${player.level} (${player.xp}/${player.xpToNext} XP)`;
this.xpBarFillEl.style.width = `${xpPct * 100}%`;
}
private updateAmmo(player: Player): void {
if (!this.ammoEl) return;
if (player.isReloading) {
this.ammoEl.textContent = 'RELOADING...';
this.ammoEl.style.color = '#e74c3c';
} else {
this.ammoEl.textContent = `Ammo: ${player.ammo}/${player.maxAmmo}`;
this.ammoEl.style.color = '#ecf0f1';
}
}
private updateLeaderboard(state: RoomState): void {
if (!this.leaderboardEl) return;
// Build leaderboard HTML from state
const entries: Array<{ name: string; level: number; score: number; xp: number; id: string }> = [];
for (const [id, p] of state.players) {
if (p.alive) {
entries.push({
name: p.name,
level: p.level,
score: p.score,
xp: p.xp,
id,
});
}
}
entries.sort((a, b) => b.xp - a.xp);
const top10 = entries.slice(0, 10);
let html = '<div style="color:#f1c40f;font-size:14px;font-weight:bold;margin-bottom:6px;text-align:center;">Leaderboard</div>';
for (let i = 0; i < top10.length; i++) {
const e = top10[i];
const color = i === 0 ? '#f1c40f' : i === 1 ? '#bdc3c7' : i === 2 ? '#cd7f32' : '#ecf0f1';
html += `<div style="color:${color};font-size:12px;padding:2px 0;">${i + 1}. ${e.name} — Lvl ${e.level} — ${e.score} kills</div>`;
}
this.leaderboardEl.innerHTML = html;
}
// ─── Upgrade Popup ─────────────────────────────────────────────────────────
private updateUpgrades(player: Player): void {
if (!this.upgradePopupEl || !this.upgradeGridEl) return;
if (player.upgradePoints > 0) {
this.upgradePopupEl.style.display = 'block';
this.renderUpgradeButtons(player);
} else {
this.upgradePopupEl.style.display = 'none';
}
}
private renderUpgradeButtons(player: Player): void {
if (!this.upgradeGridEl) return;
// Only rebuild if needed (simple check: compare point count)
const currentPoints = this.upgradeGridEl.dataset.points;
if (currentPoints === String(player.upgradePoints) &&
this.upgradeGridEl.dataset.stats === String(player.stats)) {
return;
}
this.upgradeGridEl.dataset.points = String(player.upgradePoints);
this.upgradeGridEl.dataset.stats = String(player.stats);
this.upgradeGridEl.innerHTML = '';
for (let i = 0; i < STAT_COUNT; i++) {
const statLevel = player.stats[i] ?? 0;
const statName = STAT_NAMES[i];
const bonus = STAT_BONUSES[i as Stat];
const bonusText = bonus.isPct
? `+${Math.round(bonus.perLevel * 100)}%`
: `+${bonus.perLevel}`;
const btn = document.createElement('button');
btn.style.background = '#2c3e50';
btn.style.border = '1px solid #34495e';
btn.style.borderRadius = '6px';
btn.style.padding = '8px 4px';
btn.style.color = '#ecf0f1';
btn.style.fontSize = '11px';
btn.style.cursor = 'pointer';
btn.style.textAlign = 'center';
btn.style.transition = 'background 0.15s';
btn.style.pointerEvents = 'auto';
btn.innerHTML = `
<div style="font-weight:bold;margin-bottom:2px;">${statName}</div>
<div style="color:#f1c40f;font-size:10px;">${bonusText}</div>
<div style="color:#95a5a6;font-size:10px;margin-top:2px;">Lvl ${statLevel}/7</div>
`;
btn.addEventListener('mouseenter', () => {
btn.style.background = '#34495e';
});
btn.addEventListener('mouseleave', () => {
btn.style.background = '#2c3e50';
});
const statIdx = i;
btn.addEventListener('click', () => {
if (this.upgradeCallback) {
this.upgradeCallback(statIdx);
}
});
this.upgradeGridEl.appendChild(btn);
}
// Points remaining label
const pointsLabel = document.createElement('div');
pointsLabel.style.gridColumn = '1 / -1';
pointsLabel.style.textAlign = 'center';
pointsLabel.style.color = '#f1c40f';
pointsLabel.style.fontSize = '14px';
pointsLabel.style.marginTop = '4px';
pointsLabel.textContent = `${player.upgradePoints} point${player.upgradePoints > 1 ? 's' : ''} remaining`;
this.upgradeGridEl.appendChild(pointsLabel);
}
/** Register callback for stat upgrade selection */
onUpgrade(callback: (stat: number) => void): void {
this.upgradeCallback = callback;
}
// ─── Cleanup ───────────────────────────────────────────────────────────────
destroy(): void {
this.hide();
this.hudEl.innerHTML = '';
}
}
+208
View File
@@ -0,0 +1,208 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Input Handler (WASD + Mouse)
// ═══════════════════════════════════════════════════════════════════════════════
import {
INPUT_MS,
type PlayerInput,
} from '@guncircle/shared';
/** Input sequence counter */
let globalSeq = 0;
/** Movement angle for each WASD key (in radians, standard math convention) */
const KEY_ANGLES: Record<string, number> = {
KeyW: -Math.PI / 2, // Up
KeyS: Math.PI / 2, // Down
KeyA: Math.PI, // Left
KeyD: 0, // Right
};
export class InputHandler {
/** Currently pressed keys (WASD) */
private readonly keys = new Set<string>();
/** Mouse X in screen coordinates */
mouseX = 0;
/** Mouse Y in screen coordinates */
mouseY = 0;
/** Is left mouse button held down */
mouseDown = false;
/** Canvas element for center calculations */
private readonly canvas: HTMLCanvasElement;
/** Bound event handlers for cleanup */
private readonly boundKeyDown: (e: KeyboardEvent) => void;
private readonly boundKeyUp: (e: KeyboardEvent) => void;
private readonly boundMouseMove: (e: MouseEvent) => void;
private readonly boundMouseDown: (e: MouseEvent) => void;
private readonly boundMouseUp: (e: MouseEvent) => void;
private readonly boundContextMenu: (e: Event) => void;
/** Input send interval ID */
private inputInterval: number | null = null;
/** Callback to send input to network */
private sendCallback: ((input: PlayerInput) => void) | null = null;
constructor(canvas: HTMLCanvasElement) {
this.canvas = canvas;
this.boundKeyDown = this.handleKeyDown.bind(this);
this.boundKeyUp = this.handleKeyUp.bind(this);
this.boundMouseMove = this.handleMouseMove.bind(this);
this.boundMouseDown = this.handleMouseDown.bind(this);
this.boundMouseUp = this.handleMouseUp.bind(this);
this.boundContextMenu = this.handleContextMenu.bind(this);
window.addEventListener('keydown', this.boundKeyDown);
window.addEventListener('keyup', this.boundKeyUp);
window.addEventListener('mousemove', this.boundMouseMove);
window.addEventListener('mousedown', this.boundMouseDown);
window.addEventListener('mouseup', this.boundMouseUp);
window.addEventListener('contextmenu', this.boundContextMenu);
}
// ─── Event Handlers ────────────────────────────────────────────────────────
private handleKeyDown(e: KeyboardEvent): void {
// Skip WASD handling when typing in an input/textarea
const activeTag = (document.activeElement?.tagName ?? '').toLowerCase();
if (activeTag === 'input' || activeTag === 'textarea') {
return;
}
if (e.code in KEY_ANGLES) {
e.preventDefault();
this.keys.add(e.code);
}
}
private handleKeyUp(e: KeyboardEvent): void {
const activeTag = (document.activeElement?.tagName ?? '').toLowerCase();
if (activeTag === 'input' || activeTag === 'textarea') {
return;
}
if (e.code in KEY_ANGLES) {
e.preventDefault();
this.keys.delete(e.code);
}
}
private handleMouseMove(e: MouseEvent): void {
this.mouseX = e.clientX;
this.mouseY = e.clientY;
}
private handleMouseDown(e: MouseEvent): void {
if (e.button === 0) {
this.mouseDown = true;
}
}
private handleMouseUp(e: MouseEvent): void {
if (e.button === 0) {
this.mouseDown = false;
}
}
private handleContextMenu(e: Event): void {
e.preventDefault();
}
// ─── Query Methods ─────────────────────────────────────────────────────────
/**
* Compute movement angle from active WASD keys.
* Returns -1 if no movement keys are pressed.
* Diagonal movement averages the angles of active keys.
*/
getMoveAngle(): number {
if (this.keys.size === 0) {
return -1;
}
let sumX = 0;
let sumY = 0;
for (const key of this.keys) {
const angle = KEY_ANGLES[key];
if (angle !== undefined) {
sumX += Math.cos(angle);
sumY += Math.sin(angle);
}
}
return Math.atan2(sumY, sumX);
}
/**
* Compute aim angle in world space from mouse position.
* Converts screen mouse coords to world coords using camera position,
* then computes angle from player to mouse in world space.
*/
getAimAngle(cameraX: number, cameraY: number, playerX: number, playerY: number): number {
// Convert screen mouse to world coordinates
const worldMouseX = this.mouseX - this.canvas.width / 2 + cameraX;
const worldMouseY = this.mouseY - this.canvas.height / 2 + cameraY;
return Math.atan2(worldMouseY - playerY, worldMouseX - playerX);
}
/** Is the player currently shooting (left mouse held) */
isShooting(): boolean {
return this.mouseDown;
}
// ─── Serialization ─────────────────────────────────────────────────────────
/** Build a PlayerInput from the current input state */
getInput(cameraX: number, cameraY: number, playerX: number, playerY: number): PlayerInput {
globalSeq++;
return {
seq: globalSeq,
moveAngle: this.getMoveAngle(),
aimAngle: this.getAimAngle(cameraX, cameraY, playerX, playerY),
isShooting: this.mouseDown,
};
}
// ─── Input Sending ─────────────────────────────────────────────────────────
/**
* Start sending input at 60Hz.
* @param getCamera - Returns current camera position [x, y]
* @param getPlayer - Returns current player position [x, y]
* @param sendFn - Callback that receives the PlayerInput
*/
startSending(
getCamera: () => [number, number],
getPlayer: () => [number, number],
sendFn: (input: PlayerInput) => void
): void {
this.sendCallback = sendFn;
this.inputInterval = window.setInterval(() => {
const [camX, camY] = getCamera();
const [plX, plY] = getPlayer();
const input = this.getInput(camX, camY, plX, plY);
sendFn(input);
}, INPUT_MS);
}
/** Stop the input sending interval */
stopSending(): void {
if (this.inputInterval !== null) {
clearInterval(this.inputInterval);
this.inputInterval = null;
}
}
// ─── Cleanup ───────────────────────────────────────────────────────────────
/** Remove all event listeners */
destroy(): void {
this.stopSending();
window.removeEventListener('keydown', this.boundKeyDown);
window.removeEventListener('keyup', this.boundKeyUp);
window.removeEventListener('mousemove', this.boundMouseMove);
window.removeEventListener('mousedown', this.boundMouseDown);
window.removeEventListener('mouseup', this.boundMouseUp);
window.removeEventListener('contextmenu', this.boundContextMenu);
}
}
+167
View File
@@ -0,0 +1,167 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Entity Interpolation (Pure Functions)
// ═══════════════════════════════════════════════════════════════════════════════
import { lerp, lerpAngle, vec2 } from '@guncircle/shared';
import type { Player, Bullet } from '@guncircle/shared';
import { POS_DRIFT_THRESHOLD } from '@guncircle/shared';
// ─── Snapshot Types ──────────────────────────────────────────────────────────
/** A lightweight snapshot of player state for interpolation */
export interface PlayerSnapshot {
x: number;
y: number;
angle: number;
hp: number;
recoilOffset: number;
timestamp: number;
}
/** A lightweight snapshot of bullet state for interpolation */
export interface BulletSnapshot {
x: number;
y: number;
angle: number;
timestamp: number;
}
/** A server state snapshot at a given time */
export interface StateSnapshot {
timestamp: number;
tick: number;
players: Map<string, PlayerSnapshot>;
bullets: Map<string, BulletSnapshot>;
}
// ─── Player Interpolation ────────────────────────────────────────────────────
/**
* Interpolate between two player snapshots.
* @param prev - Previous snapshot
* @param curr - Current snapshot
* @param t - Interpolation factor [0, 1]
* @returns Interpolated snapshot
*/
export function interpolatePlayer(
prev: PlayerSnapshot,
curr: PlayerSnapshot,
t: number
): PlayerSnapshot {
return {
x: lerp(prev.x, curr.x, t),
y: lerp(prev.y, curr.y, t),
angle: lerpAngle(prev.angle, curr.angle, t),
hp: lerp(prev.hp, curr.hp, t),
recoilOffset: lerp(prev.recoilOffset, curr.recoilOffset, t),
timestamp: lerp(prev.timestamp, curr.timestamp, t),
};
}
// ─── Bullet Interpolation ────────────────────────────────────────────────────
/**
* Interpolate between two bullet snapshots.
* @param prev - Previous snapshot
* @param curr - Current snapshot
* @param t - Interpolation factor [0, 1]
* @returns Interpolated snapshot
*/
export function interpolateBullet(
prev: BulletSnapshot,
curr: BulletSnapshot,
t: number
): BulletSnapshot {
return {
x: lerp(prev.x, curr.x, t),
y: lerp(prev.y, curr.y, t),
angle: lerpAngle(prev.angle, curr.angle, t),
timestamp: lerp(prev.timestamp, curr.timestamp, t),
};
}
// ─── Server Reconciliation ───────────────────────────────────────────────────
/**
* Check if predicted position has drifted too far from server position.
*/
export function shouldCorrect(
predicted: { x: number; y: number },
server: { x: number; y: number }
): boolean {
const dx = predicted.x - server.x;
const dy = predicted.y - server.y;
return dx * dx + dy * dy > POS_DRIFT_THRESHOLD * POS_DRIFT_THRESHOLD;
}
/**
* Smoothly correct predicted position toward server position.
* Uses a lerp at ~15 units/sec for smooth visual correction.
*/
export function smoothCorrection(
predicted: { x: number; y: number },
server: { x: number; y: number },
dt: number
): { x: number; y: number } {
// Correction speed: ~15 units per second
const correctionSpeed = 15;
const diff = vec2.sub(server, predicted);
const dist = vec2.len(diff);
if (dist < 0.1) {
return { x: server.x, y: server.y };
}
// Move toward server position at fixed speed, but don't overshoot
const maxMove = correctionSpeed * dt;
if (dist <= maxMove) {
return { x: server.x, y: server.y };
}
const t = maxMove / dist;
return {
x: predicted.x + diff.x * t,
y: predicted.y + diff.y * t,
};
}
// ─── Snapshot Builders ───────────────────────────────────────────────────────
/**
* Build a state snapshot from the current Colyseus room state.
*/
export function buildSnapshot(
players: Map<string, Player>,
bullets: Map<string, Bullet>,
tick: number
): StateSnapshot {
const playerSnaps = new Map<string, PlayerSnapshot>();
const bulletSnaps = new Map<string, BulletSnapshot>();
for (const [id, p] of players) {
playerSnaps.set(id, {
x: p.x,
y: p.y,
angle: p.angle,
hp: p.hp,
recoilOffset: p.recoilOffset,
timestamp: performance.now(),
});
}
for (const [id, b] of bullets) {
bulletSnaps.set(id, {
x: b.x,
y: b.y,
angle: b.angle,
timestamp: performance.now(),
});
}
return {
timestamp: performance.now(),
tick,
players: playerSnaps,
bullets: bulletSnaps,
};
}
+66
View File
@@ -0,0 +1,66 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Client Entry Point
// ═══════════════════════════════════════════════════════════════════════════════
import { Menu } from './menu.js';
import { startGame, stopGame } from './game.js';
import { startOfflineGame, stopOfflineGame } from './offline-game.js';
// ─── Application State ───────────────────────────────────────────────────────
/** Is the game currently running */
let isPlaying = false;
/** Is offline mode active */
let isOffline = false;
// ─── DOM Ready ───────────────────────────────────────────────────────────────
function init(): void {
// Create menu handler
const menu = new Menu();
// Register play callback
menu.onPlay((name: string) => {
if (isPlaying) return;
isPlaying = true;
menu.hide();
// Start in offline mode immediately (no server required)
isOffline = true;
startOfflineGame(name);
});
// Show menu initially
menu.show();
// Handle Enter key on menu
const nameInput = document.getElementById('nameInput') as HTMLInputElement | null;
if (nameInput) {
nameInput.focus();
}
// Handle page unload — clean up
window.addEventListener('beforeunload', () => {
stopGame();
stopOfflineGame();
});
// Handle escape to return to menu
window.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && isPlaying) {
stopGame();
stopOfflineGame();
isPlaying = false;
isOffline = false;
menu.show();
}
});
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
+86
View File
@@ -0,0 +1,86 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Menu Handler
// ═══════════════════════════════════════════════════════════════════════════════
export class Menu {
private readonly menuEl: HTMLDivElement;
private readonly nameInput: HTMLInputElement;
private readonly playBtn: HTMLButtonElement;
private playCallback: ((name: string) => void) | null = null;
private keyHandler: ((e: KeyboardEvent) => void) | null = null;
constructor() {
const menuEl = document.getElementById('menu') as HTMLDivElement | null;
const nameInput = document.getElementById('nameInput') as HTMLInputElement | null;
const playBtn = document.getElementById('playBtn') as HTMLButtonElement | null;
if (!menuEl || !nameInput || !playBtn) {
throw new Error('Menu: required DOM elements not found');
}
this.menuEl = menuEl;
this.nameInput = nameInput;
this.playBtn = playBtn;
this.setupListeners();
}
private setupListeners(): void {
// Play button click
this.playBtn.addEventListener('click', (e) => {
e.preventDefault();
this.handlePlay();
});
// Enter key in input field
this.keyHandler = (e: KeyboardEvent): void => {
e.preventDefault();
if (e.key === 'Enter') {
this.handlePlay();
}
};
this.nameInput.addEventListener('keydown', this.keyHandler);
}
private handlePlay(): void {
const name = this.getName();
if (this.playCallback) {
this.playCallback(name);
}
}
/** Get trimmed name from input, defaulting to "Player" */
getName(): string {
const raw = this.nameInput.value.trim();
return raw.length > 0 ? raw.slice(0, 16) : 'Player';
}
/** Register callback for when player clicks PLAY */
onPlay(callback: (name: string) => void): void {
this.playCallback = callback;
}
/** Show the menu overlay */
show(): void {
this.menuEl.style.display = 'flex';
this.nameInput.focus();
}
/** Hide the menu overlay */
hide(): void {
this.menuEl.style.display = 'none';
this.nameInput.blur();
// Move focus to canvas so it receives keyboard events
const canvas = document.getElementById('game') as HTMLCanvasElement | null;
if (canvas) {
canvas.focus();
}
}
/** Clean up event listeners */
destroy(): void {
if (this.keyHandler) {
this.nameInput.removeEventListener('keydown', this.keyHandler);
}
}
}
+402
View File
@@ -0,0 +1,402 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Network Manager (Colyseus Client)
// ═══════════════════════════════════════════════════════════════════════════════
import { Client, Room } from 'colyseus.js';
import {
RoomState,
Player,
type PlayerInput,
POS_DRIFT_THRESHOLD,
PLAYER_BASE_SPEED,
PLAYER_FRICTION,
ARENA_WIDTH,
ARENA_HEIGHT,
lerp,
vec2,
} from '@guncircle/shared';
import {
buildSnapshot,
shouldCorrect,
smoothCorrection,
type StateSnapshot,
type PlayerSnapshot,
} from './interpolation.js';
/** Interpolation delay in ms — render 100ms behind server */
const INTERP_DELAY_MS = 100;
/** Max snapshots to keep in buffer */
const MAX_SNAPSHOTS = 32;
/** Smooth correction lerp factor per second */
const CORRECTION_LERP_RATE = 5;
/** Pending input with sequence number */
interface PendingInput {
seq: number;
input: PlayerInput;
predictedX: number;
predictedY: number;
}
/** Predicted player state for client-side prediction */
interface PredictedState {
x: number;
y: number;
vx: number;
vy: number;
lastProcessedSeq: number;
}
export class NetworkManager {
private client: Client | null = null;
private room: Room<RoomState> | null = null;
/** Local player session ID */
localPlayerId: string | null = null;
/** Current room state */
state: RoomState | null = null;
/** Snapshot buffer for interpolation [oldest ... newest] */
private snapshots: StateSnapshot[] = [];
/** Pending inputs for reconciliation */
private pendingInputs: PendingInput[] = [];
/** Predicted local player state */
predictedState: PredictedState | null = null;
/** Is connected */
connected = false;
/** Connection error message */
error: string | null = null;
/** On disconnect callback */
private disconnectCallback: (() => void) | null = null;
/** On connect callback */
private connectCallback: ((localId: string) => void) | null = null;
/** Current input sequence number */
private inputSeq = 0;
// ─── Connection ────────────────────────────────────────────────────────────
/**
* Connect to Colyseus server and join a room.
*/
async connect(roomName: string, options: { name: string }): Promise<void> {
try {
// Connect via WebSocket proxy
this.client = new Client('ws://localhost:3000');
this.room = await Promise.race([
this.client.joinOrCreate<RoomState>(roomName, {
name: options.name.slice(0, 16),
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Connection timed out')), 5000)
),
]);
this.localPlayerId = this.room.sessionId;
this.connected = true;
this.error = null;
this.setupRoomHandlers();
if (this.connectCallback) {
this.connectCallback(this.localPlayerId);
}
} catch (err) {
this.connected = false;
this.error = err instanceof Error ? err.message : 'Connection failed';
throw err;
}
}
private setupRoomHandlers(): void {
if (!this.room) return;
this.room.onStateChange((state) => {
this.handleStateChange(state);
});
this.room.onMessage('error', (msg: string) => {
console.error('[Network] Server error:', msg);
});
this.room.onLeave((code) => {
console.log(`[Network] Left room (code: ${code})`);
this.connected = false;
this.localPlayerId = null;
if (this.disconnectCallback) {
this.disconnectCallback();
}
});
this.room.onError((code: number) => {
console.error('[Network] Room error, code:', code);
this.error = `Connection error (code: ${code})`;
});
}
private handleStateChange(newState: RoomState): void {
this.state = newState;
// Build and store snapshot for interpolation
const snapshot = buildSnapshot(
newState.players,
newState.bullets,
newState.tick
);
this.addSnapshot(snapshot);
// Server reconciliation for local player
if (this.localPlayerId) {
this.reconcileLocalPlayer(snapshot);
}
}
private addSnapshot(snapshot: StateSnapshot): void {
this.snapshots.push(snapshot);
if (this.snapshots.length > MAX_SNAPSHOTS) {
this.snapshots.shift();
}
}
// ─── Client-Side Prediction ────────────────────────────────────────────────
/**
* Apply local movement prediction immediately.
* Call this each frame with the current input to predict local position.
*/
applyPrediction(input: PlayerInput, dt: number): void {
if (!this.localPlayerId || !this.state) return;
const serverPlayer = this.state.players.get(this.localPlayerId);
if (!serverPlayer) return;
// Initialize predicted state from server if needed
if (!this.predictedState) {
this.predictedState = {
x: serverPlayer.x,
y: serverPlayer.y,
vx: serverPlayer.vx,
vy: serverPlayer.vy,
lastProcessedSeq: input.seq,
};
}
// Apply movement based on input
if (input.moveAngle >= 0) {
const speed = PLAYER_BASE_SPEED * dt;
this.predictedState.vx += Math.cos(input.moveAngle) * speed * 5;
this.predictedState.vy += Math.sin(input.moveAngle) * speed * 5;
}
// Apply friction
this.predictedState.vx *= PLAYER_FRICTION;
this.predictedState.vy *= PLAYER_FRICTION;
// Update position
this.predictedState.x += this.predictedState.vx * dt;
this.predictedState.y += this.predictedState.vy * dt;
// Clamp to arena
this.predictedState.x = Math.max(0, Math.min(ARENA_WIDTH, this.predictedState.x));
this.predictedState.y = Math.max(0, Math.min(ARENA_HEIGHT, this.predictedState.y));
// Store pending input for reconciliation
this.pendingInputs.push({
seq: input.seq,
input,
predictedX: this.predictedState.x,
predictedY: this.predictedState.y,
});
// Keep only last ~2 seconds of pending inputs
const maxPending = 120;
if (this.pendingInputs.length > maxPending) {
this.pendingInputs = this.pendingInputs.slice(-maxPending);
}
}
// ─── Server Reconciliation ─────────────────────────────────────────────────
/**
* Compare predicted position to server position and correct if needed.
*/
private reconcileLocalPlayer(serverSnapshot: StateSnapshot): void {
if (!this.predictedState || !this.localPlayerId) return;
const serverPlayer = serverSnapshot.players.get(this.localPlayerId);
if (!serverPlayer) return;
// Remove acknowledged inputs (anything older than the server tick)
// Since we don't have per-ack sequence numbers from server snapshots,
// we keep recent inputs and use a sliding window approach
const tooOld = this.pendingInputs.filter(
(pi) => pi.seq < serverSnapshot.tick - 4
);
if (tooOld.length > 0) {
this.pendingInputs = this.pendingInputs.filter(
(pi) => pi.seq >= serverSnapshot.tick - 4
);
}
// Check for drift between predicted and server positions
if (
shouldCorrect(
{ x: this.predictedState.x, y: this.predictedState.y },
{ x: serverPlayer.x, y: serverPlayer.y }
)
) {
// Snap predicted state toward server position
this.predictedState.x = lerp(
this.predictedState.x,
serverPlayer.x,
0.3
);
this.predictedState.y = lerp(
this.predictedState.y,
serverPlayer.y,
0.3
);
// Reset velocity to zero (server sends velocity, but PlayerSnapshot doesn't include it)
// Velocity gets rebuilt by next frame's prediction
this.predictedState.vx *= 0.5;
this.predictedState.vy *= 0.5;
}
}
/**
* Get the reconciled local player position for rendering.
* Returns the predicted position (which has been nudged toward server).
*/
getLocalPosition(): { x: number; y: number } | null {
if (this.predictedState) {
return { x: this.predictedState.x, y: this.predictedState.y };
}
return null;
}
// ─── Entity Interpolation ──────────────────────────────────────────────────
/**
* Get interpolated player data for rendering.
* Returns interpolated position between server snapshots at (now - INTERP_DELAY_MS).
*/
getInterpolatedPlayer(playerId: string): PlayerSnapshot | null {
const renderTime = performance.now() - INTERP_DELAY_MS;
return this.getInterpolatedPlayerAtTime(playerId, renderTime);
}
private getInterpolatedPlayerAtTime(
playerId: string,
renderTime: number
): PlayerSnapshot | null {
const snaps = this.snapshots;
if (snaps.length < 2) {
// Not enough history — return latest known state
const latest = snaps[snaps.length - 1];
if (latest) {
return latest.players.get(playerId) ?? null;
}
return null;
}
// Find two snapshots that bracket renderTime
let beforeIdx = -1;
for (let i = snaps.length - 2; i >= 0; i--) {
if (snaps[i]!.timestamp <= renderTime) {
beforeIdx = i;
break;
}
}
if (beforeIdx === -1) {
// renderTime is before all snapshots — return earliest
const p = snaps[0]!.players.get(playerId);
return p ?? null;
}
const before = snaps[beforeIdx]!;
const after = snaps[beforeIdx + 1]!;
const playerBefore = before.players.get(playerId);
const playerAfter = after.players.get(playerId);
if (!playerBefore || !playerAfter) {
// Player missing from one snapshot
return playerBefore ?? playerAfter ?? null;
}
// Calculate interpolation factor
const timeSpan = after.timestamp - before.timestamp;
if (timeSpan === 0) return playerBefore;
const t = Math.max(0, Math.min(1, (renderTime - before.timestamp) / timeSpan));
return {
x: lerp(playerBefore.x, playerAfter.x, t),
y: lerp(playerBefore.y, playerAfter.y, t),
angle: lerp(playerBefore.angle, playerAfter.angle, t),
hp: lerp(playerBefore.hp, playerAfter.hp, t),
recoilOffset: lerp(playerBefore.recoilOffset, playerAfter.recoilOffset, t),
timestamp: renderTime,
};
}
/**
* Get the interpolated position for a remote entity at render time.
* Used by the renderer for other players.
*/
getEntityPosition(entityId: string): { x: number; y: number } | null {
const interp = this.getInterpolatedPlayer(entityId);
if (interp) {
return { x: interp.x, y: interp.y };
}
return null;
}
// ─── Input Sending ─────────────────────────────────────────────────────────
/** Send player input to the server */
sendInput(input: PlayerInput): void {
if (!this.room || !this.connected) return;
this.inputSeq++;
const inputWithSeq: PlayerInput = {
...input,
seq: this.inputSeq,
};
this.room.send('input', inputWithSeq);
}
/** Send upgrade choice to the server */
sendUpgrade(choice: number): void {
if (!this.room || !this.connected) return;
this.room.send('upgrade', { choice });
}
// ─── Callbacks ─────────────────────────────────────────────────────────────
onConnect(callback: (localId: string) => void): void {
this.connectCallback = callback;
}
onDisconnect(callback: () => void): void {
this.disconnectCallback = callback;
}
// ─── Disconnect ────────────────────────────────────────────────────────────
/** Leave the room and clean up */
disconnect(): void {
if (this.room) {
this.room.leave();
this.room = null;
}
this.client = null;
this.connected = false;
this.localPlayerId = null;
this.state = null;
this.snapshots = [];
this.pendingInputs = [];
this.predictedState = null;
}
}
+176
View File
@@ -0,0 +1,176 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Offline Game Bridge
// ═══════════════════════════════════════════════════════════════════════════════
// Connects OfflineEngine to Renderer, Camera, Input, and HUD for a fully
// playable single-player experience without a server.
// ═══════════════════════════════════════════════════════════════════════════════
import { OfflineEngine } from './offline-mode.js';
import { Renderer } from './renderer.js';
import { Camera } from './camera.js';
import { InputHandler } from './input.js';
import { HUD } from './hud.js';
import type { PlayerInput } from '@guncircle/shared';
/** Max delta time to prevent spiral of death */
const MAX_DT = 0.1;
/** Camera follow lerp rate */
const CAMERA_LERP_SPEED = 8;
/** Active offline game state */
interface OfflineGameState {
canvas: HTMLCanvasElement;
ctx: CanvasRenderingContext2D;
camera: Camera;
input: InputHandler;
renderer: Renderer;
hud: HUD;
engine: OfflineEngine;
lastTime: number;
running: boolean;
}
let game: OfflineGameState | null = null;
let rafHandle = 0;
let inputInterval = 0;
/**
* Start the offline game with the given player name.
*/
export function startOfflineGame(playerName: string): void {
// Clean up any existing game
stopOfflineGame();
// Get canvas
const canvas = document.getElementById('game') as HTMLCanvasElement | null;
if (!canvas) throw new Error('Game canvas not found');
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) throw new Error('Could not get 2D context');
// Initialize subsystems
const camera = new Camera(canvas.width, canvas.height);
const input = new InputHandler(canvas);
const renderer = new Renderer(ctx, canvas);
const hud = new HUD();
const engine = new OfflineEngine();
// Create game state
game = {
canvas,
ctx,
camera,
input,
renderer,
hud,
engine,
lastTime: performance.now(),
running: true,
};
// Handle resize
const handleResize = (): void => {
if (!game) return;
const w = window.innerWidth;
const h = window.innerHeight;
game.canvas.width = w;
game.canvas.height = h;
game.camera.resize(w, h);
game.renderer.resize(w, h);
};
window.addEventListener('resize', handleResize);
(game as unknown as Record<string, unknown>)['_resizeHandler'] = handleResize;
// Start engine
engine.start(playerName);
engine.onUpgrade((choice: number) => {
engine.applyUpgrade(choice);
});
// Setup HUD
hud.show();
hud.onUpgrade((statIdx: number) => {
engine.applyUpgrade(statIdx);
});
// Send input at 60Hz to the offline engine
input.startSending(
// getCamera
() => [camera.x, camera.y],
// getPlayer
() => {
const p = engine.state.players.get(engine.localPlayerId);
return p ? [p.x, p.y] : [camera.x, camera.y];
},
// sendFn
(inputData: PlayerInput) => {
if (!game) return;
game.engine.processInput(
inputData.moveAngle,
inputData.aimAngle,
inputData.isShooting,
inputData.seq
);
}
);
// Start game loop
game.lastTime = performance.now();
gameLoop(performance.now());
}
/**
* Main game loop using requestAnimationFrame.
*/
function gameLoop(now: number): void {
if (!game || !game.running) return;
let dt = (now - game.lastTime) / 1000;
game.lastTime = now;
if (dt > MAX_DT) dt = MAX_DT;
// Update camera to follow local player
const state = game.engine.state;
const localPlayer = state.players.get(game.engine.localPlayerId);
if (localPlayer && localPlayer.alive) {
const rate = Math.min(CAMERA_LERP_SPEED * dt, 1);
game.camera.x += (localPlayer.x - game.camera.x) * rate;
game.camera.y += (localPlayer.y - game.camera.y) * rate;
game.camera.clampToArena();
}
// Update HUD
game.hud.update(state, game.engine.localPlayerId);
// Render
game.renderer.render(state, game.camera, game.engine.localPlayerId, dt);
// Queue next frame
rafHandle = requestAnimationFrame(gameLoop);
}
/**
* Stop the offline game and clean up all resources.
*/
export function stopOfflineGame(): void {
if (!game) return;
game.running = false;
cancelAnimationFrame(rafHandle);
clearInterval(inputInterval);
game.input.destroy();
game.hud.destroy();
const resizeHandler = (game as unknown as Record<string, unknown>)['_resizeHandler'] as
| (() => void)
| undefined;
if (resizeHandler) {
window.removeEventListener('resize', resizeHandler);
}
game = null;
}
+568
View File
@@ -0,0 +1,568 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Offline Demo Mode (Local Single-Player)
// ═══════════════════════════════════════════════════════════════════════════════
// Provides a fully playable local mode when no server is available.
// AI bots move randomly, shoot periodically, drop XP on death.
// ═══════════════════════════════════════════════════════════════════════════════
import {
RoomState, Player, Bullet, XPOrb, LeaderboardEntry,
ARENA_WIDTH, ARENA_HEIGHT, PLAYER_BASE_HP, PLAYER_BASE_SPEED,
PLAYER_BASE_RADIUS, PLAYER_FRICTION, TICK_MS,
XP_LEVELS, lerp, clamp, randFloat, randInt, randSign, vec2,
GUN_BARREL_LENGTH,
COLORS,
type GunConfig,
} from '@guncircle/shared';
// ─── Gun Configs (embedded for offline) ──────────────────────────────────────
const GUNS: GunConfig[] = [
{ id: 'pistol', name: 'Pistol', category: 0, damage: 12, fireRate: 4, bulletSpeed: 400, pelletCount: 1, spread: 0, recoilOffset: 0.06, recoilRecoveryMs: 120, kickbackForce: 30, ammoMax: 12, reloadTimeMs: 1500, bulletSize: 4, penetration: 1, bulletLifetimeMs: 2000, bulletColor: '#f1c40f' },
{ id: 'rifle', name: 'Rifle', category: 1, damage: 18, fireRate: 3, bulletSpeed: 500, pelletCount: 1, spread: 0.02, recoilOffset: 0.08, recoilRecoveryMs: 180, kickbackForce: 45, ammoMax: 8, reloadTimeMs: 2000, bulletSize: 4.5, penetration: 2, bulletLifetimeMs: 2500, bulletColor: '#e67e22' },
{ id: 'shotgun', name: 'Shotgun', category: 2, damage: 8, fireRate: 1.2, bulletSpeed: 320, pelletCount: 5, spread: 0.25, recoilOffset: 0.15, recoilRecoveryMs: 300, kickbackForce: 80, ammoMax: 5, reloadTimeMs: 2500, bulletSize: 3, penetration: 1, bulletLifetimeMs: 1500, bulletColor: '#9b59b6' },
{ id: 'sniper', name: 'Sniper', category: 3, damage: 45, fireRate: 0.8, bulletSpeed: 700, pelletCount: 1, spread: 0, recoilOffset: 0.2, recoilRecoveryMs: 500, kickbackForce: 120, ammoMax: 3, reloadTimeMs: 3000, bulletSize: 6, penetration: 3, bulletLifetimeMs: 3500, bulletColor: '#e74c3c' },
{ id: 'smg', name: 'SMG', category: 4, damage: 6, fireRate: 10, bulletSpeed: 380, pelletCount: 1, spread: 0.06, recoilOffset: 0.04, recoilRecoveryMs: 80, kickbackForce: 20, ammoMax: 30, reloadTimeMs: 1800, bulletSize: 3, penetration: 1, bulletLifetimeMs: 1800, bulletColor: '#2ecc71' },
];
// ─── Bot AI ──────────────────────────────────────────────────────────────────
interface BotState {
id: string;
changeDirTimer: number;
shootTimer: number;
aimAngle: number;
moveAngle: number;
}
// ─── OfflineEngine ───────────────────────────────────────────────────────────
export class OfflineEngine {
state = new RoomState();
localPlayerId = 'local';
private bots: Map<string, BotState> = new Map();
private bulletLifetimes: Map<string, number> = new Map();
private nextBulletId = 0;
private tickCount = 0;
private inputSeq = 0;
private lastShotTime = 0;
private reloadTimer = 0;
private isReloading = false;
private ammo = 12;
private localRecoil = 0;
private localVx = 0;
private localVy = 0;
private animTime = 0;
private upgradeCallback: ((choice: number) => void) | null = null;
private lastBotId = 0;
/** Start the offline mode with the given player name */
start(playerName: string): void {
// Create local player
const local = new Player();
local.x = ARENA_WIDTH / 2;
local.y = ARENA_HEIGHT / 2;
local.name = playerName.substring(0, 16) || 'Player';
local.hp = PLAYER_BASE_HP;
local.maxHp = PLAYER_BASE_HP;
local.level = 1;
local.xp = 0;
local.xpToNext = XP_LEVELS[1] ?? 50;
local.gunType = 0;
local.score = 0;
local.alive = true;
local.radius = PLAYER_BASE_RADIUS;
local.ammo = GUNS[0]!.ammoMax;
local.maxAmmo = GUNS[0]!.ammoMax;
local.isReloading = false;
local.recoilOffset = 0;
local.angle = 0;
local.vx = 0;
local.vy = 0;
this.state.players.set(this.localPlayerId, local);
// Spawn bots
this.spawnBots(8);
// Spawn some initial XP orbs
for (let i = 0; i < 20; i++) {
this.spawnRandomOrb();
}
// Start tick loop
setInterval(() => this.tick(), TICK_MS);
}
private spawnBots(count: number): void {
const names = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Ghost', 'Hunter', 'Inferno', 'Juggernaut', 'Kraken', 'Lynx'];
for (let i = 0; i < count; i++) {
this.lastBotId++;
const id = `bot_${this.lastBotId}`;
const bot = new Player();
bot.x = randFloat(200, ARENA_WIDTH - 200);
bot.y = randFloat(200, ARENA_HEIGHT - 200);
bot.name = names[i % names.length]!;
bot.hp = PLAYER_BASE_HP;
bot.maxHp = PLAYER_BASE_HP;
bot.level = randInt(1, 6);
bot.xp = 0;
bot.xpToNext = XP_LEVELS[bot.level] ?? 50;
bot.gunType = randInt(0, 5);
bot.score = 0;
bot.alive = true;
bot.radius = PLAYER_BASE_RADIUS;
// Give bots XP so they drop meaningful orbs on death
bot.xp = Math.floor((XP_LEVELS[bot.level] ?? 50) * 0.4);
const gun = GUNS[bot.gunType]!;
bot.ammo = gun.ammoMax;
bot.maxAmmo = gun.ammoMax;
bot.isReloading = false;
bot.recoilOffset = 0;
bot.angle = randFloat(0, Math.PI * 2);
bot.vx = 0;
bot.vy = 0;
this.state.players.set(id, bot);
this.bots.set(id, {
id,
changeDirTimer: randFloat(1, 3),
shootTimer: randFloat(0.5, 2),
aimAngle: bot.angle,
moveAngle: randFloat(0, Math.PI * 2),
});
}
}
private spawnRandomOrb(): void {
const orb = new XPOrb();
orb.x = randFloat(100, ARENA_WIDTH - 100);
orb.y = randFloat(100, ARENA_HEIGHT - 100);
orb.value = randInt(5, 25);
orb.lifetime = 30;
this.state.xpOrbs.set(`orb_${randInt(0, 1000000)}`, orb);
}
// ─── Input ───────────────────────────────────────────────────────────────────
processInput(moveAngle: number, aimAngle: number, isShooting: boolean, _seq: number): void {
this.inputSeq++;
const local = this.state.players.get(this.localPlayerId);
if (!local || !local.alive) return;
// Update aim
local.angle = aimAngle;
// Movement
if (moveAngle >= 0) {
const speed = PLAYER_BASE_SPEED * 0.15;
this.localVx += Math.cos(moveAngle) * speed;
this.localVy += Math.sin(moveAngle) * speed;
const spd = Math.sqrt(this.localVx * this.localVx + this.localVy * this.localVy);
if (spd > PLAYER_BASE_SPEED) {
const s = PLAYER_BASE_SPEED / spd;
this.localVx *= s;
this.localVy *= s;
}
}
// Shooting
if (isShooting) {
this.localShoot(local, aimAngle);
}
// Apply movement
this.localVx *= PLAYER_FRICTION;
this.localVy *= PLAYER_FRICTION;
local.x += this.localVx * (TICK_MS / 1000);
local.y += this.localVy * (TICK_MS / 1000);
local.x = clamp(local.x, 0, ARENA_WIDTH);
local.y = clamp(local.y, 0, ARENA_HEIGHT);
local.vx = this.localVx;
local.vy = this.localVy;
// Recoil recovery
this.localRecoil = lerp(this.localRecoil, 0, 0.15);
local.recoilOffset = this.localRecoil;
// Reload
this.updateReload();
}
private localShoot(player: Player, aimAngle: number): void {
if (this.isReloading) return;
if (this.ammo <= 0) {
this.startReload(player);
return;
}
const gun = GUNS[0]!; // Always pistol in offline mode
const now = performance.now();
if (now - this.lastShotTime < 1000 / gun.fireRate) return;
// Apply recoil
this.localRecoil = randSign() * gun.recoilOffset;
player.recoilOffset = this.localRecoil;
// Kickback
const kb = vec2.fromAngle(aimAngle + Math.PI, gun.kickbackForce * 0.5);
this.localVx += kb.x;
this.localVy += kb.y;
// Spawn bullet
const spawnPos = vec2.fromAngle(aimAngle + this.localRecoil, GUN_BARREL_LENGTH);
const bullet = new Bullet();
bullet.x = player.x + spawnPos.x;
bullet.y = player.y + spawnPos.y;
const bVel = vec2.fromAngle(aimAngle + this.localRecoil, gun.bulletSpeed);
bullet.vx = bVel.x;
bullet.vy = bVel.y;
bullet.angle = aimAngle + this.localRecoil;
bullet.ownerId = this.localPlayerId;
bullet.damage = gun.damage;
bullet.penetration = gun.penetration;
bullet.bulletType = 0;
bullet.isCritical = Math.random() < 0.1;
bullet.size = gun.bulletSize;
bullet.color = gun.bulletColor;
const bId = `b_${this.nextBulletId++}`;
this.state.bullets.set(bId, bullet);
this.bulletLifetimes.set(bId, now);
this.ammo--;
this.lastShotTime = now;
if (this.ammo <= 0) {
this.startReload(player);
}
}
private startReload(player: Player): void {
if (this.isReloading) return;
this.isReloading = true;
this.reloadTimer = GUNS[0]!.reloadTimeMs;
player.isReloading = true;
}
private updateReload(): void {
if (!this.isReloading) return;
this.reloadTimer -= TICK_MS;
if (this.reloadTimer <= 0) {
this.isReloading = false;
this.ammo = GUNS[0]!.ammoMax;
const local = this.state.players.get(this.localPlayerId);
if (local) {
local.ammo = this.ammo;
local.isReloading = false;
}
}
}
// ─── Game Tick ───────────────────────────────────────────────────────────────
private tick(): void {
this.tickCount++;
this.state.tick = this.tickCount;
const dt = TICK_MS / 1000;
// Update bots
this.updateBots(dt);
// Update bullets
this.updateBullets(dt);
// Check bullet-player collisions
this.checkCollisions();
// Update orb lifetimes
this.updateOrbs(dt);
// Regen HP
this.regenHp(dt);
// Rebuild leaderboard
this.rebuildLeaderboard();
this.animTime += dt;
}
private updateBots(dt: number): void {
for (const [botId, botState] of this.bots) {
const bot = this.state.players.get(botId);
if (!bot || !bot.alive) continue;
// Change direction periodically
botState.changeDirTimer -= dt;
if (botState.changeDirTimer <= 0) {
botState.changeDirTimer = randFloat(1, 4);
botState.moveAngle = randFloat(0, Math.PI * 2);
if (Math.random() < 0.3) {
botState.moveAngle = -1; // Stop moving
}
}
// Move
if (botState.moveAngle >= 0) {
const speed = PLAYER_BASE_SPEED * 0.1;
bot.vx += Math.cos(botState.moveAngle) * speed;
bot.vy += Math.sin(botState.moveAngle) * speed;
const spd = Math.sqrt(bot.vx * bot.vx + bot.vy * bot.vy);
if (spd > PLAYER_BASE_SPEED) {
const s = PLAYER_BASE_SPEED / spd;
bot.vx *= s;
bot.vy *= s;
}
}
bot.vx *= PLAYER_FRICTION;
bot.vy *= PLAYER_FRICTION;
bot.x += bot.vx * dt;
bot.y += bot.vy * dt;
bot.x = clamp(bot.x, 0, ARENA_WIDTH);
bot.y = clamp(bot.y, 0, ARENA_HEIGHT);
// Aim at local player
const local = this.state.players.get(this.localPlayerId);
if (local && local.alive) {
botState.aimAngle = Math.atan2(local.y - bot.y, local.x - bot.x);
}
bot.angle = botState.aimAngle;
// Shoot periodically
botState.shootTimer -= dt;
if (botState.shootTimer <= 0) {
botState.shootTimer = randFloat(0.5, 3);
if (local && local.alive && Math.random() < 0.6) {
this.botShoot(botId, bot);
}
}
// Recoil recovery
bot.recoilOffset = lerp(bot.recoilOffset, 0, 0.15);
}
}
private botShoot(botId: string, bot: Player): void {
const gun = GUNS[bot.gunType] ?? GUNS[0]!;
const recoil = randSign() * gun.recoilOffset * 0.5;
bot.recoilOffset = recoil;
const spawnPos = vec2.fromAngle(bot.angle + recoil, GUN_BARREL_LENGTH);
const bullet = new Bullet();
bullet.x = bot.x + spawnPos.x;
bullet.y = bot.y + spawnPos.y;
const bVel = vec2.fromAngle(bot.angle + recoil, gun.bulletSpeed);
bullet.vx = bVel.x;
bullet.vy = bVel.y;
bullet.angle = bot.angle + recoil;
bullet.ownerId = botId;
bullet.damage = gun.damage * 0.3; // Bots do less damage
bullet.penetration = 1;
bullet.bulletType = bot.gunType;
bullet.isCritical = Math.random() < 0.05;
bullet.size = gun.bulletSize;
bullet.color = gun.bulletColor;
const bId = `bb_${this.nextBulletId++}`;
this.state.bullets.set(bId, bullet);
this.bulletLifetimes.set(bId, performance.now());
}
private updateBullets(dt: number): void {
const now = performance.now();
const toRemove: string[] = [];
for (const [id, bullet] of this.state.bullets) {
bullet.x += bullet.vx * dt;
bullet.y += bullet.vy * dt;
const spawnTime = this.bulletLifetimes.get(id) ?? now;
if (now - spawnTime > 3000 || bullet.x < -100 || bullet.x > ARENA_WIDTH + 100 || bullet.y < -100 || bullet.y > ARENA_HEIGHT + 100) {
toRemove.push(id);
}
}
for (const id of toRemove) {
this.state.bullets.delete(id);
this.bulletLifetimes.delete(id);
}
}
private checkCollisions(): void {
const toRemove: string[] = [];
for (const [bId, bullet] of this.state.bullets) {
for (const [pId, player] of this.state.players) {
if (!player.alive) continue;
if (bullet.ownerId === pId) continue;
const dx = bullet.x - player.x;
const dy = bullet.y - player.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < player.radius + bullet.size) {
// Hit!
player.hp -= bullet.damage;
if (bullet.penetration <= 1) {
toRemove.push(bId);
} else {
bullet.penetration--;
}
// Check death
if (player.hp <= 0) {
this.handleDeath(pId, bullet.ownerId);
}
break;
}
}
}
for (const id of toRemove) {
this.state.bullets.delete(id);
this.bulletLifetimes.delete(id);
}
// Player-orb collisions
for (const [oId, orb] of this.state.xpOrbs) {
const local = this.state.players.get(this.localPlayerId);
if (!local || !local.alive) continue;
const dx = local.x - orb.x;
const dy = local.y - orb.y;
if (dx * dx + dy * dy < 900) {
local.xp += orb.value;
this.state.xpOrbs.delete(oId);
this.checkLevelUp(local);
}
}
}
private handleDeath(playerId: string, killerId: string): void {
const player = this.state.players.get(playerId);
if (!player) return;
// Drop XP orbs
const dropXP = Math.floor(player.xp * 0.5);
if (dropXP > 0) {
for (let i = 0; i < 5; i++) {
const orb = new XPOrb();
const angle = randFloat(0, Math.PI * 2);
const dist = randFloat(10, 40);
orb.x = clamp(player.x + Math.cos(angle) * dist, 0, ARENA_WIDTH);
orb.y = clamp(player.y + Math.sin(angle) * dist, 0, ARENA_HEIGHT);
orb.value = Math.floor(dropXP / 5);
orb.lifetime = 30;
this.state.xpOrbs.set(`orb_death_${randInt(0, 1000000)}`, orb);
}
}
// Award kill
const killer = this.state.players.get(killerId);
if (killer && killer.alive) {
killer.score += 1;
killer.xp += Math.floor(player.xp * 0.5);
this.checkLevelUp(killer);
}
if (playerId === this.localPlayerId) {
// Local player respawns after 2s
player.hp = 0;
player.alive = false;
setTimeout(() => {
player.x = ARENA_WIDTH / 2 + randFloat(-200, 200);
player.y = ARENA_HEIGHT / 2 + randFloat(-200, 200);
player.hp = player.maxHp;
player.alive = true;
player.xp = 0;
player.level = 1;
player.xpToNext = XP_LEVELS[1] ?? 50;
player.score = 0;
this.ammo = GUNS[0]!.ammoMax;
player.ammo = this.ammo;
this.localVx = 0;
this.localVy = 0;
player.vx = 0;
player.vy = 0;
}, 2000);
} else {
// Bot respawns immediately elsewhere
player.x = randFloat(100, ARENA_WIDTH - 100);
player.y = randFloat(100, ARENA_HEIGHT - 100);
player.hp = player.maxHp;
player.alive = true;
player.xp = 0;
player.level = 1;
player.xpToNext = XP_LEVELS[1] ?? 50;
}
}
private checkLevelUp(player: Player): void {
while (player.level < 45 && player.xp >= (XP_LEVELS[player.level] ?? Infinity)) {
player.level += 1;
player.upgradePoints += 1;
player.xpToNext = player.level < 45 ? (XP_LEVELS[player.level] ?? 999999) : 999999;
// Heal on level up
player.hp = player.maxHp;
}
}
private updateOrbs(dt: number): void {
const toRemove: string[] = [];
for (const [id, orb] of this.state.xpOrbs) {
orb.lifetime -= dt;
if (orb.lifetime <= 0) {
toRemove.push(id);
}
}
for (const id of toRemove) {
this.state.xpOrbs.delete(id);
}
// Spawn new orbs occasionally
if (this.state.xpOrbs.size < 15 && Math.random() < 0.02) {
this.spawnRandomOrb();
}
}
private regenHp(dt: number): void {
for (const player of this.state.players.values()) {
if (!player.alive) continue;
if (player.hp < player.maxHp) {
player.hp = Math.min(player.maxHp, player.hp + 1 * dt);
}
}
}
private rebuildLeaderboard(): void {
const entries: { name: string; level: number; score: number; xp: number }[] = [];
for (const p of this.state.players.values()) {
entries.push({ name: p.name, level: p.level, score: p.score, xp: p.xp });
}
entries.sort((a, b) => b.xp - a.xp);
this.state.leaderboard.clear();
for (let i = 0; i < Math.min(10, entries.length); i++) {
const e = entries[i]!;
const entry = new LeaderboardEntry();
entry.name = e.name;
entry.level = e.level;
entry.score = e.score;
entry.xp = e.xp;
this.state.leaderboard.push(entry);
}
}
applyUpgrade(choice: number): void {
const local = this.state.players.get(this.localPlayerId);
if (!local || local.upgradePoints <= 0) return;
if (choice < 0 || choice >= 9) return;
if ((local.stats[choice] ?? 0) >= 7) return;
local.stats[choice] = (local.stats[choice] ?? 0) + 1;
local.upgradePoints -= 1;
if (choice === 0) { // MaxHP
local.maxHp += 20;
local.hp += 20;
}
}
onUpgrade(callback: (choice: number) => void): void {
this.upgradeCallback = callback;
}
}
+397
View File
@@ -0,0 +1,397 @@
// ═══════════════════════════════════════════════════════════════════════════════
// 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;
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"outDir": "./dist",
"rootDir": "../..",
"jsx": "preserve",
"skipLibCheck": true,
"noEmit": true
},
"include": ["./src", "../shared/src"],
"exclude": ["node_modules", "dist"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from 'vite';
import path from 'path';
export default defineConfig({
root: '.',
server: {
port: 5173,
proxy: {
'/ws': {
target: 'ws://localhost:3000',
ws: true,
},
},
},
build: {
outDir: './dist',
emptyOutDir: true,
target: 'es2020',
},
esbuild: {
target: 'es2020',
},
resolve: {
alias: {
'@guncircle/shared': path.resolve(__dirname, '../shared/src'),
},
},
});
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/server/package.json packages/server/
RUN pnpm install --frozen-lockfile
COPY packages/shared/ packages/shared/
COPY packages/server/ packages/server/
RUN pnpm --filter @guncircle/shared build
RUN pnpm --filter @guncircle/server build
FROM node:22-alpine
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY pnpm-workspace.yaml package.json ./
COPY --from=builder /app/packages/shared/dist packages/shared/dist
COPY --from=builder /app/packages/shared/package.json packages/shared/
COPY --from=builder /app/packages/server/dist packages/server/dist
COPY --from=builder /app/packages/server/package.json packages/server/
COPY --from=builder /app/packages/server/config packages/server/config
RUN pnpm install --prod --frozen-lockfile
EXPOSE 3000
CMD ["node", "packages/server/dist/index.js"]
+175
View File
@@ -0,0 +1,175 @@
{
"branches": [
{
"id": "fighter",
"name": "Fighter",
"description": "Balanced combatant. +10% Max HP, +5% Bullet Damage.",
"levelRequired": 5,
"parentId": null,
"unlocksCategories": [0, 1],
"passiveBonuses": { "0": 0.10, "3": 0.05 }
},
{
"id": "shotgunner",
"name": "Shotgunner",
"description": "Close-range powerhouse. Unlocks shotguns. +15% Max HP, -10% Recoil.",
"levelRequired": 5,
"parentId": null,
"unlocksCategories": [2],
"passiveBonuses": { "0": 0.15, "6": 0.10 }
},
{
"id": "scout",
"name": "Scout",
"description": "Fast and evasive. +15% Movement Speed, +5% Reload Speed.",
"levelRequired": 5,
"parentId": null,
"unlocksCategories": [4],
"passiveBonuses": { "2": 0.15, "5": 0.05 }
},
{
"id": "soldier",
"name": "Soldier",
"description": "Veteran warrior. +15% Bullet Damage, +10% Bullet Speed.",
"levelRequired": 10,
"parentId": "fighter",
"unlocksCategories": [1],
"passiveBonuses": { "3": 0.15, "4": 0.10 }
},
{
"id": "bruiser",
"name": "Bruiser",
"description": "Tough tank. +20% Max HP, +10% HP Regen.",
"levelRequired": 10,
"parentId": "fighter",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.20, "1": 0.10 }
},
{
"id": "blaster",
"name": "Blaster",
"description": "Explosive force. +20% Bullet Damage, +10% Crit Chance.",
"levelRequired": 15,
"parentId": "soldier",
"unlocksCategories": [],
"passiveBonuses": { "3": 0.20, "7": 0.10 }
},
{
"id": "slayer",
"name": "Slayer",
"description": "Ultimate hunter. +25% Bullet Damage, +15% Crit Damage.",
"levelRequired": 30,
"parentId": "blaster",
"unlocksCategories": [],
"passiveBonuses": { "3": 0.25, "8": 0.15 }
},
{
"id": "destroyer",
"name": "Destroyer",
"description": "Peak power. +20% all damage stats, +10% Max HP.",
"levelRequired": 45,
"parentId": "slayer",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.10, "3": 0.20, "7": 0.10, "8": 0.10 }
},
{
"id": "charger",
"name": "Charger",
"description": "Bull rush expert. +15% Movement Speed, +10% Bullet Damage.",
"levelRequired": 15,
"parentId": "bruiser",
"unlocksCategories": [],
"passiveBonuses": { "2": 0.15, "3": 0.10 }
},
{
"id": "juggernaut",
"name": "Juggernaut",
"description": "Unstoppable. +25% Max HP, +15% HP Regen.",
"levelRequired": 30,
"parentId": "charger",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.25, "1": 0.15 }
},
{
"id": "colossus",
"name": "Colossus",
"description": "Living fortress. +30% Max HP, +20% HP Regen, +10% Damage.",
"levelRequired": 45,
"parentId": "juggernaut",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.30, "1": 0.20, "3": 0.10 }
},
{
"id": "scatter",
"name": "Scatter",
"description": "Spread shot master. +15% Reload Speed, -15% Recoil.",
"levelRequired": 10,
"parentId": "shotgunner",
"unlocksCategories": [2],
"passiveBonuses": { "5": 0.15, "6": 0.15 }
},
{
"id": "breaker",
"name": "Breaker",
"description": "Armor breaker. +20% Bullet Damage, +10% Bullet Speed.",
"levelRequired": 15,
"parentId": "scatter",
"unlocksCategories": [],
"passiveBonuses": { "3": 0.20, "4": 0.10 }
},
{
"id": "havoc",
"name": "Havoc",
"description": "Chaos incarnate. +25% Reload Speed, +15% Movement Speed.",
"levelRequired": 30,
"parentId": "breaker",
"unlocksCategories": [],
"passiveBonuses": { "2": 0.15, "5": 0.25 }
},
{
"id": "annihilator",
"name": "Annihilator",
"description": "Total devastation. +20% all stats.",
"levelRequired": 45,
"parentId": "havoc",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.10, "2": 0.10, "3": 0.15, "5": 0.15 }
},
{
"id": "runner",
"name": "Runner",
"description": "Speed demon. +20% Movement Speed, +10% Reload Speed.",
"levelRequired": 10,
"parentId": "scout",
"unlocksCategories": [4],
"passiveBonuses": { "2": 0.20, "5": 0.10 }
},
{
"id": "stalker",
"name": "Stalker",
"description": "Stealth marksman. +15% Bullet Speed, +10% Crit Chance.",
"levelRequired": 15,
"parentId": "runner",
"unlocksCategories": [3],
"passiveBonuses": { "4": 0.15, "7": 0.10 }
},
{
"id": "phantom",
"name": "Phantom",
"description": "Ghost warrior. +20% Movement Speed, +15% Crit Damage.",
"levelRequired": 30,
"parentId": "stalker",
"unlocksCategories": [],
"passiveBonuses": { "2": 0.20, "8": 0.15 }
},
{
"id": "assassin",
"name": "Assassin",
"description": "Silent death. +25% Crit Chance, +25% Crit Damage.",
"levelRequired": 45,
"parentId": "phantom",
"unlocksCategories": [],
"passiveBonuses": { "7": 0.25, "8": 0.25 }
}
]
}
+99
View File
@@ -0,0 +1,99 @@
{
"guns": [
{
"id": "pistol",
"name": "Pistol",
"category": 0,
"damage": 12,
"fireRate": 4,
"bulletSpeed": 400,
"pelletCount": 1,
"spread": 0,
"recoilOffset": 0.06,
"recoilRecoveryMs": 120,
"kickbackForce": 30,
"ammoMax": 12,
"reloadTimeMs": 1500,
"bulletSize": 4,
"penetration": 1,
"bulletLifetimeMs": 2000,
"bulletColor": "#f1c40f"
},
{
"id": "rifle",
"name": "Rifle",
"category": 1,
"damage": 18,
"fireRate": 3,
"bulletSpeed": 500,
"pelletCount": 1,
"spread": 0.02,
"recoilOffset": 0.08,
"recoilRecoveryMs": 180,
"kickbackForce": 45,
"ammoMax": 8,
"reloadTimeMs": 2000,
"bulletSize": 4.5,
"penetration": 2,
"bulletLifetimeMs": 2500,
"bulletColor": "#e67e22"
},
{
"id": "shotgun",
"name": "Shotgun",
"category": 2,
"damage": 8,
"fireRate": 1.2,
"bulletSpeed": 320,
"pelletCount": 5,
"spread": 0.25,
"recoilOffset": 0.15,
"recoilRecoveryMs": 300,
"kickbackForce": 80,
"ammoMax": 5,
"reloadTimeMs": 2500,
"bulletSize": 3,
"penetration": 1,
"bulletLifetimeMs": 1500,
"bulletColor": "#9b59b6"
},
{
"id": "sniper",
"name": "Sniper",
"category": 3,
"damage": 45,
"fireRate": 0.8,
"bulletSpeed": 700,
"pelletCount": 1,
"spread": 0,
"recoilOffset": 0.2,
"recoilRecoveryMs": 500,
"kickbackForce": 120,
"ammoMax": 3,
"reloadTimeMs": 3000,
"bulletSize": 6,
"penetration": 3,
"bulletLifetimeMs": 3500,
"bulletColor": "#e74c3c"
},
{
"id": "smg",
"name": "SMG",
"category": 4,
"damage": 6,
"fireRate": 10,
"bulletSpeed": 380,
"pelletCount": 1,
"spread": 0.06,
"recoilOffset": 0.04,
"recoilRecoveryMs": 80,
"kickbackForce": 20,
"ammoMax": 30,
"reloadTimeMs": 1800,
"bulletSize": 3,
"penetration": 1,
"bulletLifetimeMs": 1800,
"bulletColor": "#2ecc71"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@guncircle/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"type-check": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@colyseus/core": "^0.16.0",
"@colyseus/schema": "^3.0.0",
"@colyseus/uwebsockets-transport": "^0.16.0",
"@guncircle/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.6.0",
"vitest": "^2.0.0"
}
}
+241
View File
@@ -0,0 +1,241 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Branch (Class) System
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import {
type ClassBranch,
type Player,
STAT_COUNT,
Stat,
STAT_BONUSES,
STAT_MAX_LEVEL,
} from '@guncircle/shared';
// ─── Branch Config Container ─────────────────────────────────────────────────
interface BranchesJson {
branches: ClassBranch[];
}
// ─── BranchSystem ────────────────────────────────────────────────────────────
export class BranchSystem {
private branches: ClassBranch[] = [];
constructor() {
this.loadConfigs();
}
private loadConfigs(): void {
try {
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, '../config/branches.json');
const raw = readFileSync(configPath, 'utf-8');
const parsed: BranchesJson = JSON.parse(raw) as BranchesJson;
this.branches = parsed.branches ?? [];
} catch {
this.branches = [];
}
}
/** Total branches loaded */
getBranchCount(): number {
return this.branches.length;
}
/** Get branch by string id */
getBranchById(id: string): ClassBranch | undefined {
return this.branches.find((b) => b.id === id);
}
/** Get branch by 0-based index (used for classType field) */
getBranchByIndex(index: number): ClassBranch | undefined {
if (index < 0 || index >= this.branches.length) return undefined;
return this.branches[index];
}
/** Get the index of a branch by its string id */
getBranchIndexById(id: string): number {
return this.branches.findIndex((b) => b.id === id);
}
/** Get all loaded branches */
getAllBranches(): readonly ClassBranch[] {
return this.branches;
}
/**
* Get branches available to a player at their current level.
* Filters by: levelRequired <= player level, parent matches current branch path.
*/
getAvailableBranches(level: number, currentBranchId: string | null): ClassBranch[] {
return this.branches.filter((branch) => {
// Must meet level requirement
if (branch.levelRequired > level) return false;
// Root branches (parentId === null) are always available at their level
if (branch.parentId === null) {
// Only show root branches if player hasn't chosen a branch yet
// or if they're at the exact milestone level
return currentBranchId === null || this.isDirectRootChoice(branch, currentBranchId);
}
// Non-root branches require parent to be in the player's branch path
return this.isInBranchPath(branch.parentId, currentBranchId);
});
}
/**
* Check if a branch is a direct root choice for a player.
* A root branch is a direct choice if the player has no branch yet.
*/
private isDirectRootChoice(rootBranch: ClassBranch, currentBranchId: string | null): boolean {
if (currentBranchId === null) return true;
// If player has a branch, root branches are not direct choices anymore
return false;
}
/**
* Check if `ancestorId` is in the branch path leading to `descendantId`.
* This includes the descendant itself.
*/
isInBranchPath(ancestorId: string, descendantId: string | null): boolean {
if (descendantId === null) return false;
if (ancestorId === descendantId) return true;
let current: ClassBranch | undefined = this.getBranchById(descendantId);
while (current !== undefined && current.parentId !== null) {
if (current.parentId === ancestorId) return true;
current = this.getBranchById(current.parentId);
}
return false;
}
/**
* Get the root branch id in a player's branch path.
* Walks up the parent chain to find the top-level ancestor.
*/
getRootBranchId(branchId: string | null): string | null {
if (branchId === null) return null;
let current: ClassBranch | undefined = this.getBranchById(branchId);
while (current !== undefined && current.parentId !== null) {
current = this.getBranchById(current.parentId);
}
return current?.id ?? null;
}
/**
* Get all branch IDs in the path from root to the given branch.
*/
getBranchPath(branchId: string | null): string[] {
const path: string[] = [];
if (branchId === null) return path;
// Walk up to root first
const ancestors: string[] = [];
let current: ClassBranch | undefined = this.getBranchById(branchId);
while (current !== undefined) {
ancestors.unshift(current.id);
if (current.parentId === null) break;
current = this.getBranchById(current.parentId);
}
return ancestors;
}
/**
* Apply passive stat bonuses from a branch to a player's stat levels.
* This returns the total passive bonus for each stat from the branch tree.
*/
calculateBranchBonuses(player: Player): Record<Stat, number> {
const bonuses: Record<Stat, number> = {
[Stat.MaxHp]: 0,
[Stat.HpRegen]: 0,
[Stat.MovementSpeed]: 0,
[Stat.BulletDamage]: 0,
[Stat.BulletSpeed]: 0,
[Stat.ReloadSpeed]: 0,
[Stat.RecoilStability]: 0,
[Stat.CritChance]: 0,
[Stat.CritDamage]: 0,
};
// Get the branch path and sum all passive bonuses
const branchId: string | null =
player.classType > 0 ? this.getBranchByIndex(player.classType)?.id ?? null : null;
if (branchId === null) return bonuses;
const path: string[] = this.getBranchPath(branchId);
for (const bid of path) {
const branch: ClassBranch | undefined = this.getBranchById(bid);
if (branch === undefined) continue;
for (const [statKeyStr, value] of Object.entries(branch.passiveBonuses)) {
const statIdx: number = parseInt(statKeyStr, 10);
if (statIdx >= 0 && statIdx < STAT_COUNT && value !== undefined) {
bonuses[statIdx as Stat] += value;
}
}
}
return bonuses;
}
/**
* Get the effective stat value for a player, including branch bonuses.
* Returns the player's invested stat level + branch passive bonus.
*/
getEffectiveStatLevel(player: Player, stat: Stat): number {
const investedLevel: number = player.stats[stat] ?? 0;
const bonuses: Record<Stat, number> = this.calculateBranchBonuses(player);
return investedLevel + bonuses[stat];
}
/**
* Apply branch selection to a player. Sets their classType index.
*/
applyBranch(player: Player, branchId: string): boolean {
const branchIdx: number = this.getBranchIndexById(branchId);
if (branchIdx < 0) return false;
player.classType = branchIdx;
return true;
}
/**
* Get all gun category indices unlocked by a player's branch path.
*/
getUnlockedGunCategories(player: Player): number[] {
const branchId: string | null =
player.classType > 0 ? this.getBranchByIndex(player.classType)?.id ?? null : null;
if (branchId === null) return [0]; // Default: only pistols
const path: string[] = this.getBranchPath(branchId);
const categories: Set<number> = new Set<number>();
// Always include pistol
categories.add(0);
for (const bid of path) {
const branch: ClassBranch | undefined = this.getBranchById(bid);
if (branch === undefined) continue;
for (const cat of branch.unlocksCategories) {
categories.add(cat);
}
}
return Array.from(categories).sort((a, b) => a - b);
}
/**
* Check if a gun category is unlocked for a player.
*/
isGunCategoryUnlocked(player: Player, category: number): boolean {
const unlocked: number[] = this.getUnlockedGunCategories(player);
return unlocked.includes(category);
}
}
+306
View File
@@ -0,0 +1,306 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Collision System
// ═══════════════════════════════════════════════════════════════════════════════
import {
type Player,
type Bullet,
type XPOrb,
type Obstacle,
type RoomState,
SpatialHashGrid,
circleCollisionResolve,
vec2,
type Vec2,
SPATIAL_CELL_SIZE,
ARENA_WIDTH,
ARENA_HEIGHT,
PLAYER_BASE_RADIUS,
} from '@guncircle/shared';
// ─── Identifiable Entity for Spatial Hash ────────────────────────────────────
interface SpatialEntity {
id: number;
x: number;
y: number;
}
// ─── CollisionCallbacks ──────────────────────────────────────────────────────
export interface BulletHitCallback {
(bullet: Bullet, bulletId: string, victim: Player, victimId: string): void;
}
export interface OrbCollectCallback {
(player: Player, playerId: string, orb: XPOrb, orbId: string): void;
}
export interface ObstacleCollideCallback {
(player: Player, playerId: string, obstacle: Obstacle): void;
}
// ─── CollisionSystem ─────────────────────────────────────────────────────────
export class CollisionSystem {
private bulletGrid: SpatialHashGrid<SpatialEntity>;
private playerGrid: SpatialHashGrid<SpatialEntity>;
constructor() {
this.bulletGrid = new SpatialHashGrid<SpatialEntity>(SPATIAL_CELL_SIZE);
this.playerGrid = new SpatialHashGrid<SpatialEntity>(SPATIAL_CELL_SIZE);
}
// ─── Grid Building ─────────────────────────────────────────────────────────
/** Rebuild the spatial hash grid for bullets. */
buildBulletGrid(bullets: Map<string, Bullet>): void {
this.bulletGrid.clear();
let idx = 0;
for (const [id, bullet] of bullets.entries()) {
const entity: SpatialEntity = {
id: idx++,
x: bullet.x,
y: bullet.y,
};
this.bulletGrid.insert(entity);
}
}
/** Rebuild the spatial hash grid for players. */
buildPlayerGrid(players: Map<string, Player>): void {
this.playerGrid.clear();
let idx = 0;
for (const [id, player] of players.entries()) {
if (!player.alive) continue;
const entity: SpatialEntity = {
id: idx++,
x: player.x,
y: player.y,
};
this.playerGrid.insert(entity);
}
}
// ─── Bullet vs Player ──────────────────────────────────────────────────────
/**
* Check all bullet-player collisions. For each bullet, query nearby players.
* Excludes the bullet's owner. Calls onHit for each valid collision.
*/
checkBulletPlayerCollisions(
bullets: Map<string, Bullet>,
players: Map<string, Player>,
onHit: BulletHitCallback
): void {
for (const [bulletId, bullet] of bullets.entries()) {
// Skip bullets with no penetration left
if (bullet.penetration <= 0) continue;
for (const [playerId, player] of players.entries()) {
// Don't hit the owner
if (bullet.ownerId === playerId) continue;
if (!player.alive) continue;
// Quick distance check
const dx: number = bullet.x - player.x;
const dy: number = bullet.y - player.y;
const r: number = bullet.size + player.radius;
const distSq: number = dx * dx + dy * dy;
if (distSq < r * r) {
onHit(bullet, bulletId, player, playerId);
if (bullet.penetration <= 0) break;
}
}
}
}
// ─── Player vs XP Orb ──────────────────────────────────────────────────────
/**
* Check all player-orb collisions. Pickup radius is ~30 units.
*/
checkPlayerOrbCollisions(
players: Map<string, Player>,
orbs: Map<string, XPOrb>,
onCollect: OrbCollectCallback
): void {
const PICKUP_RADIUS: number = 30;
const PICKUP_RADIUS_SQ: number = PICKUP_RADIUS * PICKUP_RADIUS;
for (const [playerId, player] of players.entries()) {
if (!player.alive) continue;
for (const [orbId, orb] of orbs.entries()) {
const dx: number = player.x - orb.x;
const dy: number = player.y - orb.y;
const distSq: number = dx * dx + dy * dy;
if (distSq < PICKUP_RADIUS_SQ) {
onCollect(player, playerId, orb, orbId);
}
}
}
}
// ─── Player vs Obstacle ────────────────────────────────────────────────────
/**
* Check all player-obstacle collisions. Resolve overlap by pushing player out.
* Obstacles are treated as AABB (axis-aligned bounding boxes) with circle overlap.
*/
checkPlayerObstacleCollisions(
players: Map<string, Player>,
obstacles: Map<string, Obstacle>,
onCollide?: ObstacleCollideCallback
): void {
for (const [playerId, player] of players.entries()) {
if (!player.alive) continue;
for (const [obsId, obstacle] of obstacles.entries()) {
this.resolvePlayerObstacle(player, obstacle, onCollide, playerId);
}
}
}
/**
* Resolve a single player-obstacle collision. Obstacle is AABB.
*/
private resolvePlayerObstacle(
player: Player,
obstacle: Obstacle,
onCollide: ObstacleCollideCallback | undefined,
playerId: string
): void {
// Find closest point on obstacle rectangle to player center
const closestX: number = Math.max(
obstacle.x,
Math.min(obstacle.x + obstacle.width, player.x)
);
const closestY: number = Math.max(
obstacle.y,
Math.min(obstacle.y + obstacle.height, player.y)
);
const dx: number = player.x - closestX;
const dy: number = player.y - closestY;
const distSq: number = dx * dx + dy * dy;
if (distSq < player.radius * player.radius) {
const dist: number = Math.sqrt(distSq);
const overlap: number = player.radius - dist;
if (dist === 0) {
// Player center is inside obstacle — push toward nearest edge
const leftDist: number = Math.abs(player.x - obstacle.x);
const rightDist: number = Math.abs(player.x - (obstacle.x + obstacle.width));
const topDist: number = Math.abs(player.y - obstacle.y);
const bottomDist: number = Math.abs(player.y - (obstacle.y + obstacle.height));
const minDist: number = Math.min(leftDist, rightDist, topDist, bottomDist);
if (minDist === leftDist) {
player.x -= overlap;
} else if (minDist === rightDist) {
player.x += overlap;
} else if (minDist === topDist) {
player.y -= overlap;
} else {
player.y += overlap;
}
} else {
const nx: number = dx / dist;
const ny: number = dy / dist;
player.x += nx * overlap;
player.y += ny * overlap;
}
onCollide?.(player, playerId, obstacle);
}
}
// ─── Player vs Player ──────────────────────────────────────────────────────
/**
* Check all player-player collisions. Apply soft push apart.
* Uses O(n^2) for small player counts; spatial hash can be used for optimization.
*/
checkPlayerPlayerCollisions(players: Map<string, Player>): void {
const playerList: { id: string; player: Player }[] = [];
for (const [id, player] of players.entries()) {
if (player.alive) {
playerList.push({ id, player });
}
}
for (let i = 0; i < playerList.length; i++) {
for (let j = i + 1; j < playerList.length; j++) {
const a = playerList[i].player;
const b = playerList[j].player;
const dx: number = a.x - b.x;
const dy: number = a.y - b.y;
const distSq: number = dx * dx + dy * dy;
const minDist: number = a.radius + b.radius;
if (distSq < minDist * minDist && distSq > 0) {
const dist: number = Math.sqrt(distSq);
const overlap: number = minDist - dist;
const nx: number = dx / dist;
const ny: number = dy / dist;
// Soft push — each moves half the overlap
const pushX: number = nx * overlap * 0.5;
const pushY: number = ny * overlap * 0.5;
a.x += pushX;
a.y += pushY;
b.x -= pushX;
b.y -= pushY;
}
}
}
}
// ─── Arena Bounds ──────────────────────────────────────────────────────────
/**
* Clamp all alive players to arena bounds.
*/
checkArenaBounds(players: Map<string, Player>): void {
for (const player of players.values()) {
if (!player.alive) continue;
const minX: number = player.radius;
const minY: number = player.radius;
const maxX: number = ARENA_WIDTH - player.radius;
const maxY: number = ARENA_HEIGHT - player.radius;
if (player.x < minX) player.x = minX;
if (player.x > maxX) player.x = maxX;
if (player.y < minY) player.y = minY;
if (player.y > maxY) player.y = maxY;
}
}
/**
* Clamp bullets to arena bounds. Returns set of bullet IDs that are out of bounds.
*/
checkBulletBounds(bullets: Map<string, Bullet>): Set<string> {
const outOfBounds: Set<string> = new Set<string>();
for (const [id, bullet] of bullets.entries()) {
if (
bullet.x < -bullet.size ||
bullet.x > ARENA_WIDTH + bullet.size ||
bullet.y < -bullet.size ||
bullet.y > ARENA_HEIGHT + bullet.size
) {
outOfBounds.add(id);
}
}
return outOfBounds;
}
}
+417
View File
@@ -0,0 +1,417 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Game Loop
// ═══════════════════════════════════════════════════════════════════════════════
import {
RoomState,
Player,
Bullet,
XPOrb,
LeaderboardEntry,
type PlayerInput,
TICK_MS,
PLAYER_FRICTION,
} from '@guncircle/shared';
import { PlayerManager } from './player-manager.js';
import { CollisionSystem } from './collision.js';
import { PhysicsEngine } from './physics.js';
import { GunSystem } from './gun-system.js';
import { BranchSystem } from './branch-system.js';
// ─── Pending Input per Player ────────────────────────────────────────────────
interface PendingInput {
input: PlayerInput;
playerId: string;
}
// ─── GameLoop ────────────────────────────────────────────────────────────────
export class GameLoop {
private state: RoomState;
private playerManager: PlayerManager;
private collisionSystem: CollisionSystem;
private physics: PhysicsEngine;
private gunSystem: GunSystem;
private branchSystem: BranchSystem;
private pendingInputs: PendingInput[] = [];
private bulletLifetimes: Map<string, number> = new Map();
private startTime: number;
private tickCount: number = 0;
// Callbacks for room to handle player events
onPlayerDeath: ((playerId: string, killerId?: string) => void) | undefined;
onPlayerLevelUp: ((playerId: string) => void) | undefined;
constructor(
state: RoomState,
playerManager: PlayerManager,
collisionSystem: CollisionSystem,
physics: PhysicsEngine,
gunSystem: GunSystem,
branchSystem: BranchSystem
) {
this.state = state;
this.playerManager = playerManager;
this.collisionSystem = collisionSystem;
this.physics = physics;
this.gunSystem = gunSystem;
this.branchSystem = branchSystem;
this.startTime = Date.now();
}
/**
* Queue an input to be processed on the next tick.
*/
queueInput(playerId: string, input: PlayerInput): void {
this.pendingInputs.push({ playerId, input });
}
/**
* The core tick function. Called every TICK_MS.
* Order of operations is critical for deterministic simulation.
*/
tick(): void {
this.tickCount++;
const now: number = Date.now();
const dt: number = TICK_MS / 1000; // seconds
const dtMs: number = TICK_MS; // milliseconds
// 1. Increment tick counter
this.state.tick = this.tickCount;
// 2. Process pending inputs
this.processInputs(now, dt);
// 3. Update bullet positions
this.updateBullets(dt);
// 4. Update player positions (apply velocity, friction, clamp)
this.updatePlayers(dt);
// 5. Check all collisions
this.checkCollisions();
// 6. Apply recoil recovery
this.updateRecoil(dt);
// 7. Update reload timers, auto-reload when empty
this.playerManager.updateReloads(this.state.players, dtMs);
this.checkAutoReload();
// 8. Regenerate HP for alive players
this.regenerateHp(dt);
// 9. Update XP orb lifetimes, remove expired
this.updateXpOrbs(dt);
// 10. Process pending respawns
this.playerManager.processRespawns(this.state);
// 11. Rebuild leaderboard (top 10 by XP)
this.rebuildLeaderboard();
// 12. Remove expired bullets (lifetime exceeded)
this.removeExpiredBullets(now);
// Clean up processed inputs
this.pendingInputs = [];
}
// ─── Input Processing ──────────────────────────────────────────────────────
private processInputs(now: number, dt: number): void {
for (const pending of this.pendingInputs) {
const player: Player | undefined = this.state.players.get(pending.playerId);
if (player === undefined) continue;
if (!player.alive) continue;
this.playerManager.applyInput(
player,
pending.input,
pending.playerId,
this.state,
now,
dt
);
// Handle upgrade choices
if (pending.input.upgradeChoice !== undefined && player.upgradePoints > 0) {
const success: boolean = this.playerManager.applyUpgrade(
player,
pending.input.upgradeChoice
);
if (success && this.onPlayerLevelUp !== undefined) {
this.onPlayerLevelUp(pending.playerId);
}
}
}
}
// ─── Bullet Update ─────────────────────────────────────────────────────────
private updateBullets(dt: number): void {
for (const [bulletId, bullet] of this.state.bullets.entries()) {
// Update position
bullet.x += bullet.vx * dt;
bullet.y += bullet.vy * dt;
}
}
// ─── Player Update ─────────────────────────────────────────────────────────
private updatePlayers(dt: number): void {
for (const player of this.state.players.values()) {
if (!player.alive) continue;
// Apply friction to velocity
this.physics.applyFrictionToPlayer(player, PLAYER_FRICTION);
// Update position
this.physics.updatePosition(player, dt);
// Clamp to arena
this.physics.clampToArena(player);
}
}
// ─── Collision Checks ──────────────────────────────────────────────────────
private checkCollisions(): void {
// Bullet vs Player
this.collisionSystem.checkBulletPlayerCollisions(
this.state.bullets,
this.state.players,
(bullet, bulletId, victim, victimId) => {
this.handleBulletHit(bullet, bulletId, victim, victimId);
}
);
// Player vs XP Orb
this.collisionSystem.checkPlayerOrbCollisions(
this.state.players,
this.state.xpOrbs,
(player, playerId, orb, orbId) => {
this.handleOrbCollect(player, playerId, orb, orbId);
}
);
// Player vs Obstacle
this.collisionSystem.checkPlayerObstacleCollisions(
this.state.players,
this.state.obstacles
);
// Player vs Player (soft push)
this.collisionSystem.checkPlayerPlayerCollisions(this.state.players);
// Arena bounds
this.collisionSystem.checkArenaBounds(this.state.players);
// Bullet out of bounds
const outOfBounds: Set<string> =
this.collisionSystem.checkBulletBounds(this.state.bullets);
for (const id of outOfBounds) {
this.state.bullets.delete(id);
this.bulletLifetimes.delete(id);
}
}
// ─── Bullet Hit Handling ───────────────────────────────────────────────────
private handleBulletHit(
bullet: Bullet,
bulletId: string,
victim: Player,
victimId: string
): void {
if (!victim.alive) return;
if (bullet.penetration <= 0) return;
// Apply damage
const died: boolean = this.playerManager.takeDamage(
victim,
bullet.damage,
bullet.isCritical
);
// Reduce bullet penetration
bullet.penetration -= 1;
// Remove bullet if no penetration left
if (bullet.penetration <= 0) {
this.state.bullets.delete(bulletId);
this.bulletLifetimes.delete(bulletId);
}
// Check death
if (died) {
this.playerManager.onDeath(victim, victimId, this.state, bullet.ownerId);
if (this.onPlayerDeath !== undefined) {
this.onPlayerDeath(victimId, bullet.ownerId);
}
}
}
// ─── XP Orb Collection ─────────────────────────────────────────────────────
private handleOrbCollect(
player: Player,
playerId: string,
orb: XPOrb,
orbId: string
): void {
if (!player.alive) return;
player.xp += orb.value;
this.state.xpOrbs.delete(orbId);
// Check level up
const leveledUp: boolean = this.playerManager.checkLevelUp(player);
if (leveledUp && this.onPlayerLevelUp !== undefined) {
this.onPlayerLevelUp(playerId);
}
}
// ─── Recoil Recovery ───────────────────────────────────────────────────────
private updateRecoil(dt: number): void {
for (const player of this.state.players.values()) {
if (!player.alive) continue;
if (player.recoilOffset === 0) continue;
const gun = this.gunSystem.getGunConfig(player.gunType);
if (gun === undefined) continue;
this.physics.applyRecoilRecovery(player, dt, gun.recoilRecoveryMs);
}
}
// ─── Auto Reload ───────────────────────────────────────────────────────────
private checkAutoReload(): void {
for (const [playerId, player] of this.state.players.entries()) {
if (!player.alive) continue;
if (player.isReloading) continue;
if (player.ammo <= 0) {
this.playerManager.startReload(player, playerId);
}
}
}
// ─── HP Regeneration ───────────────────────────────────────────────────────
private regenerateHp(dt: number): void {
for (const player of this.state.players.values()) {
if (!player.alive) continue;
if (player.hp >= player.maxHp) continue;
const regenPerSec: number = this.playerManager.calculateHpRegen(player);
player.hp = Math.min(player.maxHp, player.hp + regenPerSec * dt);
}
}
// ─── XP Orb Lifetime ───────────────────────────────────────────────────────
private updateXpOrbs(dt: number): void {
const toRemove: string[] = [];
for (const [orbId, orb] of this.state.xpOrbs.entries()) {
orb.lifetime -= dt;
if (orb.lifetime <= 0) {
toRemove.push(orbId);
}
}
for (const id of toRemove) {
this.state.xpOrbs.delete(id);
}
}
// ─── Leaderboard ───────────────────────────────────────────────────────────
private rebuildLeaderboard(): void {
// Collect all players
const entries: { name: string; level: number; score: number; xp: number }[] = [];
for (const player of this.state.players.values()) {
entries.push({
name: player.name,
level: player.level,
score: player.score,
xp: player.xp,
});
}
// Sort by XP descending
entries.sort((a, b) => b.xp - a.xp);
// Take top 10
const topN: number = Math.min(10, entries.length);
// Clear and rebuild
this.state.leaderboard.clear();
for (let i = 0; i < topN; i++) {
const entry = entries[i];
const lbEntry: LeaderboardEntry = new LeaderboardEntry();
lbEntry.name = entry.name;
lbEntry.level = entry.level;
lbEntry.score = entry.score;
lbEntry.xp = entry.xp;
this.state.leaderboard.push(lbEntry);
}
}
// ─── Expired Bullet Cleanup ────────────────────────────────────────────────
private removeExpiredBullets(now: number): void {
const toRemove: string[] = [];
for (const [bulletId, bullet] of this.state.bullets.entries()) {
const gun = this.gunSystem.getGunConfig(bullet.bulletType);
if (gun === undefined) continue;
// Track bullet spawn time via lifetime map
let spawnTime: number | undefined = this.bulletLifetimes.get(bulletId);
if (spawnTime === undefined) {
spawnTime = now;
this.bulletLifetimes.set(bulletId, spawnTime);
}
const elapsed: number = now - spawnTime;
if (elapsed > gun.bulletLifetimeMs) {
toRemove.push(bulletId);
}
}
for (const id of toRemove) {
this.state.bullets.delete(id);
this.bulletLifetimes.delete(id);
}
}
// ─── Utility ───────────────────────────────────────────────────────────────
/**
* Get current tick number.
*/
getTick(): number {
return this.tickCount;
}
/**
* Get elapsed time since game loop started.
*/
getElapsedTime(): number {
return Date.now() - this.startTime;
}
/**
* Clean up all resources.
*/
destroy(): void {
this.pendingInputs = [];
this.bulletLifetimes.clear();
}
}
+162
View File
@@ -0,0 +1,162 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Gun System
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import {
vec2,
type Vec2,
type GunConfig,
Bullet,
GUN_BARREL_LENGTH,
STAT_COUNT,
Stat,
randSign,
} from '@guncircle/shared';
// ─── Gun Config Container ────────────────────────────────────────────────────
interface GunsJson {
guns: GunConfig[];
}
// ─── GunSystem ───────────────────────────────────────────────────────────────
export class GunSystem {
private guns: GunConfig[] = [];
constructor() {
this.loadConfigs();
}
private loadConfigs(): void {
try {
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, '../config/guns.json');
const raw = readFileSync(configPath, 'utf-8');
const parsed: GunsJson = JSON.parse(raw) as GunsJson;
this.guns = parsed.guns ?? [];
} catch {
this.guns = [];
}
}
/** Number of gun configs loaded */
getGunCount(): number {
return this.guns.length;
}
/** Get gun config by 0-based index (used for gunType field) */
getGunConfig(index: number): GunConfig | undefined {
if (index < 0 || index >= this.guns.length) return undefined;
return this.guns[index];
}
/** Get gun config by string id */
getGunById(id: string): GunConfig | undefined {
return this.guns.find((g) => g.id === id);
}
/** Get the index of a gun by its string id */
getGunIndexById(id: string): number {
return this.guns.findIndex((g) => g.id === id);
}
/** Get all loaded gun configs */
getAllGuns(): readonly GunConfig[] {
return this.guns;
}
/**
* Calculate recoil offset when firing.
* Returns signed offset: ±gun.recoilOffset * (1 - recoilStabilityStat * 0.1)
*/
calculateRecoil(gun: GunConfig, recoilStabilityStat: number): number {
const stabilityFactor: number = 1 - recoilStabilityStat * 0.1;
const clampedStability: number = Math.max(0, stabilityFactor);
const signedOffset: number = randSign() * gun.recoilOffset * clampedStability;
return signedOffset;
}
/**
* Calculate kickback velocity vector applied to player on shoot.
* Returns vector in direction opposite to aim.
*/
calculateKickback(gun: GunConfig, aimAngle: number): Vec2 {
const aimVector: Vec2 = vec2.fromAngle(aimAngle, 1);
return vec2.mul(aimVector, -gun.kickbackForce);
}
/**
* Get the world-space position where a bullet should spawn (barrel tip).
*/
getBulletSpawnPos(
playerX: number,
playerY: number,
aimAngle: number,
recoilOffset: number
): Vec2 {
const totalAngle: number = aimAngle + recoilOffset;
const barrelOffset: Vec2 = vec2.fromAngle(totalAngle, GUN_BARREL_LENGTH);
return {
x: playerX + barrelOffset.x,
y: playerY + barrelOffset.y,
};
}
/**
* Create a Bullet schema instance from gun parameters.
*/
createBullet(
gun: GunConfig,
ownerId: string,
spawnPos: Vec2,
angle: number,
damage: number,
isCrit: boolean
): Bullet {
const bullet: Bullet = new Bullet();
bullet.x = spawnPos.x;
bullet.y = spawnPos.y;
bullet.angle = angle;
bullet.ownerId = ownerId;
bullet.damage = damage;
bullet.penetration = gun.penetration;
bullet.bulletType = this.getGunIndexById(gun.id);
bullet.isCritical = isCrit;
bullet.vx = Math.cos(angle) * gun.bulletSpeed;
bullet.vy = Math.sin(angle) * gun.bulletSpeed;
bullet.size = gun.bulletSize;
bullet.color = gun.bulletColor;
return bullet;
}
/**
* Calculate cooldown between shots in ms, adjusted by reload speed stat.
* cooldown = 1000 / fireRate / (1 + reloadSpdStat * 0.1)
*/
calculateCooldown(gun: GunConfig, reloadSpdStat: number): number {
const baseCooldown: number = 1000 / gun.fireRate;
const reloadBonus: number = 1 + reloadSpdStat * 0.1;
return baseCooldown / reloadBonus;
}
/**
* Calculate reload time in ms, adjusted by reload speed stat.
*/
calculateReloadTime(gun: GunConfig, reloadSpdStat: number): number {
const reloadBonus: number = 1 + reloadSpdStat * 0.1;
return gun.reloadTimeMs / reloadBonus;
}
/**
* Calculate damage per pellet, accounting for shotgun pellet count distribution.
* Total damage is split evenly across pellets.
*/
calculatePelletDamage(baseDamage: number, pelletCount: number): number {
if (pelletCount <= 0) return baseDamage;
return baseDamage / pelletCount;
}
}
+23
View File
@@ -0,0 +1,23 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Server Entry Point
// ═══════════════════════════════════════════════════════════════════════════════
import { Server } from '@colyseus/core';
import { uWebSocketsTransport } from '@colyseus/uwebsockets-transport';
import { ArenaRoom } from './room.js';
const port: number = parseInt(process.env.PORT ?? '3000', 10);
const transport = new uWebSocketsTransport();
const server = new Server({ transport });
server.define('arena', ArenaRoom);
server
.listen(port)
.then(() => {
console.log(`GunCircle.io server listening on port ${port}`);
})
.catch((err: Error) => {
console.error('Failed to start server:', err.message);
process.exit(1);
});
+239
View File
@@ -0,0 +1,239 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Physics Engine
// ═══════════════════════════════════════════════════════════════════════════════
import {
vec2,
type Vec2,
type Bullet,
type Player,
clamp,
ARENA_WIDTH,
ARENA_HEIGHT,
PLAYER_FRICTION,
GUN_RECOVERY_LERP,
} from '@guncircle/shared';
// ─── Entity with position + velocity (duck typing for updatePosition) ────────
export interface PhysicsEntity {
x: number;
y: number;
vx: number;
vy: number;
}
// ─── PhysicsEngine ───────────────────────────────────────────────────────────
export class PhysicsEngine {
/**
* Update entity position by applying velocity over delta time.
* x += vx * dt, y += vy * dt
*/
updatePosition(entity: PhysicsEntity, dt: number): void {
entity.x += entity.vx * dt;
entity.y += entity.vy * dt;
}
/**
* Apply friction to velocity by multiplying by friction factor per tick.
* For per-second friction: factor = Math.pow(frictionPerTick, TICK_RATE)
* Here we use per-tick friction directly.
*/
applyFriction(velocity: Vec2, friction: number): Vec2 {
return {
x: velocity.x * friction,
y: velocity.y * friction,
};
}
/**
* Apply friction directly to a Player's velocity components.
*/
applyFrictionToPlayer(player: Player, friction: number): void {
player.vx *= friction;
player.vy *= friction;
}
/**
* Resolve circle-circle collision by pushing entity A out of entity B.
* Returns true if a collision was resolved.
*/
resolveCollision(
a: { x: number; y: number; radius: number },
b: { x: number; y: number; radius: number },
softness: number = 0.5
): boolean {
const dx: number = a.x - b.x;
const dy: number = a.y - b.y;
const distSq: number = dx * dx + dy * dy;
const minDist: number = a.radius + b.radius;
if (distSq >= minDist * minDist || distSq === 0) return false;
const dist: number = Math.sqrt(distSq);
const overlap: number = minDist - dist;
const nx: number = dx / dist;
const ny: number = dy / dist;
a.x += nx * overlap * softness;
a.y += ny * overlap * softness;
return true;
}
/**
* Clamp player position to arena bounds, accounting for player radius.
*/
clampToArena(player: Player): void {
const minX: number = player.radius;
const minY: number = player.radius;
const maxX: number = ARENA_WIDTH - player.radius;
const maxY: number = ARENA_HEIGHT - player.radius;
player.x = clamp(player.x, minX, maxX);
player.y = clamp(player.y, minY, maxY);
}
/**
* Clamp any entity position to arena bounds (for bullets, orbs, etc).
*/
clampEntityToArena(entity: { x: number; y: number }, radius: number = 0): void {
entity.x = clamp(entity.x, radius, ARENA_WIDTH - radius);
entity.y = clamp(entity.y, radius, ARENA_HEIGHT - radius);
}
/**
* Raycast against a circle. Returns true if the ray from origin in direction dir
* intersects the circle at (cx, cy) with radius cr within maxDist.
*/
raycastCircle(
origin: Vec2,
dir: Vec2,
circle: { x: number; y: number; radius: number },
maxDist: number
): boolean {
const oc: Vec2 = vec2.sub(origin, { x: circle.x, y: circle.y });
const a: number = vec2.dot(dir, dir);
const b: number = 2 * vec2.dot(oc, dir);
const c: number = vec2.dot(oc, oc) - circle.radius * circle.radius;
const discriminant: number = b * b - 4 * a * c;
if (discriminant < 0) return false;
const sqrtDisc: number = Math.sqrt(discriminant);
const t1: number = (-b - sqrtDisc) / (2 * a);
const t2: number = (-b + sqrtDisc) / (2 * a);
// Check if either intersection is within [0, maxDist]
const tMin: number = Math.min(t1, t2);
const tMax: number = Math.max(t1, t2);
return (tMin >= 0 && tMin <= maxDist) || (tMax >= 0 && tMax <= maxDist);
}
/**
* Check if a bullet (treated as a moving circle) overlaps a player circle.
* Uses swept-circle approximation: check endpoint overlap + mid-point.
*/
bulletHitsPlayer(
bullet: Bullet,
player: Player,
dt: number
): boolean {
const bulletSpeed: number = Math.sqrt(bullet.vx * bullet.vx + bullet.vy * bullet.vy);
if (bulletSpeed === 0) {
// Stationary bullet — just check overlap
const dx: number = bullet.x - player.x;
const dy: number = bullet.y - player.y;
const r: number = bullet.size + player.radius;
return dx * dx + dy * dy < r * r;
}
// Current position overlap
const dx: number = bullet.x - player.x;
const dy: number = bullet.y - player.y;
const r: number = bullet.size + player.radius;
if (dx * dx + dy * dy < r * r) return true;
// Check previous position (approximate by stepping back one frame)
const prevX: number = bullet.x - bullet.vx * dt;
const prevY: number = bullet.y - bullet.vy * dt;
const dpx: number = prevX - player.x;
const dpy: number = prevY - player.y;
if (dpx * dpx + dpy * dpy < r * r) return true;
// Check if line segment from prev to current intersects player circle
return this.segmentCircleIntersect(
{ x: prevX, y: prevY },
{ x: bullet.x, y: bullet.y },
{ x: player.x, y: player.y },
r
);
}
/**
* Check if a line segment intersects a circle.
*/
segmentCircleIntersect(
segStart: Vec2,
segEnd: Vec2,
circleCenter: Vec2,
circleRadius: number
): boolean {
const segDir: Vec2 = vec2.sub(segEnd, segStart);
const segLenSq: number = vec2.lenSq(segDir);
if (segLenSq === 0) {
// Degenerate segment — just check point vs circle
const d: number = vec2.dist(segStart, circleCenter);
return d < circleRadius;
}
// Project circle center onto segment
const toCircle: Vec2 = vec2.sub(circleCenter, segStart);
const t: number = clamp(vec2.dot(toCircle, segDir) / segLenSq, 0, 1);
const closest: Vec2 = vec2.add(segStart, vec2.mul(segDir, t));
const dist: number = vec2.dist(closest, circleCenter);
return dist < circleRadius;
}
/**
* Apply recoil recovery: lerp recoilOffset toward 0.
* factor = 1 - exp(-dt / recoveryMs)
*/
applyRecoilRecovery(player: Player, dt: number, recoveryMs: number): void {
if (recoveryMs <= 0) {
player.recoilOffset = 0;
return;
}
const factor: number = 1 - Math.exp(-dt / (recoveryMs / 1000));
player.recoilOffset = player.recoilOffset * (1 - factor);
if (Math.abs(player.recoilOffset) < 0.001) {
player.recoilOffset = 0;
}
}
/**
* Apply recoil recovery using the simpler lerp method (alternative).
*/
applyRecoilRecoveryLerp(player: Player): void {
player.recoilOffset *= 1 - GUN_RECOVERY_LERP;
if (Math.abs(player.recoilOffset) < 0.0001) {
player.recoilOffset = 0;
}
}
/**
* Get distance squared between two entities.
*/
distSqBetween(
a: { x: number; y: number },
b: { x: number; y: number }
): number {
const dx: number = a.x - b.x;
const dy: number = a.y - b.y;
return dx * dx + dy * dy;
}
}
+662
View File
@@ -0,0 +1,662 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Player Manager
// ═══════════════════════════════════════════════════════════════════════════════
import {
Player,
Bullet,
XPOrb,
RoomState,
type PlayerInput,
type GunConfig,
type ClassBranch,
vec2,
type Vec2,
PLAYER_BASE_HP,
PLAYER_BASE_SPEED,
PLAYER_BASE_RADIUS,
PLAYER_FRICTION,
ARENA_WIDTH,
ARENA_HEIGHT,
XP_LEVELS,
MAX_LEVEL,
STAT_COUNT,
STAT_MAX_LEVEL,
STAT_BONUSES,
Stat,
BASE_CRIT_MULTIPLIER,
DEATH_XP_DROP_PCT,
XP_KILL_PCT,
RESPAWN_DELAY_MS,
XP_ORB_LIFETIME_S,
randFloat,
randInt,
clamp,
} from '@guncircle/shared';
import { GunSystem } from './gun-system.js';
import { BranchSystem } from './branch-system.js';
// ─── Pending respawn tracking ────────────────────────────────────────────────
interface PendingRespawn {
playerId: string;
respawnAt: number;
}
// ─── PlayerManager ───────────────────────────────────────────────────────────
export class PlayerManager {
private gunSystem: GunSystem;
private branchSystem: BranchSystem;
private pendingRespawns: PendingRespawn[] = [];
private lastShotTimes: Map<string, number> = new Map();
private reloadTimers: Map<string, number> = new Map();
private nextOrbId: number = 0;
constructor(gunSystem: GunSystem, branchSystem: BranchSystem) {
this.gunSystem = gunSystem;
this.branchSystem = branchSystem;
}
// ─── Spawning ──────────────────────────────────────────────────────────────
/**
* Create and spawn a new player at a random position with base stats.
*/
spawnPlayer(id: string, name: string, state: RoomState): Player {
const player: Player = new Player();
const spawnPos: Vec2 = this.randomSpawnPosition();
player.x = spawnPos.x;
player.y = spawnPos.y;
player.name = name.substring(0, 16);
player.hp = PLAYER_BASE_HP;
player.maxHp = PLAYER_BASE_HP;
player.level = 1;
player.xp = 0;
player.xpToNext = XP_LEVELS[1] ?? 50;
player.gunType = 0; // Pistol
player.classType = 0; // No branch
player.score = 0;
player.alive = true;
player.radius = PLAYER_BASE_RADIUS;
player.upgradePoints = 0;
player.recoilOffset = 0;
player.skinId = 0;
player.vx = 0;
player.vy = 0;
// Set base gun stats
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(0);
if (gun !== undefined) {
player.ammo = gun.ammoMax;
player.maxAmmo = gun.ammoMax;
} else {
player.ammo = 12;
player.maxAmmo = 12;
}
player.isReloading = false;
// Reset stats
for (let i = 0; i < STAT_COUNT; i++) {
player.stats[i] = 0;
}
state.players.set(id, player);
this.lastShotTimes.set(id, 0);
this.reloadTimers.delete(id);
return player;
}
private randomSpawnPosition(): Vec2 {
const margin: number = 100;
return {
x: randFloat(margin, ARENA_WIDTH - margin),
y: randFloat(margin, ARENA_HEIGHT - margin),
};
}
// ─── Input Processing ──────────────────────────────────────────────────────
/**
* Apply player input: movement and shooting.
*/
applyInput(
player: Player,
input: PlayerInput,
playerId: string,
state: RoomState,
currentTime: number,
dt: number
): void {
if (!player.alive) return;
// Update aim angle
player.angle = input.aimAngle;
// Process movement
if (input.moveAngle >= 0) {
const moveSpeedStat: number = player.stats[Stat.MovementSpeed] ?? 0;
const maxSpeed: number = PLAYER_BASE_SPEED * (1 + moveSpeedStat * 0.05);
player.vx += Math.cos(input.moveAngle) * maxSpeed * 0.15;
player.vy += Math.sin(input.moveAngle) * maxSpeed * 0.15;
// Clamp speed
const speed: number = Math.sqrt(player.vx * player.vx + player.vy * player.vy);
if (speed > maxSpeed) {
const scale: number = maxSpeed / speed;
player.vx *= scale;
player.vy *= scale;
}
}
// Process shooting
if (input.isShooting) {
this.handleShoot(player, playerId, state, input.aimAngle, currentTime);
}
}
// ─── Shooting ──────────────────────────────────────────────────────────────
/**
* Handle a single shot from the player's current gun.
*/
handleShoot(
player: Player,
playerId: string,
state: RoomState,
aimAngle: number,
currentTime: number
): void {
if (!player.alive) return;
if (player.isReloading) return;
if (player.ammo <= 0) {
// Auto-reload when empty
this.startReload(player, playerId);
return;
}
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(player.gunType);
if (gun === undefined) return;
// Check fire rate
const lastShot: number = this.lastShotTimes.get(playerId) ?? 0;
const reloadSpdStat: number = player.stats[Stat.ReloadSpeed] ?? 0;
const cooldown: number = this.gunSystem.calculateCooldown(gun, reloadSpdStat);
if (currentTime - lastShot < cooldown) return;
// Apply recoil
const recoilStabStat: number = player.stats[Stat.RecoilStability] ?? 0;
const recoilOffset: number = this.gunSystem.calculateRecoil(gun, recoilStabStat);
player.recoilOffset = recoilOffset;
// Calculate kickback
const kickback: Vec2 = this.gunSystem.calculateKickback(gun, aimAngle);
player.vx += kickback.x;
player.vy += kickback.y;
// Spawn bullet(s)
if (gun.pelletCount > 1) {
// Shotgun spread
const halfSpread: number = gun.spread / 2;
const pelletDamage: number = this.gunSystem.calculatePelletDamage(
this.calculateDamage(player, gun),
gun.pelletCount
);
for (let i = 0; i < gun.pelletCount; i++) {
const t: number = gun.pelletCount <= 1 ? 0 : i / (gun.pelletCount - 1);
const pelletAngle: number = aimAngle + recoilOffset - halfSpread + gun.spread * t;
const spawnPos: Vec2 = this.gunSystem.getBulletSpawnPos(
player.x,
player.y,
aimAngle,
recoilOffset
);
const isCrit: boolean = this.rollCrit(player);
const finalDamage: number = isCrit
? pelletDamage * (BASE_CRIT_MULTIPLIER + (player.stats[Stat.CritDamage] ?? 0) * 0.1)
: pelletDamage;
const bullet: Bullet = this.gunSystem.createBullet(
gun,
playerId,
spawnPos,
pelletAngle,
finalDamage,
isCrit
);
const bulletId: string = this.generateBulletId(state);
state.bullets.set(bulletId, bullet);
}
} else {
// Single bullet
const spawnPos: Vec2 = this.gunSystem.getBulletSpawnPos(
player.x,
player.y,
aimAngle,
recoilOffset
);
const isCrit: boolean = this.rollCrit(player);
const baseDamage: number = this.calculateDamage(player, gun);
const finalDamage: number = isCrit
? baseDamage * (BASE_CRIT_MULTIPLIER + (player.stats[Stat.CritDamage] ?? 0) * 0.1)
: baseDamage;
const bullet: Bullet = this.gunSystem.createBullet(
gun,
playerId,
spawnPos,
aimAngle + recoilOffset,
finalDamage,
isCrit
);
const bulletId: string = this.generateBulletId(state);
state.bullets.set(bulletId, bullet);
}
// Consume ammo
player.ammo = Math.max(0, player.ammo - 1);
this.lastShotTimes.set(playerId, currentTime);
// Auto-reload when empty
if (player.ammo <= 0) {
this.startReload(player, playerId);
}
}
private rollCrit(player: Player): boolean {
const critChance: number = (player.stats[Stat.CritChance] ?? 0) * 0.03;
return Math.random() < critChance;
}
private generateBulletId(state: RoomState): string {
return `b_${state.tick}_${Math.random().toString(36).substring(2, 9)}`;
}
// ─── Reloading ─────────────────────────────────────────────────────────────
/**
* Start reloading the player's gun.
*/
startReload(player: Player, playerId: string): void {
if (player.isReloading) return;
if (player.ammo >= player.maxAmmo) return;
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(player.gunType);
if (gun === undefined) return;
const reloadSpdStat: number = player.stats[Stat.ReloadSpeed] ?? 0;
const reloadTime: number = this.gunSystem.calculateReloadTime(gun, reloadSpdStat);
player.isReloading = true;
this.reloadTimers.set(playerId, reloadTime);
}
/**
* Update all reload timers. Call every tick.
* dt is in milliseconds.
*/
updateReloads(players: Map<string, Player>, dtMs: number): void {
for (const [playerId, player] of players.entries()) {
if (!player.isReloading) continue;
let remaining: number | undefined = this.reloadTimers.get(playerId);
if (remaining === undefined) {
// Timer missing — fix it
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(player.gunType);
remaining = gun !== undefined ? gun.reloadTimeMs : 1500;
}
remaining -= dtMs;
this.reloadTimers.set(playerId, remaining);
if (remaining <= 0) {
// Reload complete
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(player.gunType);
player.ammo = gun !== undefined ? gun.ammoMax : player.maxAmmo;
player.isReloading = false;
this.reloadTimers.delete(playerId);
}
}
}
// ─── Damage & Death ────────────────────────────────────────────────────────
/**
* Apply damage to a player. Returns true if the player died.
*/
takeDamage(player: Player, damage: number, isCrit: boolean): boolean {
if (!player.alive) return false;
if (damage <= 0) return false;
player.hp = Math.max(0, player.hp - damage);
if (player.hp <= 0) {
return true; // Player died
}
return false;
}
/**
* Handle player death: drop XP orbs, reset stats, mark !alive.
*/
onDeath(
player: Player,
playerId: string,
state: RoomState,
killerId?: string
): void {
if (player.alive) {
// Drop 50% of held XP as orbs
this.dropXpOrbs(player, state);
// Award kill to killer
if (killerId !== undefined) {
const killer: Player | undefined = state.players.get(killerId);
if (killer !== undefined && killer.alive) {
killer.score += 1;
// Award 50% of victim's XP to killer
const xpReward: number = Math.floor(player.xp * XP_KILL_PCT);
killer.xp += xpReward;
this.checkLevelUp(killer);
}
}
// Reset player
player.alive = false;
player.hp = 0;
player.vx = 0;
player.vy = 0;
// Schedule respawn
this.pendingRespawns.push({
playerId,
respawnAt: Date.now() + RESPAWN_DELAY_MS,
});
}
}
/**
* Drop XP orbs at player's death position. Total value = 50% of held XP.
*/
private dropXpOrbs(player: Player, state: RoomState): void {
const dropAmount: number = Math.floor(player.xp * DEATH_XP_DROP_PCT);
if (dropAmount <= 0) return;
const orbCount: number = Math.min(10, Math.max(3, player.level));
const xpPerOrb: number = Math.floor(dropAmount / orbCount);
if (xpPerOrb <= 0) return;
for (let i = 0; i < orbCount; i++) {
const angle: number = Math.random() * Math.PI * 2;
const dist: number = randFloat(10, 50);
const orb: XPOrb = new XPOrb();
orb.x = clamp(player.x + Math.cos(angle) * dist, 0, ARENA_WIDTH);
orb.y = clamp(player.y + Math.sin(angle) * dist, 0, ARENA_HEIGHT);
orb.value = xpPerOrb;
orb.lifetime = XP_ORB_LIFETIME_S;
const orbId: string = `orb_${this.nextOrbId++}`;
state.xpOrbs.set(orbId, orb);
}
}
// ─── Respawn ───────────────────────────────────────────────────────────────
/**
* Process pending respawns. Call every tick.
*/
processRespawns(state: RoomState): void {
const now: number = Date.now();
const stillPending: PendingRespawn[] = [];
for (const pending of this.pendingRespawns) {
if (now >= pending.respawnAt) {
const player: Player | undefined = state.players.get(pending.playerId);
if (player !== undefined) {
this.respawnPlayer(player);
}
} else {
stillPending.push(pending);
}
}
this.pendingRespawns = stillPending;
}
/**
* Respawn a player: new random position, reset to level 1, base stats.
*/
respawnPlayer(player: Player): void {
const spawnPos: Vec2 = this.randomSpawnPosition();
player.x = spawnPos.x;
player.y = spawnPos.y;
player.vx = 0;
player.vy = 0;
player.angle = 0;
player.recoilOffset = 0;
// Keep level and XP but reset HP
player.hp = player.maxHp;
player.alive = true;
// Reset gun to pistol
player.gunType = 0;
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(0);
if (gun !== undefined) {
player.ammo = gun.ammoMax;
player.maxAmmo = gun.ammoMax;
}
player.isReloading = false;
}
// ─── Leveling & Upgrades ───────────────────────────────────────────────────
/**
* Check if player has enough XP to level up. Award points and update thresholds.
*/
checkLevelUp(player: Player): boolean {
if (player.level >= MAX_LEVEL) return false;
let leveledUp: boolean = false;
while (player.level < MAX_LEVEL && player.xp >= (XP_LEVELS[player.level] ?? Infinity)) {
player.level += 1;
player.upgradePoints += 1;
leveledUp = true;
// Update xpToNext
if (player.level < MAX_LEVEL) {
player.xpToNext = XP_LEVELS[player.level] ?? player.xpToNext;
} else {
player.xpToNext = 999999;
}
// Apply level-based stat updates
this.onLevelUp(player);
}
return leveledUp;
}
private onLevelUp(player: Player): void {
// Recalculate max HP
player.maxHp = this.calculateMaxHp(player);
// Heal on level up
player.hp = player.maxHp;
}
/**
* Apply a stat upgrade choice to a player.
* choice: 0-8 for stats, or 100+ for branch selection.
*/
applyUpgrade(player: Player, choice: number): boolean {
if (player.upgradePoints <= 0) return false;
// Branch selection: choice >= 100 means branch index
if (choice >= 100) {
const branchIdx: number = choice - 100;
const branch: ClassBranch | undefined = this.branchSystem.getBranchByIndex(branchIdx);
if (branch === undefined) return false;
if (branch.levelRequired > player.level) return false;
this.branchSystem.applyBranch(player, branch.id);
player.upgradePoints -= 1;
// Apply branch bonuses
player.maxHp = this.calculateMaxHp(player);
player.hp = Math.min(player.hp, player.maxHp);
return true;
}
// Stat upgrade
if (choice < 0 || choice >= STAT_COUNT) return false;
const currentLevel: number = player.stats[choice] ?? 0;
if (currentLevel >= STAT_MAX_LEVEL) return false;
player.stats[choice] = currentLevel + 1;
player.upgradePoints -= 1;
// Apply immediate effects
if (choice === Stat.MaxHp) {
player.maxHp = this.calculateMaxHp(player);
player.hp = Math.min(player.hp + STAT_BONUSES[Stat.MaxHp].perLevel, player.maxHp);
}
return true;
}
// ─── Stat Calculations ─────────────────────────────────────────────────────
/**
* Calculate max HP with stat and branch bonuses.
*/
calculateMaxHp(player: Player): number {
const baseHp: number = PLAYER_BASE_HP;
const hpStat: number = player.stats[Stat.MaxHp] ?? 0;
const hpBonus: number = hpStat * STAT_BONUSES[Stat.MaxHp].perLevel;
// Branch bonuses
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchHpBonus: number = branchBonuses[Stat.MaxHp];
return Math.floor(baseHp + hpBonus + baseHp * branchHpBonus);
}
/**
* Calculate movement speed with stat and branch bonuses.
*/
calculateSpeed(player: Player): number {
const moveStat: number = player.stats[Stat.MovementSpeed] ?? 0;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchMoveBonus: number = branchBonuses[Stat.MovementSpeed];
return PLAYER_BASE_SPEED * (1 + moveStat * 0.05 + branchMoveBonus);
}
/**
* Calculate damage multiplier from stats and branches.
*/
calculateDamage(player: Player, gun: GunConfig): number {
const dmgStat: number = player.stats[Stat.BulletDamage] ?? 0;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchDmgBonus: number = branchBonuses[Stat.BulletDamage];
return gun.damage * (1 + dmgStat * 0.1 + branchDmgBonus);
}
/**
* Calculate HP regeneration per second.
*/
calculateHpRegen(player: Player): number {
const regenStat: number = player.stats[Stat.HpRegen] ?? 0;
const baseRegen: number = 1; // HP per second
const statBonus: number = regenStat * STAT_BONUSES[Stat.HpRegen].perLevel;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchRegenBonus: number = branchBonuses[Stat.HpRegen];
return baseRegen + statBonus + baseRegen * branchRegenBonus;
}
/**
* Get the recoil stability factor (0-1, higher = less recoil).
*/
calculateRecoilStability(player: Player): number {
const stabStat: number = player.stats[Stat.RecoilStability] ?? 0;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchStabBonus: number = branchBonuses[Stat.RecoilStability];
return Math.min(1, stabStat * 0.1 + branchStabBonus);
}
/**
* Get the effective reload speed bonus.
*/
calculateReloadBonus(player: Player): number {
const reloadStat: number = player.stats[Stat.ReloadSpeed] ?? 0;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchReloadBonus: number = branchBonuses[Stat.ReloadSpeed];
return reloadStat * 0.1 + branchReloadBonus;
}
// ─── Utility ───────────────────────────────────────────────────────────────
/**
* Get the pending respawn list (for cleanup).
*/
getPendingRespawns(): PendingRespawn[] {
return this.pendingRespawns;
}
/**
* Remove a player's pending respawn.
*/
clearPendingRespawn(playerId: string): void {
this.pendingRespawns = this.pendingRespawns.filter((p) => p.playerId !== playerId);
}
/**
* Get remaining reload time for a player (in ms).
*/
getReloadRemaining(playerId: string): number {
return this.reloadTimers.get(playerId) ?? 0;
}
/**
* Clean up all tracking data for a player.
*/
removePlayer(playerId: string): void {
this.lastShotTimes.delete(playerId);
this.reloadTimers.delete(playerId);
this.pendingRespawns = this.pendingRespawns.filter((p) => p.playerId !== playerId);
}
/**
* Reset all player tracking.
*/
reset(): void {
this.pendingRespawns = [];
this.lastShotTimes.clear();
this.reloadTimers.clear();
this.nextOrbId = 0;
}
}
+355
View File
@@ -0,0 +1,355 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Arena Room
// ═══════════════════════════════════════════════════════════════════════════════
import { Room, Client } from '@colyseus/core';
import {
RoomState,
Player,
Obstacle,
MAX_PLAYERS_PER_ROOM,
TICK_MS,
ARENA_WIDTH,
ARENA_HEIGHT,
} from '@guncircle/shared';
import { GunSystem } from './gun-system.js';
import { BranchSystem } from './branch-system.js';
import { PlayerManager } from './player-manager.js';
import { CollisionSystem } from './collision.js';
import { PhysicsEngine } from './physics.js';
import { GameLoop } from './game-loop.js';
import { InputValidator } from './validation.js';
// ─── Join Options ────────────────────────────────────────────────────────────
interface JoinOptions {
name?: string;
}
// ─── ArenaRoom ───────────────────────────────────────────────────────────────
export class ArenaRoom extends Room<RoomState> {
readonly maxClients: number = MAX_PLAYERS_PER_ROOM;
autoDispose: boolean = false;
// Subsystems
private gunSystem: GunSystem;
private branchSystem: BranchSystem;
private playerManager: PlayerManager;
private collisionSystem: CollisionSystem;
private physics: PhysicsEngine;
private inputValidator: InputValidator;
private gameLoop: GameLoop;
// Room lifecycle
private gameLoopInterval: ReturnType<typeof setInterval> | undefined;
private disposeTimer: ReturnType<typeof setTimeout> | undefined;
private emptySince: number = 0;
private readonly DISPOSE_AFTER_EMPTY_MS: number = 60000;
// Client tracking
private clientNames: Map<string, string> = new Map();
// ─── Colyseus Lifecycle ────────────────────────────────────────────────────
onCreate(_options: unknown): void {
// Initialize state
this.setState(new RoomState());
// Initialize subsystems
this.gunSystem = new GunSystem();
this.branchSystem = new BranchSystem();
this.playerManager = new PlayerManager(this.gunSystem, this.branchSystem);
this.collisionSystem = new CollisionSystem();
this.physics = new PhysicsEngine();
this.inputValidator = new InputValidator();
// Initialize game loop
this.gameLoop = new GameLoop(
this.state,
this.playerManager,
this.collisionSystem,
this.physics,
this.gunSystem,
this.branchSystem
);
// Set up game loop callbacks
this.gameLoop.onPlayerDeath = (playerId: string, killerId?: string) => {
this.broadcast('playerDeath', { victim: playerId, killer: killerId });
};
this.gameLoop.onPlayerLevelUp = (playerId: string) => {
this.broadcast('playerLevelUp', { playerId });
};
// Spawn some initial obstacles
this.spawnObstacles();
// Start game loop
this.gameLoopInterval = setInterval(() => {
this.gameLoop.tick();
}, TICK_MS);
// Handle 'input' messages
this.onMessage('input', (client: Client, data: unknown) => {
this.handleInputMessage(client, data);
});
// Handle 'upgrade' messages
this.onMessage('upgrade', (client: Client, data: unknown) => {
this.handleUpgradeMessage(client, data);
});
// Handle 'reload' messages
this.onMessage('reload', (client: Client, _data: unknown) => {
this.handleReloadMessage(client);
});
console.log(`[ArenaRoom] Created — maxClients=${this.maxClients}`);
}
onJoin(client: Client, options: JoinOptions): void {
const name: string = (options.name ?? `Player ${client.sessionId.substring(0, 4)}`).trim();
const sanitizedName: string = name.substring(0, 16) || 'Player';
this.clientNames.set(client.sessionId, sanitizedName);
// Spawn player
const player: Player = this.playerManager.spawnPlayer(
client.sessionId,
sanitizedName,
this.state
);
// Send gun configs to client
client.send('gunConfigs', { guns: this.gunSystem.getAllGuns() });
// Send branch configs to client
client.send('branchConfigs', { branches: this.branchSystem.getAllBranches() });
// Cancel dispose timer if active
if (this.disposeTimer !== undefined) {
clearTimeout(this.disposeTimer);
this.disposeTimer = undefined;
}
console.log(`[ArenaRoom] Joined: ${sanitizedName} (${client.sessionId}) — total: ${this.state.players.size}`);
}
onLeave(client: Client, consented: boolean): void {
const player: Player | undefined = this.state.players.get(client.sessionId);
if (player !== undefined) {
// Mark as dead
player.alive = false;
player.hp = 0;
// Clean up tracking
this.playerManager.removePlayer(client.sessionId);
}
this.clientNames.delete(client.sessionId);
// Remove player from state after a short delay to let clients process
setTimeout(() => {
this.state.players.delete(client.sessionId);
}, 1000);
const remainingClients: number = this.clientNames.size;
console.log(`[ArenaRoom] Left: ${client.sessionId} (consented=${consented}) — remaining: ${remainingClients}`);
// Start dispose timer if room is empty (use clientNames, not players, since players has delayed deletion)
if (remainingClients === 0) {
this.emptySince = Date.now();
this.disposeTimer = setTimeout(() => {
if (this.clientNames.size === 0) {
console.log('[ArenaRoom] Disposing empty room');
this.disconnect();
}
}, this.DISPOSE_AFTER_EMPTY_MS);
}
}
onDispose(): void {
console.log('[ArenaRoom] Disposed');
// Clean up game loop
if (this.gameLoopInterval !== undefined) {
clearInterval(this.gameLoopInterval);
this.gameLoopInterval = undefined;
}
if (this.disposeTimer !== undefined) {
clearTimeout(this.disposeTimer);
this.disposeTimer = undefined;
}
this.gameLoop.destroy();
this.playerManager.reset();
this.clientNames.clear();
}
// ─── Message Handlers ──────────────────────────────────────────────────────
private handleInputMessage(client: Client, data: unknown): void {
const player: Player | undefined = this.state.players.get(client.sessionId);
if (player === undefined) return;
if (!player.alive) return;
// Parse and validate input
const input: PlayerInput | null = this.parseInput(data);
if (input === null) return;
// Sanitize
const sanitized: PlayerInput = this.inputValidator.sanitizeInput(input);
// Validate aim angle
const aimResult = this.inputValidator.validateAimAngle(player.angle, sanitized.aimAngle);
sanitized.aimAngle = aimResult.angle;
// Queue for game loop
this.gameLoop.queueInput(client.sessionId, sanitized);
}
private handleUpgradeMessage(client: Client, data: unknown): void {
const player: Player | undefined = this.state.players.get(client.sessionId);
if (player === undefined) return;
if (player.upgradePoints <= 0) return;
let choice: number | undefined;
if (typeof data === 'number') {
choice = data;
} else if (
typeof data === 'object' &&
data !== null &&
'choice' in data &&
typeof (data as Record<string, unknown>).choice === 'number'
) {
choice = (data as Record<string, unknown>).choice as number;
}
if (choice === undefined) return;
const success: boolean = this.playerManager.applyUpgrade(player, choice);
if (success) {
client.send('upgradeAck', { choice, pointsRemaining: player.upgradePoints });
}
}
private handleReloadMessage(client: Client): void {
const player: Player | undefined = this.state.players.get(client.sessionId);
if (player === undefined) return;
if (!player.alive) return;
this.playerManager.startReload(player, client.sessionId);
}
// ─── Input Parsing ─────────────────────────────────────────────────────────
private parseInput(data: unknown): PlayerInput | null {
if (typeof data !== 'object' || data === null) return null;
const d = data as Record<string, unknown>;
const seq: number = typeof d.seq === 'number' ? d.seq : 0;
const moveAngle: number = typeof d.moveAngle === 'number' ? d.moveAngle : -1;
const aimAngle: number = typeof d.aimAngle === 'number' ? d.aimAngle : 0;
const isShooting: boolean = Boolean(d.isShooting);
const upgradeChoice: number | undefined =
typeof d.upgradeChoice === 'number' ? d.upgradeChoice : undefined;
return { seq, moveAngle, aimAngle, isShooting, upgradeChoice };
}
// ─── Obstacle Generation ───────────────────────────────────────────────────
private spawnObstacles(): void {
// Spawn some indestructible walls around the arena
const wallThickness: number = 50;
// Top wall segments
for (let i = 0; i < 6; i++) {
this.createObstacle(
200 + i * 500,
100,
wallThickness * 4,
wallThickness,
0 // IndestructibleWall
);
}
// Bottom wall segments
for (let i = 0; i < 6; i++) {
this.createObstacle(
200 + i * 500,
ARENA_HEIGHT - 150,
wallThickness * 4,
wallThickness,
0
);
}
// Left wall segments
for (let i = 0; i < 6; i++) {
this.createObstacle(
100,
200 + i * 500,
wallThickness,
wallThickness * 4,
0
);
}
// Right wall segments
for (let i = 0; i < 6; i++) {
this.createObstacle(
ARENA_WIDTH - 150,
200 + i * 500,
wallThickness,
wallThickness * 4,
0
);
}
// Central cover blocks
this.createObstacle(ARENA_WIDTH / 2 - 100, ARENA_HEIGHT / 2 - 100, 200, 50, 3); // Cover
this.createObstacle(ARENA_WIDTH / 2 - 50, ARENA_HEIGHT / 2 + 50, 100, 100, 3);
// Scattered crates
const cratePositions: Array<[number, number]> = [
[400, 400],
[ARENA_WIDTH - 400, 400],
[400, ARENA_HEIGHT - 400],
[ARENA_WIDTH - 400, ARENA_HEIGHT - 400],
[ARENA_WIDTH / 2, 300],
[ARENA_WIDTH / 2, ARENA_HEIGHT - 300],
[300, ARENA_HEIGHT / 2],
[ARENA_WIDTH - 300, ARENA_HEIGHT / 2],
];
for (const [x, y] of cratePositions) {
this.createObstacle(x, y, 60, 60, 1); // DestructibleCrate
}
// Slow zones
this.createObstacle(ARENA_WIDTH / 4, ARENA_HEIGHT / 4, 200, 200, 2);
this.createObstacle((ARENA_WIDTH * 3) / 4, (ARENA_HEIGHT * 3) / 4, 200, 200, 2);
}
private createObstacle(
x: number,
y: number,
width: number,
height: number,
type: number
): void {
const obs: Obstacle = new Obstacle();
obs.x = x;
obs.y = y;
obs.width = width;
obs.height = height;
obs.type = type;
const id: string = `obs_${this.state.obstacles.size}`;
this.state.obstacles.set(id, obs);
}
}
+204
View File
@@ -0,0 +1,204 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Input Validator (Anti-cheat)
// ═══════════════════════════════════════════════════════════════════════════════
import {
type Player,
type GunConfig,
type PlayerInput,
PLAYER_BASE_SPEED,
PLAYER_ACCELERATION,
ARENA_WIDTH,
ARENA_HEIGHT,
STAT_MAX_LEVEL,
STAT_COUNT,
Stat,
AIM_ANGLE_TOLERANCE,
MAX_LEVEL,
} from '@guncircle/shared';
// ─── InputValidator ──────────────────────────────────────────────────────────
export class InputValidator {
/** Maximum position change per tick at max speed */
private readonly maxDistPerTick: number;
constructor() {
// At 60Hz, max distance per tick = speed / 60 + small tolerance
this.maxDistPerTick = PLAYER_BASE_SPEED / 60 + 2;
}
/**
* Validate movement input. Speed must not exceed base * (1 + moveSpeedStat).
* Returns clamped (newX, newY) that are within allowed bounds.
*/
validateMovement(
player: Player,
moveAngle: number,
dt: number
): { x: number; y: number; valid: boolean } {
if (moveAngle < -0.5) {
// -1 or negative means no movement input
return { x: player.x, y: player.y, valid: true };
}
const moveSpeedStat: number = player.stats[Stat.MovementSpeed] ?? 0;
const maxSpeed: number = PLAYER_BASE_SPEED * (1 + moveSpeedStat * 0.05);
const maxStep: number = maxSpeed * dt;
// Validate position delta from current position
const requestedVx: number = Math.cos(moveAngle) * maxSpeed;
const requestedVy: number = Math.sin(moveAngle) * maxSpeed;
const newX: number = player.x + requestedVx * dt;
const newY: number = player.y + requestedVy * dt;
// Clamp to arena
const clampedX: number = Math.max(
player.radius,
Math.min(ARENA_WIDTH - player.radius, newX)
);
const clampedY: number = Math.max(
player.radius,
Math.min(ARENA_HEIGHT - player.radius, newY)
);
// Check if the step size is reasonable
const dx: number = clampedX - player.x;
const dy: number = clampedY - player.y;
const stepSize: number = Math.sqrt(dx * dx + dy * dy);
if (stepSize > maxStep * 1.5) {
// Possible speed hack — reject movement
return { x: player.x, y: player.y, valid: false };
}
return { x: clampedX, y: clampedY, valid: true };
}
/**
* Validate aim angle change. Change per tick must be within tolerance.
*/
validateAimAngle(oldAngle: number, newAngle: number): { angle: number; valid: boolean } {
let diff: number = newAngle - oldAngle;
// Normalize to [-PI, PI]
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
const absDiff: number = Math.abs(diff);
if (absDiff > AIM_ANGLE_TOLERANCE * 10) {
// Suspiciously large angle change — could be aimbot
// Cap it to max allowed
const cappedDiff: number = Math.sign(diff) * AIM_ANGLE_TOLERANCE * 10;
let cappedAngle: number = oldAngle + cappedDiff;
while (cappedAngle > Math.PI) cappedAngle -= Math.PI * 2;
while (cappedAngle < -Math.PI) cappedAngle += Math.PI * 2;
return { angle: cappedAngle, valid: false };
}
return { angle: newAngle, valid: true };
}
/**
* Validate fire rate: time since last shot must be >= cooldown.
* Returns true if firing is allowed.
*/
validateFireRate(
lastShotTime: number,
currentTime: number,
gun: GunConfig,
reloadSpdStat: number
): boolean {
const baseCooldown: number = 1000 / gun.fireRate;
const reloadBonus: number = 1 + reloadSpdStat * 0.1;
const adjustedCooldown: number = baseCooldown / reloadBonus;
const elapsed: number = currentTime - lastShotTime;
return elapsed >= adjustedCooldown - 5; // 5ms tolerance for network jitter
}
/**
* Validate stat upgrade choice. Returns true if the player can make this upgrade.
*/
validateUpgrade(player: Player, choice: number): { valid: boolean; reason: string } {
// Must have upgrade points
if (player.upgradePoints <= 0) {
return { valid: false, reason: 'No upgrade points available' };
}
// Choice must be a valid stat index
if (choice < 0 || choice >= STAT_COUNT) {
return { valid: false, reason: 'Invalid stat index' };
}
// Stat must not be maxed
const currentLevel: number = player.stats[choice] ?? 0;
if (currentLevel >= STAT_MAX_LEVEL) {
return { valid: false, reason: 'Stat already at max level' };
}
return { valid: true, reason: '' };
}
/**
* Validate branch selection choice. Returns true if branch is valid for player.
*/
validateBranchChoice(
player: Player,
branchIndex: number,
availableBranches: number[]
): { valid: boolean; reason: string } {
if (branchIndex < 0 || branchIndex >= availableBranches.length) {
return { valid: false, reason: 'Invalid branch selection' };
}
if (player.upgradePoints <= 0) {
return { valid: false, reason: 'No upgrade points available' };
}
return { valid: true, reason: '' };
}
/**
* Validate general input bounds. Clamp numeric values to reasonable ranges.
*/
sanitizeInput(input: PlayerInput): PlayerInput {
return {
seq: Math.max(0, input.seq | 0),
moveAngle: isNaN(input.moveAngle) ? -1 : input.moveAngle,
aimAngle: isNaN(input.aimAngle) ? 0 : input.aimAngle,
isShooting: Boolean(input.isShooting),
upgradeChoice:
input.upgradeChoice !== undefined ? Math.max(0, input.upgradeChoice | 0) : undefined,
};
}
/**
* Check if a position is within arena bounds.
*/
isInArena(x: number, y: number, radius: number = 0): boolean {
return (
x >= radius &&
x <= ARENA_WIDTH - radius &&
y >= radius &&
y <= ARENA_HEIGHT - radius
);
}
/**
* Validate that velocity is within reasonable bounds.
*/
validateVelocity(vx: number, vy: number, maxSpeed: number): { vx: number; vy: number } {
const speedSq: number = vx * vx + vy * vy;
const maxSpeedSq: number = maxSpeed * maxSpeed;
if (speedSq > maxSpeedSq * 4) {
// Suspicious velocity — cap it
const speed: number = Math.sqrt(speedSq);
const scale: number = (maxSpeed * 2) / speed;
return { vx: vx * scale, vy: vy * scale };
}
return { vx, vy };
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["./src"],
"references": [{ "path": "../shared" }]
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@guncircle/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"type-check": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@colyseus/schema": "^3.0.0"
},
"devDependencies": {
"typescript": "^5.6.0",
"vitest": "^2.0.0"
}
}
+132
View File
@@ -0,0 +1,132 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Game Constants
// ═══════════════════════════════════════════════════════════════════════════════
/** Server tick rate (Hz) */
export const TICK_RATE = 60;
/** Duration of one tick in ms */
export const TICK_MS = 1000 / TICK_RATE;
/** Arena dimensions */
export const ARENA_WIDTH = 3000;
export const ARENA_HEIGHT = 3000;
/** Max players per room */
export const MAX_PLAYERS_PER_ROOM = 50;
/** Player base stats */
export const PLAYER_BASE_RADIUS = 20;
export const PLAYER_BASE_HP = 100;
export const PLAYER_BASE_SPEED = 150; // units per second
export const PLAYER_BASE_REGEN = 1; // HP per second
/** Movement physics */
export const PLAYER_ACCELERATION = 800; // units per second^2
export const PLAYER_FRICTION = 0.92; // velocity multiplier per tick
export const PLAYER_MAX_SPEED_MULT = 1.0;
/** Gun rendering */
export const GUN_BARREL_WIDTH = 8;
export const GUN_BARREL_LENGTH = 22;
export const GUN_BODY_SIZE = 10;
export const GUN_RECOVERY_LERP = 0.15; // per tick
/** XP system */
export const XP_LEVELS = [
0, 50, 150, 300, 500, 750, 1050, 1400, 1800, 2250,
2750, 3300, 3900, 4550, 5250, 6000, 7000, 8200, 9500, 11000,
12700, 14500, 16500, 18700, 21100, 23700, 26500, 29500, 32700, 36100,
40000, 45000, 51000, 58000, 66000, 75000, 85000, 96000, 108000, 121000,
135000, 150000, 167000, 185000, 204000, 225000,
];
export const MAX_LEVEL = XP_LEVELS.length; // 45
/** Death / respawn */
export const DEATH_XP_DROP_PCT = 0.5;
export const RESPAWN_DELAY_MS = 3000;
export const XP_ORB_LIFETIME_S = 30;
export const XP_ORB_DESPAWN_MS = XP_ORB_LIFETIME_S * 1000;
/** Class branch milestone levels */
export const BRANCH_LEVELS = [5, 10, 15, 30, 45];
/** Stat upgrade system */
export const STAT_MAX_LEVEL = 7;
export const STAT_UPGRADE_COST_BASE = 1; // 1 point per upgrade
/** Stats enum — indexes into uint8 array */
export enum Stat {
MaxHp = 0,
HpRegen = 1,
MovementSpeed = 2,
BulletDamage = 3,
BulletSpeed = 4,
ReloadSpeed = 5,
RecoilStability = 6,
CritChance = 7,
CritDamage = 8,
}
export const STAT_COUNT = 9;
/** Stat bonuses per level (additive) */
export const STAT_BONUSES: Record<Stat, { perLevel: number; isPct: boolean }> = {
[Stat.MaxHp]: { perLevel: 20, isPct: false },
[Stat.HpRegen]: { perLevel: 1, isPct: false },
[Stat.MovementSpeed]: { perLevel: 0.05, isPct: true },
[Stat.BulletDamage]: { perLevel: 0.10, isPct: true },
[Stat.BulletSpeed]: { perLevel: 0.08, isPct: true },
[Stat.ReloadSpeed]: { perLevel: 0.10, isPct: true },
[Stat.RecoilStability]: { perLevel: 0.10, isPct: true }, // -10% recoil per level
[Stat.CritChance]: { perLevel: 0.03, isPct: true },
[Stat.CritDamage]: { perLevel: 0.10, isPct: true },
};
/** Base crit */
export const BASE_CRIT_MULTIPLIER = 1.5; // 150%
/** XP rewards */
export const XP_PER_DAMAGE = 1;
export const XP_KILL_PCT = 0.5; // 50% of victim's held XP
/** Collision */
export const SPATIAL_CELL_SIZE = 100;
/** Network */
export const INPUT_RATE_HZ = 60;
export const INPUT_MS = 1000 / INPUT_RATE_HZ;
/** Validation tolerances */
export const POS_DRIFT_THRESHOLD = 5; // correct if drift > 5 units
export const AIM_ANGLE_TOLERANCE = 0.1; // radians
export const RECOIL_TOLERANCE = 0.15; // radians
/** Colors */
export const COLORS = {
self: '#3498db', // blue
enemy: '#e74c3c', // red
bullet: '#f1c40f', // yellow
bulletCrit: '#e74c3c', // red outline for crit
xpOrb: '#2ecc71', // green
hpBar: '#2ecc71',
hpBarBg: '#2c3e50',
nameTag: '#ecf0f1',
uiBg: 'rgba(0,0,0,0.6)',
uiBorder: '#34495e',
arenaBg: '#1a1a2e',
arenaGrid: '#16213e',
wall: '#7f8c8d',
crate: '#8b4513',
text: '#ecf0f1',
} as const;
/** Gun categories (for class branch unlocking) */
export enum GunCategory {
Pistol = 0,
Rifle = 1,
Shotgun = 2,
Sniper = 3,
SMG = 4,
}
+8
View File
@@ -0,0 +1,8 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Shared Package Exports
// ═══════════════════════════════════════════════════════════════════════════════
export * from './constants.js';
export * from './types.js';
export * from './math.js';
export * from './schema.js';
+222
View File
@@ -0,0 +1,222 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Math Utilities
// ═══════════════════════════════════════════════════════════════════════════════
import type { Vec2 } from './types.js';
// ─── Vector Operations ───────────────────────────────────────────────────────
export const vec2 = {
zero: (): Vec2 => ({ x: 0, y: 0 }),
fromAngle: (angle: number, magnitude: number = 1): Vec2 => ({
x: Math.cos(angle) * magnitude,
y: Math.sin(angle) * magnitude,
}),
add: (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y }),
sub: (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y }),
mul: (v: Vec2, s: number): Vec2 => ({ x: v.x * s, y: v.y * s }),
div: (v: Vec2, s: number): Vec2 => ({ x: v.x / s, y: v.y / s }),
len: (v: Vec2): number => Math.sqrt(v.x * v.x + v.y * v.y),
lenSq: (v: Vec2): number => v.x * v.x + v.y * v.y,
normalize: (v: Vec2): Vec2 => {
const l = Math.sqrt(v.x * v.x + v.y * v.y);
return l === 0 ? { x: 0, y: 0 } : { x: v.x / l, y: v.y / l };
},
dot: (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y,
dist: (a: Vec2, b: Vec2): number => {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
},
distSq: (a: Vec2, b: Vec2): number => {
const dx = a.x - b.x;
const dy = a.y - b.y;
return dx * dx + dy * dy;
},
clamp: (v: Vec2, maxLen: number): Vec2 => {
const l = Math.sqrt(v.x * v.x + v.y * v.y);
if (l <= maxLen) return v;
const scale = maxLen / l;
return { x: v.x * scale, y: v.y * scale };
},
angle: (v: Vec2): number => Math.atan2(v.y, v.x),
angleBetween: (a: Vec2, b: Vec2): number => Math.atan2(b.y - a.y, b.x - a.x),
lerp: (a: Vec2, b: Vec2, t: number): Vec2 => ({
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
}),
copy: (v: Vec2): Vec2 => ({ x: v.x, y: v.y }),
equals: (a: Vec2, b: Vec2, eps: number = 0.001): boolean =>
Math.abs(a.x - b.x) < eps && Math.abs(a.y - b.y) < eps,
};
// ─── Scalar Utilities ────────────────────────────────────────────────────────
export const lerp = (a: number, b: number, t: number): number =>
a + (b - a) * t;
export const lerpAngle = (a: number, b: number, t: number): number => {
let diff = b - a;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return a + diff * t;
};
export const clamp = (v: number, min: number, max: number): number =>
Math.max(min, Math.min(max, v));
export const clamp01 = (v: number): number => clamp(v, 0, 1);
export const remap = (
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number
): number => outMin + ((v - inMin) / (inMax - inMin)) * (outMax - outMin);
/** Return random float in [min, max) */
export const randFloat = (min: number, max: number): number =>
min + Math.random() * (max - min);
/** Return random int in [min, max) */
export const randInt = (min: number, max: number): number =>
Math.floor(randFloat(min, max));
/** Return 1 or -1 randomly */
export const randSign = (): number => (Math.random() < 0.5 ? 1 : -1);
/** Check if circle a overlaps circle b */
export const circleOverlap = (
ax: number, ay: number, ar: number,
bx: number, by: number, br: number
): boolean => {
const dx = ax - bx;
const dy = ay - by;
const r = ar + br;
return dx * dx + dy * dy < r * r;
};
/** Solve circle-circle collision: push a out of b. Returns correction vector for a. */
export const circleCollisionResolve = (
ax: number, ay: number, ar: number,
bx: number, by: number, br: number
): Vec2 | null => {
const dx = ax - bx;
const dy = ay - by;
const distSq = dx * dx + dy * dy;
const r = ar + br;
if (distSq >= r * r || distSq === 0) return null;
const dist = Math.sqrt(distSq);
const overlap = r - dist;
return {
x: (dx / dist) * overlap,
y: (dy / dist) * overlap,
};
};
// ─── Spatial Hash Grid ───────────────────────────────────────────────────────
export class SpatialHashGrid<T extends { x: number; y: number; id: number }> {
private cells = new Map<string, T[]>();
private itemToCell = new Map<number, string>();
constructor(private cellSize: number) {}
private hash(cx: number, cy: number): string {
return `${cx},${cy}`;
}
insert(item: T): void {
const cx = Math.floor(item.x / this.cellSize);
const cy = Math.floor(item.y / this.cellSize);
const key = this.hash(cx, cy);
let cell = this.cells.get(key);
if (!cell) {
cell = [];
this.cells.set(key, cell);
}
cell.push(item);
this.itemToCell.set(item.id, key);
}
remove(item: T): void {
const key = this.itemToCell.get(item.id);
if (!key) return;
const cell = this.cells.get(key);
if (!cell) return;
const idx = cell.findIndex((i) => i.id === item.id);
if (idx >= 0) cell.splice(idx, 1);
this.itemToCell.delete(item.id);
}
update(item: T): void {
this.remove(item);
this.insert(item);
}
query(x: number, y: number, radius: number): T[] {
const results: T[] = [];
const minCx = Math.floor((x - radius) / this.cellSize);
const maxCx = Math.floor((x + radius) / this.cellSize);
const minCy = Math.floor((y - radius) / this.cellSize);
const maxCy = Math.floor((y + radius) / this.cellSize);
const rSq = radius * radius;
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cy = minCy; cy <= maxCy; cy++) {
const key = this.hash(cx, cy);
const cell = this.cells.get(key);
if (!cell) continue;
for (const item of cell) {
const dx = item.x - x;
const dy = item.y - y;
if (dx * dx + dy * dy <= rSq) {
results.push(item);
}
}
}
}
return results;
}
queryRect(x: number, y: number, w: number, h: number): T[] {
const results: T[] = [];
const minCx = Math.floor(x / this.cellSize);
const maxCx = Math.floor((x + w) / this.cellSize);
const minCy = Math.floor(y / this.cellSize);
const maxCy = Math.floor((y + h) / this.cellSize);
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cy = minCy; cy <= maxCy; cy++) {
const key = this.hash(cx, cy);
const cell = this.cells.get(key);
if (!cell) continue;
for (const item of cell) {
if (item.x >= x && item.x <= x + w && item.y >= y && item.y <= y + h) {
results.push(item);
}
}
}
}
return results;
}
clear(): void {
this.cells.clear();
this.itemToCell.clear();
}
}
+119
View File
@@ -0,0 +1,119 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Colyseus Schema Definitions
// ═══════════════════════════════════════════════════════════════════════════════
import { Schema, type, MapSchema, ArraySchema } from '@colyseus/schema';
// ─── Player State ────────────────────────────────────────────────────────────
export class Player extends Schema {
@type('float32') x = 0;
@type('float32') y = 0;
/** Current aim angle in radians */
@type('float32') angle = 0;
/** Current HP */
@type('float32') hp = 100;
@type('float32') maxHp = 100;
/** 1-45 */
@type('uint8') level = 1;
@type('uint16') xp = 0;
@type('uint16') xpToNext = 50;
/** Index into gun configs */
@type('uint8') gunType = 0;
/** Index into class branches */
@type('uint8') classType = 0;
/** Number of kills */
@type('uint8') score = 0;
/** Player name, max 16 chars */
@type('string') name = 'Player';
/** Cosmetic skin index */
@type('uint8') skinId = 0;
/** Current recoil offset in radians */
@type('float32') recoilOffset = 0;
/** Current ammo */
@type('uint8') ammo = 0;
/** Max ammo */
@type('uint8') maxAmmo = 0;
/** Is reloading */
@type('boolean') isReloading = false;
/** Alive */
@type('boolean') alive = true;
/** Radius */
@type('float32') radius = 20;
/** Upgrade points available */
@type('uint8') upgradePoints = 0;
/** Stat levels: [maxHp, hpRegen, moveSpeed, bulletDmg, bulletSpd, reloadSpd, recoilStab, critChance, critDmg] */
@type(['uint8']) stats = new ArraySchema<number>(0, 0, 0, 0, 0, 0, 0, 0, 0);
/** Velocity x (for smoother client reconciliation) */
@type('float32') vx = 0;
/** Velocity y */
@type('float32') vy = 0;
}
// ─── Bullet State ────────────────────────────────────────────────────────────
export class Bullet extends Schema {
@type('float32') x = 0;
@type('float32') y = 0;
/** Travel angle in radians */
@type('float32') angle = 0;
/** Owner session ID */
@type('string') ownerId = '';
/** Damage */
@type('float32') damage = 10;
/** Penetration remaining */
@type('uint8') penetration = 1;
/** Bullet type index */
@type('uint8') bulletType = 0;
/** Is critical hit */
@type('boolean') isCritical = false;
/** Velocity x */
@type('float32') vx = 0;
/** Velocity y */
@type('float32') vy = 0;
/** Bullet radius/size */
@type('float32') size = 4;
/** Bullet color */
@type('string') color = '#f1c40f';
}
// ─── XP Orb State ────────────────────────────────────────────────────────────
export class XPOrb extends Schema {
@type('float32') x = 0;
@type('float32') y = 0;
/** XP value */
@type('uint16') value = 10;
/** Seconds remaining */
@type('uint8') lifetime = 30;
}
// ─── Arena Obstacle State ────────────────────────────────────────────────────
export class Obstacle extends Schema {
@type('float32') x = 0;
@type('float32') y = 0;
@type('float32') width = 50;
@type('float32') height = 50;
@type('uint8') type = 0;
}
// ─── Leaderboard Entry ───────────────────────────────────────────────────────
export class LeaderboardEntry extends Schema {
@type('string') name = '';
@type('uint8') level = 1;
@type('uint8') score = 0;
@type('uint16') xp = 0;
}
// ─── Room State ──────────────────────────────────────────────────────────────
export class RoomState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
@type({ map: Bullet }) bullets = new MapSchema<Bullet>();
@type({ map: XPOrb }) xpOrbs = new MapSchema<XPOrb>();
@type({ map: Obstacle }) obstacles = new MapSchema<Obstacle>();
@type('uint32') tick = 0;
@type([LeaderboardEntry]) leaderboard = new ArraySchema<LeaderboardEntry>();
}
+139
View File
@@ -0,0 +1,139 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Shared Type Definitions
// ═══════════════════════════════════════════════════════════════════════════════
import type { Stat, GunCategory } from './constants.js';
// ─── Vector ──────────────────────────────────────────────────────────────────
export interface Vec2 {
x: number;
y: number;
}
// ─── Gun Configuration ───────────────────────────────────────────────────────
export interface GunConfig {
/** Unique identifier */
id: string;
/** Display name */
name: string;
/** Gun category for class branch unlocking */
category: GunCategory;
/** Damage per bullet */
damage: number;
/** Shots per second */
fireRate: number;
/** Bullet speed (units/sec) */
bulletSpeed: number;
/** Number of bullets per shot (1 = single, >1 = shotgun) */
pelletCount: number;
/** Spread angle in radians (0 = no spread) */
spread: number;
/** Gun recoil rotation offset in radians (random left/right) */
recoilOffset: number;
/** Time to recover from recoil (ms) */
recoilRecoveryMs: number;
/** Backward impulse on player per shot */
kickbackForce: number;
/** Max ammo before reload */
ammoMax: number;
/** Reload time in ms */
reloadTimeMs: number;
/** Bullet radius */
bulletSize: number;
/** How many entities bullet can pass through */
penetration: number;
/** Bullet lifetime in ms */
bulletLifetimeMs: number;
/** Color for bullets */
bulletColor: string;
}
// ─── Class Branch ────────────────────────────────────────────────────────────
export interface ClassBranch {
id: string;
name: string;
description: string;
/** Level required to choose this branch */
levelRequired: number;
/** Parent branch ID (null for root) */
parentId: string | null;
/** Gun categories unlocked by this branch */
unlocksCategories: GunCategory[];
/** Passive stat bonuses (additive) */
passiveBonuses: Partial<Record<Stat, number>>;
}
// ─── Input ───────────────────────────────────────────────────────────────────
export interface PlayerInput {
/** Client frame counter */
seq: number;
/** Movement angle in radians, -1 if no movement */
moveAngle: number;
/** Aim angle in radians */
aimAngle: number;
/** True if mouse down / shooting */
isShooting: boolean;
/** Stat index or branch choice when leveling up */
upgradeChoice?: number;
}
// ─── Arena Obstacle ──────────────────────────────────────────────────────────
export interface ArenaObstacle {
id: number;
x: number;
y: number;
width: number;
height: number;
type: ObstacleType;
}
export enum ObstacleType {
IndestructibleWall = 0,
DestructibleCrate = 1,
SlowZone = 2,
Cover = 3,
}
// ─── Skin ────────────────────────────────────────────────────────────────────
export interface Skin {
id: number;
name: string;
type: SkinType;
/** Color/Pattern value */
value: string;
/** Price in coins (0 = free/achievement) */
price: number;
/** Achievement requirement (null = purchasable) */
achievement?: string;
}
export enum SkinType {
CircleColor = 0,
GunSkin = 1,
NameTagStyle = 2,
}
// ─── XP Orb ──────────────────────────────────────────────────────────────────
export interface XpOrbData {
id: number;
x: number;
y: number;
value: number;
lifetime: number; // seconds remaining
}
// ─── Leaderboard Entry ───────────────────────────────────────────────────────
export interface LeaderboardEntry {
name: string;
level: number;
score: number; // kills
xp: number;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["./src"]
}