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