Initial commit: GunCircle.io multiplayer arena shooter

This commit is contained in:
emil
2026-05-11 15:39:56 +03:00
commit 836441fc2c
45 changed files with 9391 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@guncircle/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"type-check": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@colyseus/schema": "^3.0.0"
},
"devDependencies": {
"typescript": "^5.6.0",
"vitest": "^2.0.0"
}
}
+132
View File
@@ -0,0 +1,132 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Game Constants
// ═══════════════════════════════════════════════════════════════════════════════
/** Server tick rate (Hz) */
export const TICK_RATE = 60;
/** Duration of one tick in ms */
export const TICK_MS = 1000 / TICK_RATE;
/** Arena dimensions */
export const ARENA_WIDTH = 3000;
export const ARENA_HEIGHT = 3000;
/** Max players per room */
export const MAX_PLAYERS_PER_ROOM = 50;
/** Player base stats */
export const PLAYER_BASE_RADIUS = 20;
export const PLAYER_BASE_HP = 100;
export const PLAYER_BASE_SPEED = 150; // units per second
export const PLAYER_BASE_REGEN = 1; // HP per second
/** Movement physics */
export const PLAYER_ACCELERATION = 800; // units per second^2
export const PLAYER_FRICTION = 0.92; // velocity multiplier per tick
export const PLAYER_MAX_SPEED_MULT = 1.0;
/** Gun rendering */
export const GUN_BARREL_WIDTH = 8;
export const GUN_BARREL_LENGTH = 22;
export const GUN_BODY_SIZE = 10;
export const GUN_RECOVERY_LERP = 0.15; // per tick
/** XP system */
export const XP_LEVELS = [
0, 50, 150, 300, 500, 750, 1050, 1400, 1800, 2250,
2750, 3300, 3900, 4550, 5250, 6000, 7000, 8200, 9500, 11000,
12700, 14500, 16500, 18700, 21100, 23700, 26500, 29500, 32700, 36100,
40000, 45000, 51000, 58000, 66000, 75000, 85000, 96000, 108000, 121000,
135000, 150000, 167000, 185000, 204000, 225000,
];
export const MAX_LEVEL = XP_LEVELS.length; // 45
/** Death / respawn */
export const DEATH_XP_DROP_PCT = 0.5;
export const RESPAWN_DELAY_MS = 3000;
export const XP_ORB_LIFETIME_S = 30;
export const XP_ORB_DESPAWN_MS = XP_ORB_LIFETIME_S * 1000;
/** Class branch milestone levels */
export const BRANCH_LEVELS = [5, 10, 15, 30, 45];
/** Stat upgrade system */
export const STAT_MAX_LEVEL = 7;
export const STAT_UPGRADE_COST_BASE = 1; // 1 point per upgrade
/** Stats enum — indexes into uint8 array */
export enum Stat {
MaxHp = 0,
HpRegen = 1,
MovementSpeed = 2,
BulletDamage = 3,
BulletSpeed = 4,
ReloadSpeed = 5,
RecoilStability = 6,
CritChance = 7,
CritDamage = 8,
}
export const STAT_COUNT = 9;
/** Stat bonuses per level (additive) */
export const STAT_BONUSES: Record<Stat, { perLevel: number; isPct: boolean }> = {
[Stat.MaxHp]: { perLevel: 20, isPct: false },
[Stat.HpRegen]: { perLevel: 1, isPct: false },
[Stat.MovementSpeed]: { perLevel: 0.05, isPct: true },
[Stat.BulletDamage]: { perLevel: 0.10, isPct: true },
[Stat.BulletSpeed]: { perLevel: 0.08, isPct: true },
[Stat.ReloadSpeed]: { perLevel: 0.10, isPct: true },
[Stat.RecoilStability]: { perLevel: 0.10, isPct: true }, // -10% recoil per level
[Stat.CritChance]: { perLevel: 0.03, isPct: true },
[Stat.CritDamage]: { perLevel: 0.10, isPct: true },
};
/** Base crit */
export const BASE_CRIT_MULTIPLIER = 1.5; // 150%
/** XP rewards */
export const XP_PER_DAMAGE = 1;
export const XP_KILL_PCT = 0.5; // 50% of victim's held XP
/** Collision */
export const SPATIAL_CELL_SIZE = 100;
/** Network */
export const INPUT_RATE_HZ = 60;
export const INPUT_MS = 1000 / INPUT_RATE_HZ;
/** Validation tolerances */
export const POS_DRIFT_THRESHOLD = 5; // correct if drift > 5 units
export const AIM_ANGLE_TOLERANCE = 0.1; // radians
export const RECOIL_TOLERANCE = 0.15; // radians
/** Colors */
export const COLORS = {
self: '#3498db', // blue
enemy: '#e74c3c', // red
bullet: '#f1c40f', // yellow
bulletCrit: '#e74c3c', // red outline for crit
xpOrb: '#2ecc71', // green
hpBar: '#2ecc71',
hpBarBg: '#2c3e50',
nameTag: '#ecf0f1',
uiBg: 'rgba(0,0,0,0.6)',
uiBorder: '#34495e',
arenaBg: '#1a1a2e',
arenaGrid: '#16213e',
wall: '#7f8c8d',
crate: '#8b4513',
text: '#ecf0f1',
} as const;
/** Gun categories (for class branch unlocking) */
export enum GunCategory {
Pistol = 0,
Rifle = 1,
Shotgun = 2,
Sniper = 3,
SMG = 4,
}
+8
View File
@@ -0,0 +1,8 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Shared Package Exports
// ═══════════════════════════════════════════════════════════════════════════════
export * from './constants.js';
export * from './types.js';
export * from './math.js';
export * from './schema.js';
+222
View File
@@ -0,0 +1,222 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Math Utilities
// ═══════════════════════════════════════════════════════════════════════════════
import type { Vec2 } from './types.js';
// ─── Vector Operations ───────────────────────────────────────────────────────
export const vec2 = {
zero: (): Vec2 => ({ x: 0, y: 0 }),
fromAngle: (angle: number, magnitude: number = 1): Vec2 => ({
x: Math.cos(angle) * magnitude,
y: Math.sin(angle) * magnitude,
}),
add: (a: Vec2, b: Vec2): Vec2 => ({ x: a.x + b.x, y: a.y + b.y }),
sub: (a: Vec2, b: Vec2): Vec2 => ({ x: a.x - b.x, y: a.y - b.y }),
mul: (v: Vec2, s: number): Vec2 => ({ x: v.x * s, y: v.y * s }),
div: (v: Vec2, s: number): Vec2 => ({ x: v.x / s, y: v.y / s }),
len: (v: Vec2): number => Math.sqrt(v.x * v.x + v.y * v.y),
lenSq: (v: Vec2): number => v.x * v.x + v.y * v.y,
normalize: (v: Vec2): Vec2 => {
const l = Math.sqrt(v.x * v.x + v.y * v.y);
return l === 0 ? { x: 0, y: 0 } : { x: v.x / l, y: v.y / l };
},
dot: (a: Vec2, b: Vec2): number => a.x * b.x + a.y * b.y,
dist: (a: Vec2, b: Vec2): number => {
const dx = a.x - b.x;
const dy = a.y - b.y;
return Math.sqrt(dx * dx + dy * dy);
},
distSq: (a: Vec2, b: Vec2): number => {
const dx = a.x - b.x;
const dy = a.y - b.y;
return dx * dx + dy * dy;
},
clamp: (v: Vec2, maxLen: number): Vec2 => {
const l = Math.sqrt(v.x * v.x + v.y * v.y);
if (l <= maxLen) return v;
const scale = maxLen / l;
return { x: v.x * scale, y: v.y * scale };
},
angle: (v: Vec2): number => Math.atan2(v.y, v.x),
angleBetween: (a: Vec2, b: Vec2): number => Math.atan2(b.y - a.y, b.x - a.x),
lerp: (a: Vec2, b: Vec2, t: number): Vec2 => ({
x: a.x + (b.x - a.x) * t,
y: a.y + (b.y - a.y) * t,
}),
copy: (v: Vec2): Vec2 => ({ x: v.x, y: v.y }),
equals: (a: Vec2, b: Vec2, eps: number = 0.001): boolean =>
Math.abs(a.x - b.x) < eps && Math.abs(a.y - b.y) < eps,
};
// ─── Scalar Utilities ────────────────────────────────────────────────────────
export const lerp = (a: number, b: number, t: number): number =>
a + (b - a) * t;
export const lerpAngle = (a: number, b: number, t: number): number => {
let diff = b - a;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
return a + diff * t;
};
export const clamp = (v: number, min: number, max: number): number =>
Math.max(min, Math.min(max, v));
export const clamp01 = (v: number): number => clamp(v, 0, 1);
export const remap = (
v: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number
): number => outMin + ((v - inMin) / (inMax - inMin)) * (outMax - outMin);
/** Return random float in [min, max) */
export const randFloat = (min: number, max: number): number =>
min + Math.random() * (max - min);
/** Return random int in [min, max) */
export const randInt = (min: number, max: number): number =>
Math.floor(randFloat(min, max));
/** Return 1 or -1 randomly */
export const randSign = (): number => (Math.random() < 0.5 ? 1 : -1);
/** Check if circle a overlaps circle b */
export const circleOverlap = (
ax: number, ay: number, ar: number,
bx: number, by: number, br: number
): boolean => {
const dx = ax - bx;
const dy = ay - by;
const r = ar + br;
return dx * dx + dy * dy < r * r;
};
/** Solve circle-circle collision: push a out of b. Returns correction vector for a. */
export const circleCollisionResolve = (
ax: number, ay: number, ar: number,
bx: number, by: number, br: number
): Vec2 | null => {
const dx = ax - bx;
const dy = ay - by;
const distSq = dx * dx + dy * dy;
const r = ar + br;
if (distSq >= r * r || distSq === 0) return null;
const dist = Math.sqrt(distSq);
const overlap = r - dist;
return {
x: (dx / dist) * overlap,
y: (dy / dist) * overlap,
};
};
// ─── Spatial Hash Grid ───────────────────────────────────────────────────────
export class SpatialHashGrid<T extends { x: number; y: number; id: number }> {
private cells = new Map<string, T[]>();
private itemToCell = new Map<number, string>();
constructor(private cellSize: number) {}
private hash(cx: number, cy: number): string {
return `${cx},${cy}`;
}
insert(item: T): void {
const cx = Math.floor(item.x / this.cellSize);
const cy = Math.floor(item.y / this.cellSize);
const key = this.hash(cx, cy);
let cell = this.cells.get(key);
if (!cell) {
cell = [];
this.cells.set(key, cell);
}
cell.push(item);
this.itemToCell.set(item.id, key);
}
remove(item: T): void {
const key = this.itemToCell.get(item.id);
if (!key) return;
const cell = this.cells.get(key);
if (!cell) return;
const idx = cell.findIndex((i) => i.id === item.id);
if (idx >= 0) cell.splice(idx, 1);
this.itemToCell.delete(item.id);
}
update(item: T): void {
this.remove(item);
this.insert(item);
}
query(x: number, y: number, radius: number): T[] {
const results: T[] = [];
const minCx = Math.floor((x - radius) / this.cellSize);
const maxCx = Math.floor((x + radius) / this.cellSize);
const minCy = Math.floor((y - radius) / this.cellSize);
const maxCy = Math.floor((y + radius) / this.cellSize);
const rSq = radius * radius;
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cy = minCy; cy <= maxCy; cy++) {
const key = this.hash(cx, cy);
const cell = this.cells.get(key);
if (!cell) continue;
for (const item of cell) {
const dx = item.x - x;
const dy = item.y - y;
if (dx * dx + dy * dy <= rSq) {
results.push(item);
}
}
}
}
return results;
}
queryRect(x: number, y: number, w: number, h: number): T[] {
const results: T[] = [];
const minCx = Math.floor(x / this.cellSize);
const maxCx = Math.floor((x + w) / this.cellSize);
const minCy = Math.floor(y / this.cellSize);
const maxCy = Math.floor((y + h) / this.cellSize);
for (let cx = minCx; cx <= maxCx; cx++) {
for (let cy = minCy; cy <= maxCy; cy++) {
const key = this.hash(cx, cy);
const cell = this.cells.get(key);
if (!cell) continue;
for (const item of cell) {
if (item.x >= x && item.x <= x + w && item.y >= y && item.y <= y + h) {
results.push(item);
}
}
}
}
return results;
}
clear(): void {
this.cells.clear();
this.itemToCell.clear();
}
}
+119
View File
@@ -0,0 +1,119 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Colyseus Schema Definitions
// ═══════════════════════════════════════════════════════════════════════════════
import { Schema, type, MapSchema, ArraySchema } from '@colyseus/schema';
// ─── Player State ────────────────────────────────────────────────────────────
export class Player extends Schema {
@type('float32') x = 0;
@type('float32') y = 0;
/** Current aim angle in radians */
@type('float32') angle = 0;
/** Current HP */
@type('float32') hp = 100;
@type('float32') maxHp = 100;
/** 1-45 */
@type('uint8') level = 1;
@type('uint16') xp = 0;
@type('uint16') xpToNext = 50;
/** Index into gun configs */
@type('uint8') gunType = 0;
/** Index into class branches */
@type('uint8') classType = 0;
/** Number of kills */
@type('uint8') score = 0;
/** Player name, max 16 chars */
@type('string') name = 'Player';
/** Cosmetic skin index */
@type('uint8') skinId = 0;
/** Current recoil offset in radians */
@type('float32') recoilOffset = 0;
/** Current ammo */
@type('uint8') ammo = 0;
/** Max ammo */
@type('uint8') maxAmmo = 0;
/** Is reloading */
@type('boolean') isReloading = false;
/** Alive */
@type('boolean') alive = true;
/** Radius */
@type('float32') radius = 20;
/** Upgrade points available */
@type('uint8') upgradePoints = 0;
/** Stat levels: [maxHp, hpRegen, moveSpeed, bulletDmg, bulletSpd, reloadSpd, recoilStab, critChance, critDmg] */
@type(['uint8']) stats = new ArraySchema<number>(0, 0, 0, 0, 0, 0, 0, 0, 0);
/** Velocity x (for smoother client reconciliation) */
@type('float32') vx = 0;
/** Velocity y */
@type('float32') vy = 0;
}
// ─── Bullet State ────────────────────────────────────────────────────────────
export class Bullet extends Schema {
@type('float32') x = 0;
@type('float32') y = 0;
/** Travel angle in radians */
@type('float32') angle = 0;
/** Owner session ID */
@type('string') ownerId = '';
/** Damage */
@type('float32') damage = 10;
/** Penetration remaining */
@type('uint8') penetration = 1;
/** Bullet type index */
@type('uint8') bulletType = 0;
/** Is critical hit */
@type('boolean') isCritical = false;
/** Velocity x */
@type('float32') vx = 0;
/** Velocity y */
@type('float32') vy = 0;
/** Bullet radius/size */
@type('float32') size = 4;
/** Bullet color */
@type('string') color = '#f1c40f';
}
// ─── XP Orb State ────────────────────────────────────────────────────────────
export class XPOrb extends Schema {
@type('float32') x = 0;
@type('float32') y = 0;
/** XP value */
@type('uint16') value = 10;
/** Seconds remaining */
@type('uint8') lifetime = 30;
}
// ─── Arena Obstacle State ────────────────────────────────────────────────────
export class Obstacle extends Schema {
@type('float32') x = 0;
@type('float32') y = 0;
@type('float32') width = 50;
@type('float32') height = 50;
@type('uint8') type = 0;
}
// ─── Leaderboard Entry ───────────────────────────────────────────────────────
export class LeaderboardEntry extends Schema {
@type('string') name = '';
@type('uint8') level = 1;
@type('uint8') score = 0;
@type('uint16') xp = 0;
}
// ─── Room State ──────────────────────────────────────────────────────────────
export class RoomState extends Schema {
@type({ map: Player }) players = new MapSchema<Player>();
@type({ map: Bullet }) bullets = new MapSchema<Bullet>();
@type({ map: XPOrb }) xpOrbs = new MapSchema<XPOrb>();
@type({ map: Obstacle }) obstacles = new MapSchema<Obstacle>();
@type('uint32') tick = 0;
@type([LeaderboardEntry]) leaderboard = new ArraySchema<LeaderboardEntry>();
}
+139
View File
@@ -0,0 +1,139 @@
// ═══════════════════════════════════════════════════════════════════════════════
// GunCircle.io — Shared Type Definitions
// ═══════════════════════════════════════════════════════════════════════════════
import type { Stat, GunCategory } from './constants.js';
// ─── Vector ──────────────────────────────────────────────────────────────────
export interface Vec2 {
x: number;
y: number;
}
// ─── Gun Configuration ───────────────────────────────────────────────────────
export interface GunConfig {
/** Unique identifier */
id: string;
/** Display name */
name: string;
/** Gun category for class branch unlocking */
category: GunCategory;
/** Damage per bullet */
damage: number;
/** Shots per second */
fireRate: number;
/** Bullet speed (units/sec) */
bulletSpeed: number;
/** Number of bullets per shot (1 = single, >1 = shotgun) */
pelletCount: number;
/** Spread angle in radians (0 = no spread) */
spread: number;
/** Gun recoil rotation offset in radians (random left/right) */
recoilOffset: number;
/** Time to recover from recoil (ms) */
recoilRecoveryMs: number;
/** Backward impulse on player per shot */
kickbackForce: number;
/** Max ammo before reload */
ammoMax: number;
/** Reload time in ms */
reloadTimeMs: number;
/** Bullet radius */
bulletSize: number;
/** How many entities bullet can pass through */
penetration: number;
/** Bullet lifetime in ms */
bulletLifetimeMs: number;
/** Color for bullets */
bulletColor: string;
}
// ─── Class Branch ────────────────────────────────────────────────────────────
export interface ClassBranch {
id: string;
name: string;
description: string;
/** Level required to choose this branch */
levelRequired: number;
/** Parent branch ID (null for root) */
parentId: string | null;
/** Gun categories unlocked by this branch */
unlocksCategories: GunCategory[];
/** Passive stat bonuses (additive) */
passiveBonuses: Partial<Record<Stat, number>>;
}
// ─── Input ───────────────────────────────────────────────────────────────────
export interface PlayerInput {
/** Client frame counter */
seq: number;
/** Movement angle in radians, -1 if no movement */
moveAngle: number;
/** Aim angle in radians */
aimAngle: number;
/** True if mouse down / shooting */
isShooting: boolean;
/** Stat index or branch choice when leveling up */
upgradeChoice?: number;
}
// ─── Arena Obstacle ──────────────────────────────────────────────────────────
export interface ArenaObstacle {
id: number;
x: number;
y: number;
width: number;
height: number;
type: ObstacleType;
}
export enum ObstacleType {
IndestructibleWall = 0,
DestructibleCrate = 1,
SlowZone = 2,
Cover = 3,
}
// ─── Skin ────────────────────────────────────────────────────────────────────
export interface Skin {
id: number;
name: string;
type: SkinType;
/** Color/Pattern value */
value: string;
/** Price in coins (0 = free/achievement) */
price: number;
/** Achievement requirement (null = purchasable) */
achievement?: string;
}
export enum SkinType {
CircleColor = 0,
GunSkin = 1,
NameTagStyle = 2,
}
// ─── XP Orb ──────────────────────────────────────────────────────────────────
export interface XpOrbData {
id: number;
x: number;
y: number;
value: number;
lifetime: number; // seconds remaining
}
// ─── Leaderboard Entry ───────────────────────────────────────────────────────
export interface LeaderboardEntry {
name: string;
level: number;
score: number; // kills
xp: number;
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["./src"]
}