Initial commit: GunCircle.io multiplayer arena shooter

This commit is contained in:
emil
2026-05-11 15:39:56 +03:00
commit 836441fc2c
45 changed files with 9391 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY pnpm-workspace.yaml package.json ./
COPY packages/shared/package.json packages/shared/
COPY packages/server/package.json packages/server/
RUN pnpm install --frozen-lockfile
COPY packages/shared/ packages/shared/
COPY packages/server/ packages/server/
RUN pnpm --filter @guncircle/shared build
RUN pnpm --filter @guncircle/server build
FROM node:22-alpine
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY pnpm-workspace.yaml package.json ./
COPY --from=builder /app/packages/shared/dist packages/shared/dist
COPY --from=builder /app/packages/shared/package.json packages/shared/
COPY --from=builder /app/packages/server/dist packages/server/dist
COPY --from=builder /app/packages/server/package.json packages/server/
COPY --from=builder /app/packages/server/config packages/server/config
RUN pnpm install --prod --frozen-lockfile
EXPOSE 3000
CMD ["node", "packages/server/dist/index.js"]
+175
View File
@@ -0,0 +1,175 @@
{
"branches": [
{
"id": "fighter",
"name": "Fighter",
"description": "Balanced combatant. +10% Max HP, +5% Bullet Damage.",
"levelRequired": 5,
"parentId": null,
"unlocksCategories": [0, 1],
"passiveBonuses": { "0": 0.10, "3": 0.05 }
},
{
"id": "shotgunner",
"name": "Shotgunner",
"description": "Close-range powerhouse. Unlocks shotguns. +15% Max HP, -10% Recoil.",
"levelRequired": 5,
"parentId": null,
"unlocksCategories": [2],
"passiveBonuses": { "0": 0.15, "6": 0.10 }
},
{
"id": "scout",
"name": "Scout",
"description": "Fast and evasive. +15% Movement Speed, +5% Reload Speed.",
"levelRequired": 5,
"parentId": null,
"unlocksCategories": [4],
"passiveBonuses": { "2": 0.15, "5": 0.05 }
},
{
"id": "soldier",
"name": "Soldier",
"description": "Veteran warrior. +15% Bullet Damage, +10% Bullet Speed.",
"levelRequired": 10,
"parentId": "fighter",
"unlocksCategories": [1],
"passiveBonuses": { "3": 0.15, "4": 0.10 }
},
{
"id": "bruiser",
"name": "Bruiser",
"description": "Tough tank. +20% Max HP, +10% HP Regen.",
"levelRequired": 10,
"parentId": "fighter",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.20, "1": 0.10 }
},
{
"id": "blaster",
"name": "Blaster",
"description": "Explosive force. +20% Bullet Damage, +10% Crit Chance.",
"levelRequired": 15,
"parentId": "soldier",
"unlocksCategories": [],
"passiveBonuses": { "3": 0.20, "7": 0.10 }
},
{
"id": "slayer",
"name": "Slayer",
"description": "Ultimate hunter. +25% Bullet Damage, +15% Crit Damage.",
"levelRequired": 30,
"parentId": "blaster",
"unlocksCategories": [],
"passiveBonuses": { "3": 0.25, "8": 0.15 }
},
{
"id": "destroyer",
"name": "Destroyer",
"description": "Peak power. +20% all damage stats, +10% Max HP.",
"levelRequired": 45,
"parentId": "slayer",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.10, "3": 0.20, "7": 0.10, "8": 0.10 }
},
{
"id": "charger",
"name": "Charger",
"description": "Bull rush expert. +15% Movement Speed, +10% Bullet Damage.",
"levelRequired": 15,
"parentId": "bruiser",
"unlocksCategories": [],
"passiveBonuses": { "2": 0.15, "3": 0.10 }
},
{
"id": "juggernaut",
"name": "Juggernaut",
"description": "Unstoppable. +25% Max HP, +15% HP Regen.",
"levelRequired": 30,
"parentId": "charger",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.25, "1": 0.15 }
},
{
"id": "colossus",
"name": "Colossus",
"description": "Living fortress. +30% Max HP, +20% HP Regen, +10% Damage.",
"levelRequired": 45,
"parentId": "juggernaut",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.30, "1": 0.20, "3": 0.10 }
},
{
"id": "scatter",
"name": "Scatter",
"description": "Spread shot master. +15% Reload Speed, -15% Recoil.",
"levelRequired": 10,
"parentId": "shotgunner",
"unlocksCategories": [2],
"passiveBonuses": { "5": 0.15, "6": 0.15 }
},
{
"id": "breaker",
"name": "Breaker",
"description": "Armor breaker. +20% Bullet Damage, +10% Bullet Speed.",
"levelRequired": 15,
"parentId": "scatter",
"unlocksCategories": [],
"passiveBonuses": { "3": 0.20, "4": 0.10 }
},
{
"id": "havoc",
"name": "Havoc",
"description": "Chaos incarnate. +25% Reload Speed, +15% Movement Speed.",
"levelRequired": 30,
"parentId": "breaker",
"unlocksCategories": [],
"passiveBonuses": { "2": 0.15, "5": 0.25 }
},
{
"id": "annihilator",
"name": "Annihilator",
"description": "Total devastation. +20% all stats.",
"levelRequired": 45,
"parentId": "havoc",
"unlocksCategories": [],
"passiveBonuses": { "0": 0.10, "2": 0.10, "3": 0.15, "5": 0.15 }
},
{
"id": "runner",
"name": "Runner",
"description": "Speed demon. +20% Movement Speed, +10% Reload Speed.",
"levelRequired": 10,
"parentId": "scout",
"unlocksCategories": [4],
"passiveBonuses": { "2": 0.20, "5": 0.10 }
},
{
"id": "stalker",
"name": "Stalker",
"description": "Stealth marksman. +15% Bullet Speed, +10% Crit Chance.",
"levelRequired": 15,
"parentId": "runner",
"unlocksCategories": [3],
"passiveBonuses": { "4": 0.15, "7": 0.10 }
},
{
"id": "phantom",
"name": "Phantom",
"description": "Ghost warrior. +20% Movement Speed, +15% Crit Damage.",
"levelRequired": 30,
"parentId": "stalker",
"unlocksCategories": [],
"passiveBonuses": { "2": 0.20, "8": 0.15 }
},
{
"id": "assassin",
"name": "Assassin",
"description": "Silent death. +25% Crit Chance, +25% Crit Damage.",
"levelRequired": 45,
"parentId": "phantom",
"unlocksCategories": [],
"passiveBonuses": { "7": 0.25, "8": 0.25 }
}
]
}
+99
View File
@@ -0,0 +1,99 @@
{
"guns": [
{
"id": "pistol",
"name": "Pistol",
"category": 0,
"damage": 12,
"fireRate": 4,
"bulletSpeed": 400,
"pelletCount": 1,
"spread": 0,
"recoilOffset": 0.06,
"recoilRecoveryMs": 120,
"kickbackForce": 30,
"ammoMax": 12,
"reloadTimeMs": 1500,
"bulletSize": 4,
"penetration": 1,
"bulletLifetimeMs": 2000,
"bulletColor": "#f1c40f"
},
{
"id": "rifle",
"name": "Rifle",
"category": 1,
"damage": 18,
"fireRate": 3,
"bulletSpeed": 500,
"pelletCount": 1,
"spread": 0.02,
"recoilOffset": 0.08,
"recoilRecoveryMs": 180,
"kickbackForce": 45,
"ammoMax": 8,
"reloadTimeMs": 2000,
"bulletSize": 4.5,
"penetration": 2,
"bulletLifetimeMs": 2500,
"bulletColor": "#e67e22"
},
{
"id": "shotgun",
"name": "Shotgun",
"category": 2,
"damage": 8,
"fireRate": 1.2,
"bulletSpeed": 320,
"pelletCount": 5,
"spread": 0.25,
"recoilOffset": 0.15,
"recoilRecoveryMs": 300,
"kickbackForce": 80,
"ammoMax": 5,
"reloadTimeMs": 2500,
"bulletSize": 3,
"penetration": 1,
"bulletLifetimeMs": 1500,
"bulletColor": "#9b59b6"
},
{
"id": "sniper",
"name": "Sniper",
"category": 3,
"damage": 45,
"fireRate": 0.8,
"bulletSpeed": 700,
"pelletCount": 1,
"spread": 0,
"recoilOffset": 0.2,
"recoilRecoveryMs": 500,
"kickbackForce": 120,
"ammoMax": 3,
"reloadTimeMs": 3000,
"bulletSize": 6,
"penetration": 3,
"bulletLifetimeMs": 3500,
"bulletColor": "#e74c3c"
},
{
"id": "smg",
"name": "SMG",
"category": 4,
"damage": 6,
"fireRate": 10,
"bulletSpeed": 380,
"pelletCount": 1,
"spread": 0.06,
"recoilOffset": 0.04,
"recoilRecoveryMs": 80,
"kickbackForce": 20,
"ammoMax": 30,
"reloadTimeMs": 1800,
"bulletSize": 3,
"penetration": 1,
"bulletLifetimeMs": 1800,
"bulletColor": "#2ecc71"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "@guncircle/server",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"type-check": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@colyseus/core": "^0.16.0",
"@colyseus/schema": "^3.0.0",
"@colyseus/uwebsockets-transport": "^0.16.0",
"@guncircle/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.0.0",
"typescript": "^5.6.0",
"vitest": "^2.0.0"
}
}
+241
View File
@@ -0,0 +1,241 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Branch (Class) System
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import {
type ClassBranch,
type Player,
STAT_COUNT,
Stat,
STAT_BONUSES,
STAT_MAX_LEVEL,
} from '@guncircle/shared';
// ─── Branch Config Container ─────────────────────────────────────────────────
interface BranchesJson {
branches: ClassBranch[];
}
// ─── BranchSystem ────────────────────────────────────────────────────────────
export class BranchSystem {
private branches: ClassBranch[] = [];
constructor() {
this.loadConfigs();
}
private loadConfigs(): void {
try {
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, '../config/branches.json');
const raw = readFileSync(configPath, 'utf-8');
const parsed: BranchesJson = JSON.parse(raw) as BranchesJson;
this.branches = parsed.branches ?? [];
} catch {
this.branches = [];
}
}
/** Total branches loaded */
getBranchCount(): number {
return this.branches.length;
}
/** Get branch by string id */
getBranchById(id: string): ClassBranch | undefined {
return this.branches.find((b) => b.id === id);
}
/** Get branch by 0-based index (used for classType field) */
getBranchByIndex(index: number): ClassBranch | undefined {
if (index < 0 || index >= this.branches.length) return undefined;
return this.branches[index];
}
/** Get the index of a branch by its string id */
getBranchIndexById(id: string): number {
return this.branches.findIndex((b) => b.id === id);
}
/** Get all loaded branches */
getAllBranches(): readonly ClassBranch[] {
return this.branches;
}
/**
* Get branches available to a player at their current level.
* Filters by: levelRequired <= player level, parent matches current branch path.
*/
getAvailableBranches(level: number, currentBranchId: string | null): ClassBranch[] {
return this.branches.filter((branch) => {
// Must meet level requirement
if (branch.levelRequired > level) return false;
// Root branches (parentId === null) are always available at their level
if (branch.parentId === null) {
// Only show root branches if player hasn't chosen a branch yet
// or if they're at the exact milestone level
return currentBranchId === null || this.isDirectRootChoice(branch, currentBranchId);
}
// Non-root branches require parent to be in the player's branch path
return this.isInBranchPath(branch.parentId, currentBranchId);
});
}
/**
* Check if a branch is a direct root choice for a player.
* A root branch is a direct choice if the player has no branch yet.
*/
private isDirectRootChoice(rootBranch: ClassBranch, currentBranchId: string | null): boolean {
if (currentBranchId === null) return true;
// If player has a branch, root branches are not direct choices anymore
return false;
}
/**
* Check if `ancestorId` is in the branch path leading to `descendantId`.
* This includes the descendant itself.
*/
isInBranchPath(ancestorId: string, descendantId: string | null): boolean {
if (descendantId === null) return false;
if (ancestorId === descendantId) return true;
let current: ClassBranch | undefined = this.getBranchById(descendantId);
while (current !== undefined && current.parentId !== null) {
if (current.parentId === ancestorId) return true;
current = this.getBranchById(current.parentId);
}
return false;
}
/**
* Get the root branch id in a player's branch path.
* Walks up the parent chain to find the top-level ancestor.
*/
getRootBranchId(branchId: string | null): string | null {
if (branchId === null) return null;
let current: ClassBranch | undefined = this.getBranchById(branchId);
while (current !== undefined && current.parentId !== null) {
current = this.getBranchById(current.parentId);
}
return current?.id ?? null;
}
/**
* Get all branch IDs in the path from root to the given branch.
*/
getBranchPath(branchId: string | null): string[] {
const path: string[] = [];
if (branchId === null) return path;
// Walk up to root first
const ancestors: string[] = [];
let current: ClassBranch | undefined = this.getBranchById(branchId);
while (current !== undefined) {
ancestors.unshift(current.id);
if (current.parentId === null) break;
current = this.getBranchById(current.parentId);
}
return ancestors;
}
/**
* Apply passive stat bonuses from a branch to a player's stat levels.
* This returns the total passive bonus for each stat from the branch tree.
*/
calculateBranchBonuses(player: Player): Record<Stat, number> {
const bonuses: Record<Stat, number> = {
[Stat.MaxHp]: 0,
[Stat.HpRegen]: 0,
[Stat.MovementSpeed]: 0,
[Stat.BulletDamage]: 0,
[Stat.BulletSpeed]: 0,
[Stat.ReloadSpeed]: 0,
[Stat.RecoilStability]: 0,
[Stat.CritChance]: 0,
[Stat.CritDamage]: 0,
};
// Get the branch path and sum all passive bonuses
const branchId: string | null =
player.classType > 0 ? this.getBranchByIndex(player.classType)?.id ?? null : null;
if (branchId === null) return bonuses;
const path: string[] = this.getBranchPath(branchId);
for (const bid of path) {
const branch: ClassBranch | undefined = this.getBranchById(bid);
if (branch === undefined) continue;
for (const [statKeyStr, value] of Object.entries(branch.passiveBonuses)) {
const statIdx: number = parseInt(statKeyStr, 10);
if (statIdx >= 0 && statIdx < STAT_COUNT && value !== undefined) {
bonuses[statIdx as Stat] += value;
}
}
}
return bonuses;
}
/**
* Get the effective stat value for a player, including branch bonuses.
* Returns the player's invested stat level + branch passive bonus.
*/
getEffectiveStatLevel(player: Player, stat: Stat): number {
const investedLevel: number = player.stats[stat] ?? 0;
const bonuses: Record<Stat, number> = this.calculateBranchBonuses(player);
return investedLevel + bonuses[stat];
}
/**
* Apply branch selection to a player. Sets their classType index.
*/
applyBranch(player: Player, branchId: string): boolean {
const branchIdx: number = this.getBranchIndexById(branchId);
if (branchIdx < 0) return false;
player.classType = branchIdx;
return true;
}
/**
* Get all gun category indices unlocked by a player's branch path.
*/
getUnlockedGunCategories(player: Player): number[] {
const branchId: string | null =
player.classType > 0 ? this.getBranchByIndex(player.classType)?.id ?? null : null;
if (branchId === null) return [0]; // Default: only pistols
const path: string[] = this.getBranchPath(branchId);
const categories: Set<number> = new Set<number>();
// Always include pistol
categories.add(0);
for (const bid of path) {
const branch: ClassBranch | undefined = this.getBranchById(bid);
if (branch === undefined) continue;
for (const cat of branch.unlocksCategories) {
categories.add(cat);
}
}
return Array.from(categories).sort((a, b) => a - b);
}
/**
* Check if a gun category is unlocked for a player.
*/
isGunCategoryUnlocked(player: Player, category: number): boolean {
const unlocked: number[] = this.getUnlockedGunCategories(player);
return unlocked.includes(category);
}
}
+306
View File
@@ -0,0 +1,306 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Collision System
// ═══════════════════════════════════════════════════════════════════════════════
import {
type Player,
type Bullet,
type XPOrb,
type Obstacle,
type RoomState,
SpatialHashGrid,
circleCollisionResolve,
vec2,
type Vec2,
SPATIAL_CELL_SIZE,
ARENA_WIDTH,
ARENA_HEIGHT,
PLAYER_BASE_RADIUS,
} from '@guncircle/shared';
// ─── Identifiable Entity for Spatial Hash ────────────────────────────────────
interface SpatialEntity {
id: number;
x: number;
y: number;
}
// ─── CollisionCallbacks ──────────────────────────────────────────────────────
export interface BulletHitCallback {
(bullet: Bullet, bulletId: string, victim: Player, victimId: string): void;
}
export interface OrbCollectCallback {
(player: Player, playerId: string, orb: XPOrb, orbId: string): void;
}
export interface ObstacleCollideCallback {
(player: Player, playerId: string, obstacle: Obstacle): void;
}
// ─── CollisionSystem ─────────────────────────────────────────────────────────
export class CollisionSystem {
private bulletGrid: SpatialHashGrid<SpatialEntity>;
private playerGrid: SpatialHashGrid<SpatialEntity>;
constructor() {
this.bulletGrid = new SpatialHashGrid<SpatialEntity>(SPATIAL_CELL_SIZE);
this.playerGrid = new SpatialHashGrid<SpatialEntity>(SPATIAL_CELL_SIZE);
}
// ─── Grid Building ─────────────────────────────────────────────────────────
/** Rebuild the spatial hash grid for bullets. */
buildBulletGrid(bullets: Map<string, Bullet>): void {
this.bulletGrid.clear();
let idx = 0;
for (const [id, bullet] of bullets.entries()) {
const entity: SpatialEntity = {
id: idx++,
x: bullet.x,
y: bullet.y,
};
this.bulletGrid.insert(entity);
}
}
/** Rebuild the spatial hash grid for players. */
buildPlayerGrid(players: Map<string, Player>): void {
this.playerGrid.clear();
let idx = 0;
for (const [id, player] of players.entries()) {
if (!player.alive) continue;
const entity: SpatialEntity = {
id: idx++,
x: player.x,
y: player.y,
};
this.playerGrid.insert(entity);
}
}
// ─── Bullet vs Player ──────────────────────────────────────────────────────
/**
* Check all bullet-player collisions. For each bullet, query nearby players.
* Excludes the bullet's owner. Calls onHit for each valid collision.
*/
checkBulletPlayerCollisions(
bullets: Map<string, Bullet>,
players: Map<string, Player>,
onHit: BulletHitCallback
): void {
for (const [bulletId, bullet] of bullets.entries()) {
// Skip bullets with no penetration left
if (bullet.penetration <= 0) continue;
for (const [playerId, player] of players.entries()) {
// Don't hit the owner
if (bullet.ownerId === playerId) continue;
if (!player.alive) continue;
// Quick distance check
const dx: number = bullet.x - player.x;
const dy: number = bullet.y - player.y;
const r: number = bullet.size + player.radius;
const distSq: number = dx * dx + dy * dy;
if (distSq < r * r) {
onHit(bullet, bulletId, player, playerId);
if (bullet.penetration <= 0) break;
}
}
}
}
// ─── Player vs XP Orb ──────────────────────────────────────────────────────
/**
* Check all player-orb collisions. Pickup radius is ~30 units.
*/
checkPlayerOrbCollisions(
players: Map<string, Player>,
orbs: Map<string, XPOrb>,
onCollect: OrbCollectCallback
): void {
const PICKUP_RADIUS: number = 30;
const PICKUP_RADIUS_SQ: number = PICKUP_RADIUS * PICKUP_RADIUS;
for (const [playerId, player] of players.entries()) {
if (!player.alive) continue;
for (const [orbId, orb] of orbs.entries()) {
const dx: number = player.x - orb.x;
const dy: number = player.y - orb.y;
const distSq: number = dx * dx + dy * dy;
if (distSq < PICKUP_RADIUS_SQ) {
onCollect(player, playerId, orb, orbId);
}
}
}
}
// ─── Player vs Obstacle ────────────────────────────────────────────────────
/**
* Check all player-obstacle collisions. Resolve overlap by pushing player out.
* Obstacles are treated as AABB (axis-aligned bounding boxes) with circle overlap.
*/
checkPlayerObstacleCollisions(
players: Map<string, Player>,
obstacles: Map<string, Obstacle>,
onCollide?: ObstacleCollideCallback
): void {
for (const [playerId, player] of players.entries()) {
if (!player.alive) continue;
for (const [obsId, obstacle] of obstacles.entries()) {
this.resolvePlayerObstacle(player, obstacle, onCollide, playerId);
}
}
}
/**
* Resolve a single player-obstacle collision. Obstacle is AABB.
*/
private resolvePlayerObstacle(
player: Player,
obstacle: Obstacle,
onCollide: ObstacleCollideCallback | undefined,
playerId: string
): void {
// Find closest point on obstacle rectangle to player center
const closestX: number = Math.max(
obstacle.x,
Math.min(obstacle.x + obstacle.width, player.x)
);
const closestY: number = Math.max(
obstacle.y,
Math.min(obstacle.y + obstacle.height, player.y)
);
const dx: number = player.x - closestX;
const dy: number = player.y - closestY;
const distSq: number = dx * dx + dy * dy;
if (distSq < player.radius * player.radius) {
const dist: number = Math.sqrt(distSq);
const overlap: number = player.radius - dist;
if (dist === 0) {
// Player center is inside obstacle — push toward nearest edge
const leftDist: number = Math.abs(player.x - obstacle.x);
const rightDist: number = Math.abs(player.x - (obstacle.x + obstacle.width));
const topDist: number = Math.abs(player.y - obstacle.y);
const bottomDist: number = Math.abs(player.y - (obstacle.y + obstacle.height));
const minDist: number = Math.min(leftDist, rightDist, topDist, bottomDist);
if (minDist === leftDist) {
player.x -= overlap;
} else if (minDist === rightDist) {
player.x += overlap;
} else if (minDist === topDist) {
player.y -= overlap;
} else {
player.y += overlap;
}
} else {
const nx: number = dx / dist;
const ny: number = dy / dist;
player.x += nx * overlap;
player.y += ny * overlap;
}
onCollide?.(player, playerId, obstacle);
}
}
// ─── Player vs Player ──────────────────────────────────────────────────────
/**
* Check all player-player collisions. Apply soft push apart.
* Uses O(n^2) for small player counts; spatial hash can be used for optimization.
*/
checkPlayerPlayerCollisions(players: Map<string, Player>): void {
const playerList: { id: string; player: Player }[] = [];
for (const [id, player] of players.entries()) {
if (player.alive) {
playerList.push({ id, player });
}
}
for (let i = 0; i < playerList.length; i++) {
for (let j = i + 1; j < playerList.length; j++) {
const a = playerList[i].player;
const b = playerList[j].player;
const dx: number = a.x - b.x;
const dy: number = a.y - b.y;
const distSq: number = dx * dx + dy * dy;
const minDist: number = a.radius + b.radius;
if (distSq < minDist * minDist && distSq > 0) {
const dist: number = Math.sqrt(distSq);
const overlap: number = minDist - dist;
const nx: number = dx / dist;
const ny: number = dy / dist;
// Soft push — each moves half the overlap
const pushX: number = nx * overlap * 0.5;
const pushY: number = ny * overlap * 0.5;
a.x += pushX;
a.y += pushY;
b.x -= pushX;
b.y -= pushY;
}
}
}
}
// ─── Arena Bounds ──────────────────────────────────────────────────────────
/**
* Clamp all alive players to arena bounds.
*/
checkArenaBounds(players: Map<string, Player>): void {
for (const player of players.values()) {
if (!player.alive) continue;
const minX: number = player.radius;
const minY: number = player.radius;
const maxX: number = ARENA_WIDTH - player.radius;
const maxY: number = ARENA_HEIGHT - player.radius;
if (player.x < minX) player.x = minX;
if (player.x > maxX) player.x = maxX;
if (player.y < minY) player.y = minY;
if (player.y > maxY) player.y = maxY;
}
}
/**
* Clamp bullets to arena bounds. Returns set of bullet IDs that are out of bounds.
*/
checkBulletBounds(bullets: Map<string, Bullet>): Set<string> {
const outOfBounds: Set<string> = new Set<string>();
for (const [id, bullet] of bullets.entries()) {
if (
bullet.x < -bullet.size ||
bullet.x > ARENA_WIDTH + bullet.size ||
bullet.y < -bullet.size ||
bullet.y > ARENA_HEIGHT + bullet.size
) {
outOfBounds.add(id);
}
}
return outOfBounds;
}
}
+417
View File
@@ -0,0 +1,417 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Game Loop
// ═══════════════════════════════════════════════════════════════════════════════
import {
RoomState,
Player,
Bullet,
XPOrb,
LeaderboardEntry,
type PlayerInput,
TICK_MS,
PLAYER_FRICTION,
} from '@guncircle/shared';
import { PlayerManager } from './player-manager.js';
import { CollisionSystem } from './collision.js';
import { PhysicsEngine } from './physics.js';
import { GunSystem } from './gun-system.js';
import { BranchSystem } from './branch-system.js';
// ─── Pending Input per Player ────────────────────────────────────────────────
interface PendingInput {
input: PlayerInput;
playerId: string;
}
// ─── GameLoop ────────────────────────────────────────────────────────────────
export class GameLoop {
private state: RoomState;
private playerManager: PlayerManager;
private collisionSystem: CollisionSystem;
private physics: PhysicsEngine;
private gunSystem: GunSystem;
private branchSystem: BranchSystem;
private pendingInputs: PendingInput[] = [];
private bulletLifetimes: Map<string, number> = new Map();
private startTime: number;
private tickCount: number = 0;
// Callbacks for room to handle player events
onPlayerDeath: ((playerId: string, killerId?: string) => void) | undefined;
onPlayerLevelUp: ((playerId: string) => void) | undefined;
constructor(
state: RoomState,
playerManager: PlayerManager,
collisionSystem: CollisionSystem,
physics: PhysicsEngine,
gunSystem: GunSystem,
branchSystem: BranchSystem
) {
this.state = state;
this.playerManager = playerManager;
this.collisionSystem = collisionSystem;
this.physics = physics;
this.gunSystem = gunSystem;
this.branchSystem = branchSystem;
this.startTime = Date.now();
}
/**
* Queue an input to be processed on the next tick.
*/
queueInput(playerId: string, input: PlayerInput): void {
this.pendingInputs.push({ playerId, input });
}
/**
* The core tick function. Called every TICK_MS.
* Order of operations is critical for deterministic simulation.
*/
tick(): void {
this.tickCount++;
const now: number = Date.now();
const dt: number = TICK_MS / 1000; // seconds
const dtMs: number = TICK_MS; // milliseconds
// 1. Increment tick counter
this.state.tick = this.tickCount;
// 2. Process pending inputs
this.processInputs(now, dt);
// 3. Update bullet positions
this.updateBullets(dt);
// 4. Update player positions (apply velocity, friction, clamp)
this.updatePlayers(dt);
// 5. Check all collisions
this.checkCollisions();
// 6. Apply recoil recovery
this.updateRecoil(dt);
// 7. Update reload timers, auto-reload when empty
this.playerManager.updateReloads(this.state.players, dtMs);
this.checkAutoReload();
// 8. Regenerate HP for alive players
this.regenerateHp(dt);
// 9. Update XP orb lifetimes, remove expired
this.updateXpOrbs(dt);
// 10. Process pending respawns
this.playerManager.processRespawns(this.state);
// 11. Rebuild leaderboard (top 10 by XP)
this.rebuildLeaderboard();
// 12. Remove expired bullets (lifetime exceeded)
this.removeExpiredBullets(now);
// Clean up processed inputs
this.pendingInputs = [];
}
// ─── Input Processing ──────────────────────────────────────────────────────
private processInputs(now: number, dt: number): void {
for (const pending of this.pendingInputs) {
const player: Player | undefined = this.state.players.get(pending.playerId);
if (player === undefined) continue;
if (!player.alive) continue;
this.playerManager.applyInput(
player,
pending.input,
pending.playerId,
this.state,
now,
dt
);
// Handle upgrade choices
if (pending.input.upgradeChoice !== undefined && player.upgradePoints > 0) {
const success: boolean = this.playerManager.applyUpgrade(
player,
pending.input.upgradeChoice
);
if (success && this.onPlayerLevelUp !== undefined) {
this.onPlayerLevelUp(pending.playerId);
}
}
}
}
// ─── Bullet Update ─────────────────────────────────────────────────────────
private updateBullets(dt: number): void {
for (const [bulletId, bullet] of this.state.bullets.entries()) {
// Update position
bullet.x += bullet.vx * dt;
bullet.y += bullet.vy * dt;
}
}
// ─── Player Update ─────────────────────────────────────────────────────────
private updatePlayers(dt: number): void {
for (const player of this.state.players.values()) {
if (!player.alive) continue;
// Apply friction to velocity
this.physics.applyFrictionToPlayer(player, PLAYER_FRICTION);
// Update position
this.physics.updatePosition(player, dt);
// Clamp to arena
this.physics.clampToArena(player);
}
}
// ─── Collision Checks ──────────────────────────────────────────────────────
private checkCollisions(): void {
// Bullet vs Player
this.collisionSystem.checkBulletPlayerCollisions(
this.state.bullets,
this.state.players,
(bullet, bulletId, victim, victimId) => {
this.handleBulletHit(bullet, bulletId, victim, victimId);
}
);
// Player vs XP Orb
this.collisionSystem.checkPlayerOrbCollisions(
this.state.players,
this.state.xpOrbs,
(player, playerId, orb, orbId) => {
this.handleOrbCollect(player, playerId, orb, orbId);
}
);
// Player vs Obstacle
this.collisionSystem.checkPlayerObstacleCollisions(
this.state.players,
this.state.obstacles
);
// Player vs Player (soft push)
this.collisionSystem.checkPlayerPlayerCollisions(this.state.players);
// Arena bounds
this.collisionSystem.checkArenaBounds(this.state.players);
// Bullet out of bounds
const outOfBounds: Set<string> =
this.collisionSystem.checkBulletBounds(this.state.bullets);
for (const id of outOfBounds) {
this.state.bullets.delete(id);
this.bulletLifetimes.delete(id);
}
}
// ─── Bullet Hit Handling ───────────────────────────────────────────────────
private handleBulletHit(
bullet: Bullet,
bulletId: string,
victim: Player,
victimId: string
): void {
if (!victim.alive) return;
if (bullet.penetration <= 0) return;
// Apply damage
const died: boolean = this.playerManager.takeDamage(
victim,
bullet.damage,
bullet.isCritical
);
// Reduce bullet penetration
bullet.penetration -= 1;
// Remove bullet if no penetration left
if (bullet.penetration <= 0) {
this.state.bullets.delete(bulletId);
this.bulletLifetimes.delete(bulletId);
}
// Check death
if (died) {
this.playerManager.onDeath(victim, victimId, this.state, bullet.ownerId);
if (this.onPlayerDeath !== undefined) {
this.onPlayerDeath(victimId, bullet.ownerId);
}
}
}
// ─── XP Orb Collection ─────────────────────────────────────────────────────
private handleOrbCollect(
player: Player,
playerId: string,
orb: XPOrb,
orbId: string
): void {
if (!player.alive) return;
player.xp += orb.value;
this.state.xpOrbs.delete(orbId);
// Check level up
const leveledUp: boolean = this.playerManager.checkLevelUp(player);
if (leveledUp && this.onPlayerLevelUp !== undefined) {
this.onPlayerLevelUp(playerId);
}
}
// ─── Recoil Recovery ───────────────────────────────────────────────────────
private updateRecoil(dt: number): void {
for (const player of this.state.players.values()) {
if (!player.alive) continue;
if (player.recoilOffset === 0) continue;
const gun = this.gunSystem.getGunConfig(player.gunType);
if (gun === undefined) continue;
this.physics.applyRecoilRecovery(player, dt, gun.recoilRecoveryMs);
}
}
// ─── Auto Reload ───────────────────────────────────────────────────────────
private checkAutoReload(): void {
for (const [playerId, player] of this.state.players.entries()) {
if (!player.alive) continue;
if (player.isReloading) continue;
if (player.ammo <= 0) {
this.playerManager.startReload(player, playerId);
}
}
}
// ─── HP Regeneration ───────────────────────────────────────────────────────
private regenerateHp(dt: number): void {
for (const player of this.state.players.values()) {
if (!player.alive) continue;
if (player.hp >= player.maxHp) continue;
const regenPerSec: number = this.playerManager.calculateHpRegen(player);
player.hp = Math.min(player.maxHp, player.hp + regenPerSec * dt);
}
}
// ─── XP Orb Lifetime ───────────────────────────────────────────────────────
private updateXpOrbs(dt: number): void {
const toRemove: string[] = [];
for (const [orbId, orb] of this.state.xpOrbs.entries()) {
orb.lifetime -= dt;
if (orb.lifetime <= 0) {
toRemove.push(orbId);
}
}
for (const id of toRemove) {
this.state.xpOrbs.delete(id);
}
}
// ─── Leaderboard ───────────────────────────────────────────────────────────
private rebuildLeaderboard(): void {
// Collect all players
const entries: { name: string; level: number; score: number; xp: number }[] = [];
for (const player of this.state.players.values()) {
entries.push({
name: player.name,
level: player.level,
score: player.score,
xp: player.xp,
});
}
// Sort by XP descending
entries.sort((a, b) => b.xp - a.xp);
// Take top 10
const topN: number = Math.min(10, entries.length);
// Clear and rebuild
this.state.leaderboard.clear();
for (let i = 0; i < topN; i++) {
const entry = entries[i];
const lbEntry: LeaderboardEntry = new LeaderboardEntry();
lbEntry.name = entry.name;
lbEntry.level = entry.level;
lbEntry.score = entry.score;
lbEntry.xp = entry.xp;
this.state.leaderboard.push(lbEntry);
}
}
// ─── Expired Bullet Cleanup ────────────────────────────────────────────────
private removeExpiredBullets(now: number): void {
const toRemove: string[] = [];
for (const [bulletId, bullet] of this.state.bullets.entries()) {
const gun = this.gunSystem.getGunConfig(bullet.bulletType);
if (gun === undefined) continue;
// Track bullet spawn time via lifetime map
let spawnTime: number | undefined = this.bulletLifetimes.get(bulletId);
if (spawnTime === undefined) {
spawnTime = now;
this.bulletLifetimes.set(bulletId, spawnTime);
}
const elapsed: number = now - spawnTime;
if (elapsed > gun.bulletLifetimeMs) {
toRemove.push(bulletId);
}
}
for (const id of toRemove) {
this.state.bullets.delete(id);
this.bulletLifetimes.delete(id);
}
}
// ─── Utility ───────────────────────────────────────────────────────────────
/**
* Get current tick number.
*/
getTick(): number {
return this.tickCount;
}
/**
* Get elapsed time since game loop started.
*/
getElapsedTime(): number {
return Date.now() - this.startTime;
}
/**
* Clean up all resources.
*/
destroy(): void {
this.pendingInputs = [];
this.bulletLifetimes.clear();
}
}
+162
View File
@@ -0,0 +1,162 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Gun System
// ═══════════════════════════════════════════════════════════════════════════════
import { readFileSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import {
vec2,
type Vec2,
type GunConfig,
Bullet,
GUN_BARREL_LENGTH,
STAT_COUNT,
Stat,
randSign,
} from '@guncircle/shared';
// ─── Gun Config Container ────────────────────────────────────────────────────
interface GunsJson {
guns: GunConfig[];
}
// ─── GunSystem ───────────────────────────────────────────────────────────────
export class GunSystem {
private guns: GunConfig[] = [];
constructor() {
this.loadConfigs();
}
private loadConfigs(): void {
try {
const __dirname = dirname(fileURLToPath(import.meta.url));
const configPath = join(__dirname, '../config/guns.json');
const raw = readFileSync(configPath, 'utf-8');
const parsed: GunsJson = JSON.parse(raw) as GunsJson;
this.guns = parsed.guns ?? [];
} catch {
this.guns = [];
}
}
/** Number of gun configs loaded */
getGunCount(): number {
return this.guns.length;
}
/** Get gun config by 0-based index (used for gunType field) */
getGunConfig(index: number): GunConfig | undefined {
if (index < 0 || index >= this.guns.length) return undefined;
return this.guns[index];
}
/** Get gun config by string id */
getGunById(id: string): GunConfig | undefined {
return this.guns.find((g) => g.id === id);
}
/** Get the index of a gun by its string id */
getGunIndexById(id: string): number {
return this.guns.findIndex((g) => g.id === id);
}
/** Get all loaded gun configs */
getAllGuns(): readonly GunConfig[] {
return this.guns;
}
/**
* Calculate recoil offset when firing.
* Returns signed offset: ±gun.recoilOffset * (1 - recoilStabilityStat * 0.1)
*/
calculateRecoil(gun: GunConfig, recoilStabilityStat: number): number {
const stabilityFactor: number = 1 - recoilStabilityStat * 0.1;
const clampedStability: number = Math.max(0, stabilityFactor);
const signedOffset: number = randSign() * gun.recoilOffset * clampedStability;
return signedOffset;
}
/**
* Calculate kickback velocity vector applied to player on shoot.
* Returns vector in direction opposite to aim.
*/
calculateKickback(gun: GunConfig, aimAngle: number): Vec2 {
const aimVector: Vec2 = vec2.fromAngle(aimAngle, 1);
return vec2.mul(aimVector, -gun.kickbackForce);
}
/**
* Get the world-space position where a bullet should spawn (barrel tip).
*/
getBulletSpawnPos(
playerX: number,
playerY: number,
aimAngle: number,
recoilOffset: number
): Vec2 {
const totalAngle: number = aimAngle + recoilOffset;
const barrelOffset: Vec2 = vec2.fromAngle(totalAngle, GUN_BARREL_LENGTH);
return {
x: playerX + barrelOffset.x,
y: playerY + barrelOffset.y,
};
}
/**
* Create a Bullet schema instance from gun parameters.
*/
createBullet(
gun: GunConfig,
ownerId: string,
spawnPos: Vec2,
angle: number,
damage: number,
isCrit: boolean
): Bullet {
const bullet: Bullet = new Bullet();
bullet.x = spawnPos.x;
bullet.y = spawnPos.y;
bullet.angle = angle;
bullet.ownerId = ownerId;
bullet.damage = damage;
bullet.penetration = gun.penetration;
bullet.bulletType = this.getGunIndexById(gun.id);
bullet.isCritical = isCrit;
bullet.vx = Math.cos(angle) * gun.bulletSpeed;
bullet.vy = Math.sin(angle) * gun.bulletSpeed;
bullet.size = gun.bulletSize;
bullet.color = gun.bulletColor;
return bullet;
}
/**
* Calculate cooldown between shots in ms, adjusted by reload speed stat.
* cooldown = 1000 / fireRate / (1 + reloadSpdStat * 0.1)
*/
calculateCooldown(gun: GunConfig, reloadSpdStat: number): number {
const baseCooldown: number = 1000 / gun.fireRate;
const reloadBonus: number = 1 + reloadSpdStat * 0.1;
return baseCooldown / reloadBonus;
}
/**
* Calculate reload time in ms, adjusted by reload speed stat.
*/
calculateReloadTime(gun: GunConfig, reloadSpdStat: number): number {
const reloadBonus: number = 1 + reloadSpdStat * 0.1;
return gun.reloadTimeMs / reloadBonus;
}
/**
* Calculate damage per pellet, accounting for shotgun pellet count distribution.
* Total damage is split evenly across pellets.
*/
calculatePelletDamage(baseDamage: number, pelletCount: number): number {
if (pelletCount <= 0) return baseDamage;
return baseDamage / pelletCount;
}
}
+23
View File
@@ -0,0 +1,23 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Server Entry Point
// ═══════════════════════════════════════════════════════════════════════════════
import { Server } from '@colyseus/core';
import { uWebSocketsTransport } from '@colyseus/uwebsockets-transport';
import { ArenaRoom } from './room.js';
const port: number = parseInt(process.env.PORT ?? '3000', 10);
const transport = new uWebSocketsTransport();
const server = new Server({ transport });
server.define('arena', ArenaRoom);
server
.listen(port)
.then(() => {
console.log(`GunCircle.io server listening on port ${port}`);
})
.catch((err: Error) => {
console.error('Failed to start server:', err.message);
process.exit(1);
});
+239
View File
@@ -0,0 +1,239 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Physics Engine
// ═══════════════════════════════════════════════════════════════════════════════
import {
vec2,
type Vec2,
type Bullet,
type Player,
clamp,
ARENA_WIDTH,
ARENA_HEIGHT,
PLAYER_FRICTION,
GUN_RECOVERY_LERP,
} from '@guncircle/shared';
// ─── Entity with position + velocity (duck typing for updatePosition) ────────
export interface PhysicsEntity {
x: number;
y: number;
vx: number;
vy: number;
}
// ─── PhysicsEngine ───────────────────────────────────────────────────────────
export class PhysicsEngine {
/**
* Update entity position by applying velocity over delta time.
* x += vx * dt, y += vy * dt
*/
updatePosition(entity: PhysicsEntity, dt: number): void {
entity.x += entity.vx * dt;
entity.y += entity.vy * dt;
}
/**
* Apply friction to velocity by multiplying by friction factor per tick.
* For per-second friction: factor = Math.pow(frictionPerTick, TICK_RATE)
* Here we use per-tick friction directly.
*/
applyFriction(velocity: Vec2, friction: number): Vec2 {
return {
x: velocity.x * friction,
y: velocity.y * friction,
};
}
/**
* Apply friction directly to a Player's velocity components.
*/
applyFrictionToPlayer(player: Player, friction: number): void {
player.vx *= friction;
player.vy *= friction;
}
/**
* Resolve circle-circle collision by pushing entity A out of entity B.
* Returns true if a collision was resolved.
*/
resolveCollision(
a: { x: number; y: number; radius: number },
b: { x: number; y: number; radius: number },
softness: number = 0.5
): boolean {
const dx: number = a.x - b.x;
const dy: number = a.y - b.y;
const distSq: number = dx * dx + dy * dy;
const minDist: number = a.radius + b.radius;
if (distSq >= minDist * minDist || distSq === 0) return false;
const dist: number = Math.sqrt(distSq);
const overlap: number = minDist - dist;
const nx: number = dx / dist;
const ny: number = dy / dist;
a.x += nx * overlap * softness;
a.y += ny * overlap * softness;
return true;
}
/**
* Clamp player position to arena bounds, accounting for player radius.
*/
clampToArena(player: Player): void {
const minX: number = player.radius;
const minY: number = player.radius;
const maxX: number = ARENA_WIDTH - player.radius;
const maxY: number = ARENA_HEIGHT - player.radius;
player.x = clamp(player.x, minX, maxX);
player.y = clamp(player.y, minY, maxY);
}
/**
* Clamp any entity position to arena bounds (for bullets, orbs, etc).
*/
clampEntityToArena(entity: { x: number; y: number }, radius: number = 0): void {
entity.x = clamp(entity.x, radius, ARENA_WIDTH - radius);
entity.y = clamp(entity.y, radius, ARENA_HEIGHT - radius);
}
/**
* Raycast against a circle. Returns true if the ray from origin in direction dir
* intersects the circle at (cx, cy) with radius cr within maxDist.
*/
raycastCircle(
origin: Vec2,
dir: Vec2,
circle: { x: number; y: number; radius: number },
maxDist: number
): boolean {
const oc: Vec2 = vec2.sub(origin, { x: circle.x, y: circle.y });
const a: number = vec2.dot(dir, dir);
const b: number = 2 * vec2.dot(oc, dir);
const c: number = vec2.dot(oc, oc) - circle.radius * circle.radius;
const discriminant: number = b * b - 4 * a * c;
if (discriminant < 0) return false;
const sqrtDisc: number = Math.sqrt(discriminant);
const t1: number = (-b - sqrtDisc) / (2 * a);
const t2: number = (-b + sqrtDisc) / (2 * a);
// Check if either intersection is within [0, maxDist]
const tMin: number = Math.min(t1, t2);
const tMax: number = Math.max(t1, t2);
return (tMin >= 0 && tMin <= maxDist) || (tMax >= 0 && tMax <= maxDist);
}
/**
* Check if a bullet (treated as a moving circle) overlaps a player circle.
* Uses swept-circle approximation: check endpoint overlap + mid-point.
*/
bulletHitsPlayer(
bullet: Bullet,
player: Player,
dt: number
): boolean {
const bulletSpeed: number = Math.sqrt(bullet.vx * bullet.vx + bullet.vy * bullet.vy);
if (bulletSpeed === 0) {
// Stationary bullet — just check overlap
const dx: number = bullet.x - player.x;
const dy: number = bullet.y - player.y;
const r: number = bullet.size + player.radius;
return dx * dx + dy * dy < r * r;
}
// Current position overlap
const dx: number = bullet.x - player.x;
const dy: number = bullet.y - player.y;
const r: number = bullet.size + player.radius;
if (dx * dx + dy * dy < r * r) return true;
// Check previous position (approximate by stepping back one frame)
const prevX: number = bullet.x - bullet.vx * dt;
const prevY: number = bullet.y - bullet.vy * dt;
const dpx: number = prevX - player.x;
const dpy: number = prevY - player.y;
if (dpx * dpx + dpy * dpy < r * r) return true;
// Check if line segment from prev to current intersects player circle
return this.segmentCircleIntersect(
{ x: prevX, y: prevY },
{ x: bullet.x, y: bullet.y },
{ x: player.x, y: player.y },
r
);
}
/**
* Check if a line segment intersects a circle.
*/
segmentCircleIntersect(
segStart: Vec2,
segEnd: Vec2,
circleCenter: Vec2,
circleRadius: number
): boolean {
const segDir: Vec2 = vec2.sub(segEnd, segStart);
const segLenSq: number = vec2.lenSq(segDir);
if (segLenSq === 0) {
// Degenerate segment — just check point vs circle
const d: number = vec2.dist(segStart, circleCenter);
return d < circleRadius;
}
// Project circle center onto segment
const toCircle: Vec2 = vec2.sub(circleCenter, segStart);
const t: number = clamp(vec2.dot(toCircle, segDir) / segLenSq, 0, 1);
const closest: Vec2 = vec2.add(segStart, vec2.mul(segDir, t));
const dist: number = vec2.dist(closest, circleCenter);
return dist < circleRadius;
}
/**
* Apply recoil recovery: lerp recoilOffset toward 0.
* factor = 1 - exp(-dt / recoveryMs)
*/
applyRecoilRecovery(player: Player, dt: number, recoveryMs: number): void {
if (recoveryMs <= 0) {
player.recoilOffset = 0;
return;
}
const factor: number = 1 - Math.exp(-dt / (recoveryMs / 1000));
player.recoilOffset = player.recoilOffset * (1 - factor);
if (Math.abs(player.recoilOffset) < 0.001) {
player.recoilOffset = 0;
}
}
/**
* Apply recoil recovery using the simpler lerp method (alternative).
*/
applyRecoilRecoveryLerp(player: Player): void {
player.recoilOffset *= 1 - GUN_RECOVERY_LERP;
if (Math.abs(player.recoilOffset) < 0.0001) {
player.recoilOffset = 0;
}
}
/**
* Get distance squared between two entities.
*/
distSqBetween(
a: { x: number; y: number },
b: { x: number; y: number }
): number {
const dx: number = a.x - b.x;
const dy: number = a.y - b.y;
return dx * dx + dy * dy;
}
}
+662
View File
@@ -0,0 +1,662 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Player Manager
// ═══════════════════════════════════════════════════════════════════════════════
import {
Player,
Bullet,
XPOrb,
RoomState,
type PlayerInput,
type GunConfig,
type ClassBranch,
vec2,
type Vec2,
PLAYER_BASE_HP,
PLAYER_BASE_SPEED,
PLAYER_BASE_RADIUS,
PLAYER_FRICTION,
ARENA_WIDTH,
ARENA_HEIGHT,
XP_LEVELS,
MAX_LEVEL,
STAT_COUNT,
STAT_MAX_LEVEL,
STAT_BONUSES,
Stat,
BASE_CRIT_MULTIPLIER,
DEATH_XP_DROP_PCT,
XP_KILL_PCT,
RESPAWN_DELAY_MS,
XP_ORB_LIFETIME_S,
randFloat,
randInt,
clamp,
} from '@guncircle/shared';
import { GunSystem } from './gun-system.js';
import { BranchSystem } from './branch-system.js';
// ─── Pending respawn tracking ────────────────────────────────────────────────
interface PendingRespawn {
playerId: string;
respawnAt: number;
}
// ─── PlayerManager ───────────────────────────────────────────────────────────
export class PlayerManager {
private gunSystem: GunSystem;
private branchSystem: BranchSystem;
private pendingRespawns: PendingRespawn[] = [];
private lastShotTimes: Map<string, number> = new Map();
private reloadTimers: Map<string, number> = new Map();
private nextOrbId: number = 0;
constructor(gunSystem: GunSystem, branchSystem: BranchSystem) {
this.gunSystem = gunSystem;
this.branchSystem = branchSystem;
}
// ─── Spawning ──────────────────────────────────────────────────────────────
/**
* Create and spawn a new player at a random position with base stats.
*/
spawnPlayer(id: string, name: string, state: RoomState): Player {
const player: Player = new Player();
const spawnPos: Vec2 = this.randomSpawnPosition();
player.x = spawnPos.x;
player.y = spawnPos.y;
player.name = name.substring(0, 16);
player.hp = PLAYER_BASE_HP;
player.maxHp = PLAYER_BASE_HP;
player.level = 1;
player.xp = 0;
player.xpToNext = XP_LEVELS[1] ?? 50;
player.gunType = 0; // Pistol
player.classType = 0; // No branch
player.score = 0;
player.alive = true;
player.radius = PLAYER_BASE_RADIUS;
player.upgradePoints = 0;
player.recoilOffset = 0;
player.skinId = 0;
player.vx = 0;
player.vy = 0;
// Set base gun stats
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(0);
if (gun !== undefined) {
player.ammo = gun.ammoMax;
player.maxAmmo = gun.ammoMax;
} else {
player.ammo = 12;
player.maxAmmo = 12;
}
player.isReloading = false;
// Reset stats
for (let i = 0; i < STAT_COUNT; i++) {
player.stats[i] = 0;
}
state.players.set(id, player);
this.lastShotTimes.set(id, 0);
this.reloadTimers.delete(id);
return player;
}
private randomSpawnPosition(): Vec2 {
const margin: number = 100;
return {
x: randFloat(margin, ARENA_WIDTH - margin),
y: randFloat(margin, ARENA_HEIGHT - margin),
};
}
// ─── Input Processing ──────────────────────────────────────────────────────
/**
* Apply player input: movement and shooting.
*/
applyInput(
player: Player,
input: PlayerInput,
playerId: string,
state: RoomState,
currentTime: number,
dt: number
): void {
if (!player.alive) return;
// Update aim angle
player.angle = input.aimAngle;
// Process movement
if (input.moveAngle >= 0) {
const moveSpeedStat: number = player.stats[Stat.MovementSpeed] ?? 0;
const maxSpeed: number = PLAYER_BASE_SPEED * (1 + moveSpeedStat * 0.05);
player.vx += Math.cos(input.moveAngle) * maxSpeed * 0.15;
player.vy += Math.sin(input.moveAngle) * maxSpeed * 0.15;
// Clamp speed
const speed: number = Math.sqrt(player.vx * player.vx + player.vy * player.vy);
if (speed > maxSpeed) {
const scale: number = maxSpeed / speed;
player.vx *= scale;
player.vy *= scale;
}
}
// Process shooting
if (input.isShooting) {
this.handleShoot(player, playerId, state, input.aimAngle, currentTime);
}
}
// ─── Shooting ──────────────────────────────────────────────────────────────
/**
* Handle a single shot from the player's current gun.
*/
handleShoot(
player: Player,
playerId: string,
state: RoomState,
aimAngle: number,
currentTime: number
): void {
if (!player.alive) return;
if (player.isReloading) return;
if (player.ammo <= 0) {
// Auto-reload when empty
this.startReload(player, playerId);
return;
}
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(player.gunType);
if (gun === undefined) return;
// Check fire rate
const lastShot: number = this.lastShotTimes.get(playerId) ?? 0;
const reloadSpdStat: number = player.stats[Stat.ReloadSpeed] ?? 0;
const cooldown: number = this.gunSystem.calculateCooldown(gun, reloadSpdStat);
if (currentTime - lastShot < cooldown) return;
// Apply recoil
const recoilStabStat: number = player.stats[Stat.RecoilStability] ?? 0;
const recoilOffset: number = this.gunSystem.calculateRecoil(gun, recoilStabStat);
player.recoilOffset = recoilOffset;
// Calculate kickback
const kickback: Vec2 = this.gunSystem.calculateKickback(gun, aimAngle);
player.vx += kickback.x;
player.vy += kickback.y;
// Spawn bullet(s)
if (gun.pelletCount > 1) {
// Shotgun spread
const halfSpread: number = gun.spread / 2;
const pelletDamage: number = this.gunSystem.calculatePelletDamage(
this.calculateDamage(player, gun),
gun.pelletCount
);
for (let i = 0; i < gun.pelletCount; i++) {
const t: number = gun.pelletCount <= 1 ? 0 : i / (gun.pelletCount - 1);
const pelletAngle: number = aimAngle + recoilOffset - halfSpread + gun.spread * t;
const spawnPos: Vec2 = this.gunSystem.getBulletSpawnPos(
player.x,
player.y,
aimAngle,
recoilOffset
);
const isCrit: boolean = this.rollCrit(player);
const finalDamage: number = isCrit
? pelletDamage * (BASE_CRIT_MULTIPLIER + (player.stats[Stat.CritDamage] ?? 0) * 0.1)
: pelletDamage;
const bullet: Bullet = this.gunSystem.createBullet(
gun,
playerId,
spawnPos,
pelletAngle,
finalDamage,
isCrit
);
const bulletId: string = this.generateBulletId(state);
state.bullets.set(bulletId, bullet);
}
} else {
// Single bullet
const spawnPos: Vec2 = this.gunSystem.getBulletSpawnPos(
player.x,
player.y,
aimAngle,
recoilOffset
);
const isCrit: boolean = this.rollCrit(player);
const baseDamage: number = this.calculateDamage(player, gun);
const finalDamage: number = isCrit
? baseDamage * (BASE_CRIT_MULTIPLIER + (player.stats[Stat.CritDamage] ?? 0) * 0.1)
: baseDamage;
const bullet: Bullet = this.gunSystem.createBullet(
gun,
playerId,
spawnPos,
aimAngle + recoilOffset,
finalDamage,
isCrit
);
const bulletId: string = this.generateBulletId(state);
state.bullets.set(bulletId, bullet);
}
// Consume ammo
player.ammo = Math.max(0, player.ammo - 1);
this.lastShotTimes.set(playerId, currentTime);
// Auto-reload when empty
if (player.ammo <= 0) {
this.startReload(player, playerId);
}
}
private rollCrit(player: Player): boolean {
const critChance: number = (player.stats[Stat.CritChance] ?? 0) * 0.03;
return Math.random() < critChance;
}
private generateBulletId(state: RoomState): string {
return `b_${state.tick}_${Math.random().toString(36).substring(2, 9)}`;
}
// ─── Reloading ─────────────────────────────────────────────────────────────
/**
* Start reloading the player's gun.
*/
startReload(player: Player, playerId: string): void {
if (player.isReloading) return;
if (player.ammo >= player.maxAmmo) return;
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(player.gunType);
if (gun === undefined) return;
const reloadSpdStat: number = player.stats[Stat.ReloadSpeed] ?? 0;
const reloadTime: number = this.gunSystem.calculateReloadTime(gun, reloadSpdStat);
player.isReloading = true;
this.reloadTimers.set(playerId, reloadTime);
}
/**
* Update all reload timers. Call every tick.
* dt is in milliseconds.
*/
updateReloads(players: Map<string, Player>, dtMs: number): void {
for (const [playerId, player] of players.entries()) {
if (!player.isReloading) continue;
let remaining: number | undefined = this.reloadTimers.get(playerId);
if (remaining === undefined) {
// Timer missing — fix it
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(player.gunType);
remaining = gun !== undefined ? gun.reloadTimeMs : 1500;
}
remaining -= dtMs;
this.reloadTimers.set(playerId, remaining);
if (remaining <= 0) {
// Reload complete
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(player.gunType);
player.ammo = gun !== undefined ? gun.ammoMax : player.maxAmmo;
player.isReloading = false;
this.reloadTimers.delete(playerId);
}
}
}
// ─── Damage & Death ────────────────────────────────────────────────────────
/**
* Apply damage to a player. Returns true if the player died.
*/
takeDamage(player: Player, damage: number, isCrit: boolean): boolean {
if (!player.alive) return false;
if (damage <= 0) return false;
player.hp = Math.max(0, player.hp - damage);
if (player.hp <= 0) {
return true; // Player died
}
return false;
}
/**
* Handle player death: drop XP orbs, reset stats, mark !alive.
*/
onDeath(
player: Player,
playerId: string,
state: RoomState,
killerId?: string
): void {
if (player.alive) {
// Drop 50% of held XP as orbs
this.dropXpOrbs(player, state);
// Award kill to killer
if (killerId !== undefined) {
const killer: Player | undefined = state.players.get(killerId);
if (killer !== undefined && killer.alive) {
killer.score += 1;
// Award 50% of victim's XP to killer
const xpReward: number = Math.floor(player.xp * XP_KILL_PCT);
killer.xp += xpReward;
this.checkLevelUp(killer);
}
}
// Reset player
player.alive = false;
player.hp = 0;
player.vx = 0;
player.vy = 0;
// Schedule respawn
this.pendingRespawns.push({
playerId,
respawnAt: Date.now() + RESPAWN_DELAY_MS,
});
}
}
/**
* Drop XP orbs at player's death position. Total value = 50% of held XP.
*/
private dropXpOrbs(player: Player, state: RoomState): void {
const dropAmount: number = Math.floor(player.xp * DEATH_XP_DROP_PCT);
if (dropAmount <= 0) return;
const orbCount: number = Math.min(10, Math.max(3, player.level));
const xpPerOrb: number = Math.floor(dropAmount / orbCount);
if (xpPerOrb <= 0) return;
for (let i = 0; i < orbCount; i++) {
const angle: number = Math.random() * Math.PI * 2;
const dist: number = randFloat(10, 50);
const orb: XPOrb = new XPOrb();
orb.x = clamp(player.x + Math.cos(angle) * dist, 0, ARENA_WIDTH);
orb.y = clamp(player.y + Math.sin(angle) * dist, 0, ARENA_HEIGHT);
orb.value = xpPerOrb;
orb.lifetime = XP_ORB_LIFETIME_S;
const orbId: string = `orb_${this.nextOrbId++}`;
state.xpOrbs.set(orbId, orb);
}
}
// ─── Respawn ───────────────────────────────────────────────────────────────
/**
* Process pending respawns. Call every tick.
*/
processRespawns(state: RoomState): void {
const now: number = Date.now();
const stillPending: PendingRespawn[] = [];
for (const pending of this.pendingRespawns) {
if (now >= pending.respawnAt) {
const player: Player | undefined = state.players.get(pending.playerId);
if (player !== undefined) {
this.respawnPlayer(player);
}
} else {
stillPending.push(pending);
}
}
this.pendingRespawns = stillPending;
}
/**
* Respawn a player: new random position, reset to level 1, base stats.
*/
respawnPlayer(player: Player): void {
const spawnPos: Vec2 = this.randomSpawnPosition();
player.x = spawnPos.x;
player.y = spawnPos.y;
player.vx = 0;
player.vy = 0;
player.angle = 0;
player.recoilOffset = 0;
// Keep level and XP but reset HP
player.hp = player.maxHp;
player.alive = true;
// Reset gun to pistol
player.gunType = 0;
const gun: GunConfig | undefined = this.gunSystem.getGunConfig(0);
if (gun !== undefined) {
player.ammo = gun.ammoMax;
player.maxAmmo = gun.ammoMax;
}
player.isReloading = false;
}
// ─── Leveling & Upgrades ───────────────────────────────────────────────────
/**
* Check if player has enough XP to level up. Award points and update thresholds.
*/
checkLevelUp(player: Player): boolean {
if (player.level >= MAX_LEVEL) return false;
let leveledUp: boolean = false;
while (player.level < MAX_LEVEL && player.xp >= (XP_LEVELS[player.level] ?? Infinity)) {
player.level += 1;
player.upgradePoints += 1;
leveledUp = true;
// Update xpToNext
if (player.level < MAX_LEVEL) {
player.xpToNext = XP_LEVELS[player.level] ?? player.xpToNext;
} else {
player.xpToNext = 999999;
}
// Apply level-based stat updates
this.onLevelUp(player);
}
return leveledUp;
}
private onLevelUp(player: Player): void {
// Recalculate max HP
player.maxHp = this.calculateMaxHp(player);
// Heal on level up
player.hp = player.maxHp;
}
/**
* Apply a stat upgrade choice to a player.
* choice: 0-8 for stats, or 100+ for branch selection.
*/
applyUpgrade(player: Player, choice: number): boolean {
if (player.upgradePoints <= 0) return false;
// Branch selection: choice >= 100 means branch index
if (choice >= 100) {
const branchIdx: number = choice - 100;
const branch: ClassBranch | undefined = this.branchSystem.getBranchByIndex(branchIdx);
if (branch === undefined) return false;
if (branch.levelRequired > player.level) return false;
this.branchSystem.applyBranch(player, branch.id);
player.upgradePoints -= 1;
// Apply branch bonuses
player.maxHp = this.calculateMaxHp(player);
player.hp = Math.min(player.hp, player.maxHp);
return true;
}
// Stat upgrade
if (choice < 0 || choice >= STAT_COUNT) return false;
const currentLevel: number = player.stats[choice] ?? 0;
if (currentLevel >= STAT_MAX_LEVEL) return false;
player.stats[choice] = currentLevel + 1;
player.upgradePoints -= 1;
// Apply immediate effects
if (choice === Stat.MaxHp) {
player.maxHp = this.calculateMaxHp(player);
player.hp = Math.min(player.hp + STAT_BONUSES[Stat.MaxHp].perLevel, player.maxHp);
}
return true;
}
// ─── Stat Calculations ─────────────────────────────────────────────────────
/**
* Calculate max HP with stat and branch bonuses.
*/
calculateMaxHp(player: Player): number {
const baseHp: number = PLAYER_BASE_HP;
const hpStat: number = player.stats[Stat.MaxHp] ?? 0;
const hpBonus: number = hpStat * STAT_BONUSES[Stat.MaxHp].perLevel;
// Branch bonuses
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchHpBonus: number = branchBonuses[Stat.MaxHp];
return Math.floor(baseHp + hpBonus + baseHp * branchHpBonus);
}
/**
* Calculate movement speed with stat and branch bonuses.
*/
calculateSpeed(player: Player): number {
const moveStat: number = player.stats[Stat.MovementSpeed] ?? 0;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchMoveBonus: number = branchBonuses[Stat.MovementSpeed];
return PLAYER_BASE_SPEED * (1 + moveStat * 0.05 + branchMoveBonus);
}
/**
* Calculate damage multiplier from stats and branches.
*/
calculateDamage(player: Player, gun: GunConfig): number {
const dmgStat: number = player.stats[Stat.BulletDamage] ?? 0;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchDmgBonus: number = branchBonuses[Stat.BulletDamage];
return gun.damage * (1 + dmgStat * 0.1 + branchDmgBonus);
}
/**
* Calculate HP regeneration per second.
*/
calculateHpRegen(player: Player): number {
const regenStat: number = player.stats[Stat.HpRegen] ?? 0;
const baseRegen: number = 1; // HP per second
const statBonus: number = regenStat * STAT_BONUSES[Stat.HpRegen].perLevel;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchRegenBonus: number = branchBonuses[Stat.HpRegen];
return baseRegen + statBonus + baseRegen * branchRegenBonus;
}
/**
* Get the recoil stability factor (0-1, higher = less recoil).
*/
calculateRecoilStability(player: Player): number {
const stabStat: number = player.stats[Stat.RecoilStability] ?? 0;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchStabBonus: number = branchBonuses[Stat.RecoilStability];
return Math.min(1, stabStat * 0.1 + branchStabBonus);
}
/**
* Get the effective reload speed bonus.
*/
calculateReloadBonus(player: Player): number {
const reloadStat: number = player.stats[Stat.ReloadSpeed] ?? 0;
const branchBonuses: Record<Stat, number> =
this.branchSystem.calculateBranchBonuses(player);
const branchReloadBonus: number = branchBonuses[Stat.ReloadSpeed];
return reloadStat * 0.1 + branchReloadBonus;
}
// ─── Utility ───────────────────────────────────────────────────────────────
/**
* Get the pending respawn list (for cleanup).
*/
getPendingRespawns(): PendingRespawn[] {
return this.pendingRespawns;
}
/**
* Remove a player's pending respawn.
*/
clearPendingRespawn(playerId: string): void {
this.pendingRespawns = this.pendingRespawns.filter((p) => p.playerId !== playerId);
}
/**
* Get remaining reload time for a player (in ms).
*/
getReloadRemaining(playerId: string): number {
return this.reloadTimers.get(playerId) ?? 0;
}
/**
* Clean up all tracking data for a player.
*/
removePlayer(playerId: string): void {
this.lastShotTimes.delete(playerId);
this.reloadTimers.delete(playerId);
this.pendingRespawns = this.pendingRespawns.filter((p) => p.playerId !== playerId);
}
/**
* Reset all player tracking.
*/
reset(): void {
this.pendingRespawns = [];
this.lastShotTimes.clear();
this.reloadTimers.clear();
this.nextOrbId = 0;
}
}
+355
View File
@@ -0,0 +1,355 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Arena Room
// ═══════════════════════════════════════════════════════════════════════════════
import { Room, Client } from '@colyseus/core';
import {
RoomState,
Player,
Obstacle,
MAX_PLAYERS_PER_ROOM,
TICK_MS,
ARENA_WIDTH,
ARENA_HEIGHT,
} from '@guncircle/shared';
import { GunSystem } from './gun-system.js';
import { BranchSystem } from './branch-system.js';
import { PlayerManager } from './player-manager.js';
import { CollisionSystem } from './collision.js';
import { PhysicsEngine } from './physics.js';
import { GameLoop } from './game-loop.js';
import { InputValidator } from './validation.js';
// ─── Join Options ────────────────────────────────────────────────────────────
interface JoinOptions {
name?: string;
}
// ─── ArenaRoom ───────────────────────────────────────────────────────────────
export class ArenaRoom extends Room<RoomState> {
readonly maxClients: number = MAX_PLAYERS_PER_ROOM;
autoDispose: boolean = false;
// Subsystems
private gunSystem: GunSystem;
private branchSystem: BranchSystem;
private playerManager: PlayerManager;
private collisionSystem: CollisionSystem;
private physics: PhysicsEngine;
private inputValidator: InputValidator;
private gameLoop: GameLoop;
// Room lifecycle
private gameLoopInterval: ReturnType<typeof setInterval> | undefined;
private disposeTimer: ReturnType<typeof setTimeout> | undefined;
private emptySince: number = 0;
private readonly DISPOSE_AFTER_EMPTY_MS: number = 60000;
// Client tracking
private clientNames: Map<string, string> = new Map();
// ─── Colyseus Lifecycle ────────────────────────────────────────────────────
onCreate(_options: unknown): void {
// Initialize state
this.setState(new RoomState());
// Initialize subsystems
this.gunSystem = new GunSystem();
this.branchSystem = new BranchSystem();
this.playerManager = new PlayerManager(this.gunSystem, this.branchSystem);
this.collisionSystem = new CollisionSystem();
this.physics = new PhysicsEngine();
this.inputValidator = new InputValidator();
// Initialize game loop
this.gameLoop = new GameLoop(
this.state,
this.playerManager,
this.collisionSystem,
this.physics,
this.gunSystem,
this.branchSystem
);
// Set up game loop callbacks
this.gameLoop.onPlayerDeath = (playerId: string, killerId?: string) => {
this.broadcast('playerDeath', { victim: playerId, killer: killerId });
};
this.gameLoop.onPlayerLevelUp = (playerId: string) => {
this.broadcast('playerLevelUp', { playerId });
};
// Spawn some initial obstacles
this.spawnObstacles();
// Start game loop
this.gameLoopInterval = setInterval(() => {
this.gameLoop.tick();
}, TICK_MS);
// Handle 'input' messages
this.onMessage('input', (client: Client, data: unknown) => {
this.handleInputMessage(client, data);
});
// Handle 'upgrade' messages
this.onMessage('upgrade', (client: Client, data: unknown) => {
this.handleUpgradeMessage(client, data);
});
// Handle 'reload' messages
this.onMessage('reload', (client: Client, _data: unknown) => {
this.handleReloadMessage(client);
});
console.log(`[ArenaRoom] Created — maxClients=${this.maxClients}`);
}
onJoin(client: Client, options: JoinOptions): void {
const name: string = (options.name ?? `Player ${client.sessionId.substring(0, 4)}`).trim();
const sanitizedName: string = name.substring(0, 16) || 'Player';
this.clientNames.set(client.sessionId, sanitizedName);
// Spawn player
const player: Player = this.playerManager.spawnPlayer(
client.sessionId,
sanitizedName,
this.state
);
// Send gun configs to client
client.send('gunConfigs', { guns: this.gunSystem.getAllGuns() });
// Send branch configs to client
client.send('branchConfigs', { branches: this.branchSystem.getAllBranches() });
// Cancel dispose timer if active
if (this.disposeTimer !== undefined) {
clearTimeout(this.disposeTimer);
this.disposeTimer = undefined;
}
console.log(`[ArenaRoom] Joined: ${sanitizedName} (${client.sessionId}) — total: ${this.state.players.size}`);
}
onLeave(client: Client, consented: boolean): void {
const player: Player | undefined = this.state.players.get(client.sessionId);
if (player !== undefined) {
// Mark as dead
player.alive = false;
player.hp = 0;
// Clean up tracking
this.playerManager.removePlayer(client.sessionId);
}
this.clientNames.delete(client.sessionId);
// Remove player from state after a short delay to let clients process
setTimeout(() => {
this.state.players.delete(client.sessionId);
}, 1000);
const remainingClients: number = this.clientNames.size;
console.log(`[ArenaRoom] Left: ${client.sessionId} (consented=${consented}) — remaining: ${remainingClients}`);
// Start dispose timer if room is empty (use clientNames, not players, since players has delayed deletion)
if (remainingClients === 0) {
this.emptySince = Date.now();
this.disposeTimer = setTimeout(() => {
if (this.clientNames.size === 0) {
console.log('[ArenaRoom] Disposing empty room');
this.disconnect();
}
}, this.DISPOSE_AFTER_EMPTY_MS);
}
}
onDispose(): void {
console.log('[ArenaRoom] Disposed');
// Clean up game loop
if (this.gameLoopInterval !== undefined) {
clearInterval(this.gameLoopInterval);
this.gameLoopInterval = undefined;
}
if (this.disposeTimer !== undefined) {
clearTimeout(this.disposeTimer);
this.disposeTimer = undefined;
}
this.gameLoop.destroy();
this.playerManager.reset();
this.clientNames.clear();
}
// ─── Message Handlers ──────────────────────────────────────────────────────
private handleInputMessage(client: Client, data: unknown): void {
const player: Player | undefined = this.state.players.get(client.sessionId);
if (player === undefined) return;
if (!player.alive) return;
// Parse and validate input
const input: PlayerInput | null = this.parseInput(data);
if (input === null) return;
// Sanitize
const sanitized: PlayerInput = this.inputValidator.sanitizeInput(input);
// Validate aim angle
const aimResult = this.inputValidator.validateAimAngle(player.angle, sanitized.aimAngle);
sanitized.aimAngle = aimResult.angle;
// Queue for game loop
this.gameLoop.queueInput(client.sessionId, sanitized);
}
private handleUpgradeMessage(client: Client, data: unknown): void {
const player: Player | undefined = this.state.players.get(client.sessionId);
if (player === undefined) return;
if (player.upgradePoints <= 0) return;
let choice: number | undefined;
if (typeof data === 'number') {
choice = data;
} else if (
typeof data === 'object' &&
data !== null &&
'choice' in data &&
typeof (data as Record<string, unknown>).choice === 'number'
) {
choice = (data as Record<string, unknown>).choice as number;
}
if (choice === undefined) return;
const success: boolean = this.playerManager.applyUpgrade(player, choice);
if (success) {
client.send('upgradeAck', { choice, pointsRemaining: player.upgradePoints });
}
}
private handleReloadMessage(client: Client): void {
const player: Player | undefined = this.state.players.get(client.sessionId);
if (player === undefined) return;
if (!player.alive) return;
this.playerManager.startReload(player, client.sessionId);
}
// ─── Input Parsing ─────────────────────────────────────────────────────────
private parseInput(data: unknown): PlayerInput | null {
if (typeof data !== 'object' || data === null) return null;
const d = data as Record<string, unknown>;
const seq: number = typeof d.seq === 'number' ? d.seq : 0;
const moveAngle: number = typeof d.moveAngle === 'number' ? d.moveAngle : -1;
const aimAngle: number = typeof d.aimAngle === 'number' ? d.aimAngle : 0;
const isShooting: boolean = Boolean(d.isShooting);
const upgradeChoice: number | undefined =
typeof d.upgradeChoice === 'number' ? d.upgradeChoice : undefined;
return { seq, moveAngle, aimAngle, isShooting, upgradeChoice };
}
// ─── Obstacle Generation ───────────────────────────────────────────────────
private spawnObstacles(): void {
// Spawn some indestructible walls around the arena
const wallThickness: number = 50;
// Top wall segments
for (let i = 0; i < 6; i++) {
this.createObstacle(
200 + i * 500,
100,
wallThickness * 4,
wallThickness,
0 // IndestructibleWall
);
}
// Bottom wall segments
for (let i = 0; i < 6; i++) {
this.createObstacle(
200 + i * 500,
ARENA_HEIGHT - 150,
wallThickness * 4,
wallThickness,
0
);
}
// Left wall segments
for (let i = 0; i < 6; i++) {
this.createObstacle(
100,
200 + i * 500,
wallThickness,
wallThickness * 4,
0
);
}
// Right wall segments
for (let i = 0; i < 6; i++) {
this.createObstacle(
ARENA_WIDTH - 150,
200 + i * 500,
wallThickness,
wallThickness * 4,
0
);
}
// Central cover blocks
this.createObstacle(ARENA_WIDTH / 2 - 100, ARENA_HEIGHT / 2 - 100, 200, 50, 3); // Cover
this.createObstacle(ARENA_WIDTH / 2 - 50, ARENA_HEIGHT / 2 + 50, 100, 100, 3);
// Scattered crates
const cratePositions: Array<[number, number]> = [
[400, 400],
[ARENA_WIDTH - 400, 400],
[400, ARENA_HEIGHT - 400],
[ARENA_WIDTH - 400, ARENA_HEIGHT - 400],
[ARENA_WIDTH / 2, 300],
[ARENA_WIDTH / 2, ARENA_HEIGHT - 300],
[300, ARENA_HEIGHT / 2],
[ARENA_WIDTH - 300, ARENA_HEIGHT / 2],
];
for (const [x, y] of cratePositions) {
this.createObstacle(x, y, 60, 60, 1); // DestructibleCrate
}
// Slow zones
this.createObstacle(ARENA_WIDTH / 4, ARENA_HEIGHT / 4, 200, 200, 2);
this.createObstacle((ARENA_WIDTH * 3) / 4, (ARENA_HEIGHT * 3) / 4, 200, 200, 2);
}
private createObstacle(
x: number,
y: number,
width: number,
height: number,
type: number
): void {
const obs: Obstacle = new Obstacle();
obs.x = x;
obs.y = y;
obs.width = width;
obs.height = height;
obs.type = type;
const id: string = `obs_${this.state.obstacles.size}`;
this.state.obstacles.set(id, obs);
}
}
+204
View File
@@ -0,0 +1,204 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Input Validator (Anti-cheat)
// ═══════════════════════════════════════════════════════════════════════════════
import {
type Player,
type GunConfig,
type PlayerInput,
PLAYER_BASE_SPEED,
PLAYER_ACCELERATION,
ARENA_WIDTH,
ARENA_HEIGHT,
STAT_MAX_LEVEL,
STAT_COUNT,
Stat,
AIM_ANGLE_TOLERANCE,
MAX_LEVEL,
} from '@guncircle/shared';
// ─── InputValidator ──────────────────────────────────────────────────────────
export class InputValidator {
/** Maximum position change per tick at max speed */
private readonly maxDistPerTick: number;
constructor() {
// At 60Hz, max distance per tick = speed / 60 + small tolerance
this.maxDistPerTick = PLAYER_BASE_SPEED / 60 + 2;
}
/**
* Validate movement input. Speed must not exceed base * (1 + moveSpeedStat).
* Returns clamped (newX, newY) that are within allowed bounds.
*/
validateMovement(
player: Player,
moveAngle: number,
dt: number
): { x: number; y: number; valid: boolean } {
if (moveAngle < -0.5) {
// -1 or negative means no movement input
return { x: player.x, y: player.y, valid: true };
}
const moveSpeedStat: number = player.stats[Stat.MovementSpeed] ?? 0;
const maxSpeed: number = PLAYER_BASE_SPEED * (1 + moveSpeedStat * 0.05);
const maxStep: number = maxSpeed * dt;
// Validate position delta from current position
const requestedVx: number = Math.cos(moveAngle) * maxSpeed;
const requestedVy: number = Math.sin(moveAngle) * maxSpeed;
const newX: number = player.x + requestedVx * dt;
const newY: number = player.y + requestedVy * dt;
// Clamp to arena
const clampedX: number = Math.max(
player.radius,
Math.min(ARENA_WIDTH - player.radius, newX)
);
const clampedY: number = Math.max(
player.radius,
Math.min(ARENA_HEIGHT - player.radius, newY)
);
// Check if the step size is reasonable
const dx: number = clampedX - player.x;
const dy: number = clampedY - player.y;
const stepSize: number = Math.sqrt(dx * dx + dy * dy);
if (stepSize > maxStep * 1.5) {
// Possible speed hack — reject movement
return { x: player.x, y: player.y, valid: false };
}
return { x: clampedX, y: clampedY, valid: true };
}
/**
* Validate aim angle change. Change per tick must be within tolerance.
*/
validateAimAngle(oldAngle: number, newAngle: number): { angle: number; valid: boolean } {
let diff: number = newAngle - oldAngle;
// Normalize to [-PI, PI]
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
const absDiff: number = Math.abs(diff);
if (absDiff > AIM_ANGLE_TOLERANCE * 10) {
// Suspiciously large angle change — could be aimbot
// Cap it to max allowed
const cappedDiff: number = Math.sign(diff) * AIM_ANGLE_TOLERANCE * 10;
let cappedAngle: number = oldAngle + cappedDiff;
while (cappedAngle > Math.PI) cappedAngle -= Math.PI * 2;
while (cappedAngle < -Math.PI) cappedAngle += Math.PI * 2;
return { angle: cappedAngle, valid: false };
}
return { angle: newAngle, valid: true };
}
/**
* Validate fire rate: time since last shot must be >= cooldown.
* Returns true if firing is allowed.
*/
validateFireRate(
lastShotTime: number,
currentTime: number,
gun: GunConfig,
reloadSpdStat: number
): boolean {
const baseCooldown: number = 1000 / gun.fireRate;
const reloadBonus: number = 1 + reloadSpdStat * 0.1;
const adjustedCooldown: number = baseCooldown / reloadBonus;
const elapsed: number = currentTime - lastShotTime;
return elapsed >= adjustedCooldown - 5; // 5ms tolerance for network jitter
}
/**
* Validate stat upgrade choice. Returns true if the player can make this upgrade.
*/
validateUpgrade(player: Player, choice: number): { valid: boolean; reason: string } {
// Must have upgrade points
if (player.upgradePoints <= 0) {
return { valid: false, reason: 'No upgrade points available' };
}
// Choice must be a valid stat index
if (choice < 0 || choice >= STAT_COUNT) {
return { valid: false, reason: 'Invalid stat index' };
}
// Stat must not be maxed
const currentLevel: number = player.stats[choice] ?? 0;
if (currentLevel >= STAT_MAX_LEVEL) {
return { valid: false, reason: 'Stat already at max level' };
}
return { valid: true, reason: '' };
}
/**
* Validate branch selection choice. Returns true if branch is valid for player.
*/
validateBranchChoice(
player: Player,
branchIndex: number,
availableBranches: number[]
): { valid: boolean; reason: string } {
if (branchIndex < 0 || branchIndex >= availableBranches.length) {
return { valid: false, reason: 'Invalid branch selection' };
}
if (player.upgradePoints <= 0) {
return { valid: false, reason: 'No upgrade points available' };
}
return { valid: true, reason: '' };
}
/**
* Validate general input bounds. Clamp numeric values to reasonable ranges.
*/
sanitizeInput(input: PlayerInput): PlayerInput {
return {
seq: Math.max(0, input.seq | 0),
moveAngle: isNaN(input.moveAngle) ? -1 : input.moveAngle,
aimAngle: isNaN(input.aimAngle) ? 0 : input.aimAngle,
isShooting: Boolean(input.isShooting),
upgradeChoice:
input.upgradeChoice !== undefined ? Math.max(0, input.upgradeChoice | 0) : undefined,
};
}
/**
* Check if a position is within arena bounds.
*/
isInArena(x: number, y: number, radius: number = 0): boolean {
return (
x >= radius &&
x <= ARENA_WIDTH - radius &&
y >= radius &&
y <= ARENA_HEIGHT - radius
);
}
/**
* Validate that velocity is within reasonable bounds.
*/
validateVelocity(vx: number, vy: number, maxSpeed: number): { vx: number; vy: number } {
const speedSq: number = vx * vx + vy * vy;
const maxSpeedSq: number = maxSpeed * maxSpeed;
if (speedSq > maxSpeedSq * 4) {
// Suspicious velocity — cap it
const speed: number = Math.sqrt(speedSq);
const scale: number = (maxSpeed * 2) / speed;
return { vx: vx * scale, vy: vy * scale };
}
return { vx, vy };
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["./src"],
"references": [{ "path": "../shared" }]
}