// ═══════════════════════════════════════════════════════════════════════════════ // 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 = new Map(); private reloadTimers: Map = 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, 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 = 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 = 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 = 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 = 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 = 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 = 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; } }