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
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
*.log
.env
.DS_Store
*.local
.vite/
coverage/
*.tsbuildinfo
+225
View File
@@ -0,0 +1,225 @@
# GunCircle.io — Technical Specification
## Overview
A browser-based multiplayer .io arena shooter (diep.io-style). Continuous FFA — no matches, no timers. Players join, spawn immediately, fight, earn XP, level up, choose class branches, die and respawn.
## Architecture
- **Monorepo**: `packages/shared`, `packages/server`, `packages/client`
- **Client**: TypeScript + Vite + HTML5 Canvas 2D (PC only, WASD + mouse)
- **Server**: Node.js + TypeScript + Colyseus (uWebSockets transport)
- **Physics**: Custom circle-circle + spatial hash grid (no external physics)
- **Network**: Client-side prediction + server reconciliation + entity interpolation
## Shared Package (`packages/shared/`)
Already implemented. Contains:
- `schema.ts` — Colyseus Schema classes (Player, Bullet, XPOrb, Obstacle, LeaderboardEntry, RoomState)
- `types.ts` — TypeScript interfaces (GunConfig, ClassBranch, PlayerInput, Skin, etc.)
- `constants.ts` — Game constants (TICK_RATE, ARENA, COLORS, STATS, XP_LEVELS, etc.)
- `math.ts` — Vector math, spatial hash grid, scalar utilities
- `config/guns.json` — 5 gun definitions (pistol, rifle, shotgun, sniper, SMG)
- `config/branches.json` — 19 class branch definitions
## Server (`packages/server/src/`)
### Entry Point: `index.ts`
Sets up Colyseus server with uWebSockets transport, listens on port 3000, registers `ArenaRoom`.
### Room: `room.ts` — `ArenaRoom extends Room<RoomState>`
- **maxClients**: 50
- **Game loop**: `setInterval` at TICK_RATE (60Hz), increments `state.tick`
- **On join**: create Player, spawn at random position, assign base gun (pistol), send gun configs
- **On leave**: mark player dead, drop 50% XP as orbs after 60s room destroy timer
- **On message**: deserialize PlayerInput, validate, apply
### Game Loop: `game-loop.ts`
Per tick (order matters):
1. Process player inputs (movement, shooting, upgrades)
2. Update bullet positions (linear velocity)
3. Update player positions (velocity + friction)
4. Detect collisions (bullets vs players, players vs obstacles, players vs XP orbs)
5. Apply damage, handle deaths
6. Update recoil recovery (lerp recoilOffset toward 0)
7. Update reload timers
8. Regenerate HP
9. Update leaderboard
10. Broadcast state (Colyseus handles delta compression)
### Physics: `physics.ts`
- `SpatialHashGrid` for O(1) queries
- `circleCollisionResolve()` for player-player and player-obstacle
- `lineCircleIntersect()` for bullet hit detection
- Arena bounds clamping
### Player Manager: `player-manager.ts`
- `spawnPlayer()`: random position, base stats, apply class bonuses
- `applyInput()`: validate speed, update velocity, handle shooting
- `handleShoot()`: apply recoil, spawn bullet(s), apply kickback, consume ammo
- `takeDamage()`: apply damage, check crit, check death
- `onDeath()`: drop XP orbs, reset level/stats, schedule respawn
- `respawnPlayer()`: new random position, level 1, base gun
- `applyUpgrade()`: validate stat choice, apply bonus
- `checkLevelUp()`: check XP thresholds, award upgrade points, prompt branch choice
### Collision: `collision.ts`
- `checkBulletPlayerCollisions()`: spatial hash query, damage on overlap
- `checkPlayerOrbCollisions()`: collect orbs within pickup radius
- `checkPlayerObstacleCollisions()`: resolve overlap, apply slow zone effect
- `checkPlayerPlayerCollisions()`: soft collision (push apart)
### Gun System: `gun-system.ts`
- Load `config/guns.json` at startup
- `getGunConfig(id)` / `getGunConfigByIndex(idx)`
- `calculateRecoil()`: random sign * recoilOffset * (1 - recoilStatBonus)
- `calculateKickback()`: -aimVector * kickbackForce
- `calculateBulletSpawn()`: barrel tip position at aimAngle + recoilOffset
### Class Branch System: `branch-system.ts`
- Load `config/branches.json` at startup
- `getAvailableBranches(level, currentBranch)` — filters by levelRequired and parentId
- `applyBranchBonuses(player, branchId)` — applies passive stat multipliers
- `getUnlockedGunCategories(branchIds)` — union of categories
### Validation: `validation.ts`
- `validateMovement()`: speed <= max * (1 + moveSpeedStat)
- `validateAimAngle()`: change <= max_turn_rate per tick
- `validateFireRate()`: timeSinceLastShot >= cooldown / (1 + reloadStat)
- `validateRecoil()`: server-calculated vs client-reported within tolerance
## Client (`packages/client/src/`)
### Entry Point: `main.ts`
- Wait for DOM ready
- Show menu, get player name
- On PLAY click: connect to Colyseus room, hide menu, start game loop
### Renderer: `renderer.ts`
Canvas 2D rendering engine:
- `render()`: called every requestAnimationFrame
- Clear canvas → save → apply camera transform → render world → restore → render HUD
**Render order (world space):**
1. Arena background (solid color + grid lines)
2. Obstacles (walls = grey rects, crates = brown rects, slow zones = blue tint, cover = green)
3. XP orbs (small colored squares/circles)
4. Bullets (filled circles with slight glow, semi-transparent trail circles behind)
5. Players (circles: blue = self, red = others)
6. Gun rendering (rectangle barrel + square body, rotated at aimAngle + recoilOffset)
7. Name tags (above circle, always visible)
8. HP bars (below name tag, green fill / dark bg)
**Gun rendering details:**
- Barrel: rectangle, `GUN_BARREL_WIDTH` x `GUN_BARREL_LENGTH`, attached to player center
- Body: small square `GUN_BODY_SIZE` behind barrel
- Rotation: around player center at `angle + recoilOffset`
- Recovery: lerp recoilOffset toward 0 each frame
**Bullet rendering:**
- Main circle: radius = bulletSize, filled with bulletColor
- Trail: 3-5 semi-transparent smaller circles behind at velocity * -dt positions
- Crit: red outline stroke (2px) when isCritical = true
### Camera: `camera.ts`
- Follow local player with slight lag (lerp at ~0.1 per frame)
- Clamp to arena bounds + viewport padding
- Transform: translate(canvas/2 - camX, canvas/2 - camY)
### Input: `input.ts`
- WASD: track key states, compute moveAngle from active keys
- Mouse: track position, convert screen → world for aimAngle
- Left click: isShooting flag
- Send input at 60Hz (setInterval)
### Networking: `network.ts`
- Connect to Colyseus server via WebSocket
- Client-side prediction: immediately move on WASD, apply velocity
- Server reconciliation: compare predicted pos to server pos, smooth correction
- Entity interpolation: for other players/bullets, lerp between prev and current state
- Bullet confirmation: predict spawn locally, server confirms trajectory
### HUD: `hud.ts`
- HP bar (top-left): green fill, shows current/max
- XP bar (below HP): yellow fill, shows progress to next level
- Ammo counter (bottom-right): current/max, reload indicator
- Level indicator (top-right): large number
- Leaderboard (top-right below level): sorted by XP, shows top 10
- Name above local player: white text, centered
- Upgrade popup: appears on level-up, shows stat buttons + branch choice at milestones
### Menu: `menu.ts`
- Name input (max 16 chars)
- PLAY button → connect → hide menu → show HUD
### Game Loop: `game.ts`
- `update(dt)`: process input, apply prediction, update camera, update HUD
- `render()`: call renderer
- Uses requestAnimationFrame with delta time
- Interpolate entity positions between server ticks
## Networking Protocol
### Client → Server (60Hz)
```
PlayerInput {
seq: uint32 // frame counter
moveAngle: float32 // -1 = none
aimAngle: float32
isShooting: bool
upgradeChoice?: uint8
}
```
### Server → Client (60Hz, delta compressed via Colyseus)
Full RoomState with Player, Bullet, XPOrb maps. Colyseus automatically sends only changed fields.
### Bandwidth Budget
- Target: < 4 KB/s per player downstream
- With 50 players + 100 bullets + 50 orbs, delta should be ~2-3 KB per tick
- Colyseus Schema uses binary encoding + delta compression
## File Structure
```
packages/server/src/
index.ts — server entry point
room.ts — ArenaRoom definition
game-loop.ts — tick loop
physics.ts — spatial hash, collision resolution
player-manager.ts — spawn, death, respawn, upgrades
collision.ts — bullet-player, player-orb, player-obstacle
gun-system.ts — gun config loading, recoil calc
branch-system.ts — class branch loading, bonus application
validation.ts — input validation, anti-cheat
bot-player.ts — AI bot for testing (optional)
packages/client/src/
main.ts — client entry, menu, connect
renderer.ts — Canvas 2D rendering
camera.ts — camera follow + smoothing
input.ts — WASD + mouse input
network.ts — Colyseus client, prediction, reconciliation
hud.ts — UI overlays (HP, XP, leaderboard, upgrades)
game.ts — client game loop (update + render)
interpolation.ts — entity interpolation between ticks
asset-loader.ts — optional image/font loading
```
## Quality Gates
1. Client: 60 FPS with 50 entities (Chrome, mid-tier laptop)
2. Server: 60 Hz tick with 50 players (<10ms per tick)
3. Bandwidth: < 4 KB/s per player downstream
4. 100ms latency feels playable (prediction hides lag)
5. No memory leaks over 10-minute session
## Visual Style (diep.io-like)
- Self: blue circle (#3498db)
- Enemies: red circle (#e74c3c)
- Background: dark (#1a1a2e) with subtle grid
- No particles, no screen shake, no muzzle flash
- Only visual feedback: gun recoil animation
- Clean, minimal, readable
## Skin System (Cosmetic Only)
- Circle color/pattern overrides
- Gun appearance overrides
- Name tag style overrides
- MVP: simple hardcoded palette, shop UI stub
+42
View File
@@ -0,0 +1,42 @@
version: '3.8'
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
server:
build:
context: .
dockerfile: packages/server/Dockerfile
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- REDIS_URL=redis://redis:6379
- PORT=3000
depends_on:
redis:
condition: service_healthy
restart: unless-stopped
client:
build:
context: .
dockerfile: packages/client/Dockerfile
ports:
- "80:80"
depends_on:
- server
restart: unless-stopped
volumes:
redis-data:
+2557
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "guncircle.io",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "pnpm --filter client dev",
"dev:server": "pnpm --filter server dev",
"build": "pnpm --filter shared build && pnpm --filter client build && pnpm --filter server build",
"type-check": "pnpm --filter shared type-check && pnpm --filter client type-check && pnpm --filter server type-check",
"lint": "eslint packages/*/src --ext .ts",
"test": "pnpm --filter shared test && pnpm --filter server test"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",
"typescript": "^5.9.3"
},
"packageManager": "pnpm@9.0.0",
"dependencies": {
"@colyseus/schema": "^4.0.25",
"colyseus.js": "^0.16.22",
"vite": "^8.0.11"
}
}
+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"]
}
+4
View File
@@ -0,0 +1,4 @@
packages:
- packages/shared
- packages/server
- packages/client
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"composite": true,
"baseUrl": ".",
"paths": {
"@guncircle/shared": ["./packages/shared/src"]
}
},
"include": ["packages/*/src"],
"exclude": ["node_modules", "dist"]
}