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