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