Initial commit: GunCircle.io multiplayer arena shooter
This commit is contained in:
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user