Files
Guncircle.io/packages/server/src/gun-system.ts
T

163 lines
5.1 KiB
TypeScript

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