Files
Guncircle.io/packages/server/src/player-manager.ts
T

663 lines
20 KiB
TypeScript

// ═══════════════════════════════════════════════════════════════════════════════
// 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;
}
}