Initial commit: GunCircle.io multiplayer arena shooter
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
*.local
|
||||
.vite/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
@@ -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
|
||||
@@ -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:
|
||||
Generated
+2557
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;"]
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = '';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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"]
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [{ "path": "../shared" }]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["./src"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
packages:
|
||||
- packages/shared
|
||||
- packages/server
|
||||
- packages/client
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user