663 lines
20 KiB
TypeScript
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;
|
|
}
|
|
}
|