commit 836441fc2c3b4b375cb23a1dd82e503d0cf6289e Author: emil Date: Mon May 11 15:39:56 2026 +0300 Initial commit: GunCircle.io multiplayer arena shooter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..184f972 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +*.log +.env +.DS_Store +*.local +.vite/ +coverage/ +*.tsbuildinfo diff --git a/SPEC.md b/SPEC.md new file mode 100644 index 0000000..9844a86 --- /dev/null +++ b/SPEC.md @@ -0,0 +1,225 @@ +# GunCircle.io — Technical Specification + +## Overview +A browser-based multiplayer .io arena shooter (diep.io-style). Continuous FFA — no matches, no timers. Players join, spawn immediately, fight, earn XP, level up, choose class branches, die and respawn. + +## Architecture +- **Monorepo**: `packages/shared`, `packages/server`, `packages/client` +- **Client**: TypeScript + Vite + HTML5 Canvas 2D (PC only, WASD + mouse) +- **Server**: Node.js + TypeScript + Colyseus (uWebSockets transport) +- **Physics**: Custom circle-circle + spatial hash grid (no external physics) +- **Network**: Client-side prediction + server reconciliation + entity interpolation + +## Shared Package (`packages/shared/`) +Already implemented. Contains: +- `schema.ts` — Colyseus Schema classes (Player, Bullet, XPOrb, Obstacle, LeaderboardEntry, RoomState) +- `types.ts` — TypeScript interfaces (GunConfig, ClassBranch, PlayerInput, Skin, etc.) +- `constants.ts` — Game constants (TICK_RATE, ARENA, COLORS, STATS, XP_LEVELS, etc.) +- `math.ts` — Vector math, spatial hash grid, scalar utilities +- `config/guns.json` — 5 gun definitions (pistol, rifle, shotgun, sniper, SMG) +- `config/branches.json` — 19 class branch definitions + +## Server (`packages/server/src/`) + +### Entry Point: `index.ts` +Sets up Colyseus server with uWebSockets transport, listens on port 3000, registers `ArenaRoom`. + +### Room: `room.ts` — `ArenaRoom extends Room` +- **maxClients**: 50 +- **Game loop**: `setInterval` at TICK_RATE (60Hz), increments `state.tick` +- **On join**: create Player, spawn at random position, assign base gun (pistol), send gun configs +- **On leave**: mark player dead, drop 50% XP as orbs after 60s room destroy timer +- **On message**: deserialize PlayerInput, validate, apply + +### Game Loop: `game-loop.ts` +Per tick (order matters): +1. Process player inputs (movement, shooting, upgrades) +2. Update bullet positions (linear velocity) +3. Update player positions (velocity + friction) +4. Detect collisions (bullets vs players, players vs obstacles, players vs XP orbs) +5. Apply damage, handle deaths +6. Update recoil recovery (lerp recoilOffset toward 0) +7. Update reload timers +8. Regenerate HP +9. Update leaderboard +10. Broadcast state (Colyseus handles delta compression) + +### Physics: `physics.ts` +- `SpatialHashGrid` for O(1) queries +- `circleCollisionResolve()` for player-player and player-obstacle +- `lineCircleIntersect()` for bullet hit detection +- Arena bounds clamping + +### Player Manager: `player-manager.ts` +- `spawnPlayer()`: random position, base stats, apply class bonuses +- `applyInput()`: validate speed, update velocity, handle shooting +- `handleShoot()`: apply recoil, spawn bullet(s), apply kickback, consume ammo +- `takeDamage()`: apply damage, check crit, check death +- `onDeath()`: drop XP orbs, reset level/stats, schedule respawn +- `respawnPlayer()`: new random position, level 1, base gun +- `applyUpgrade()`: validate stat choice, apply bonus +- `checkLevelUp()`: check XP thresholds, award upgrade points, prompt branch choice + +### Collision: `collision.ts` +- `checkBulletPlayerCollisions()`: spatial hash query, damage on overlap +- `checkPlayerOrbCollisions()`: collect orbs within pickup radius +- `checkPlayerObstacleCollisions()`: resolve overlap, apply slow zone effect +- `checkPlayerPlayerCollisions()`: soft collision (push apart) + +### Gun System: `gun-system.ts` +- Load `config/guns.json` at startup +- `getGunConfig(id)` / `getGunConfigByIndex(idx)` +- `calculateRecoil()`: random sign * recoilOffset * (1 - recoilStatBonus) +- `calculateKickback()`: -aimVector * kickbackForce +- `calculateBulletSpawn()`: barrel tip position at aimAngle + recoilOffset + +### Class Branch System: `branch-system.ts` +- Load `config/branches.json` at startup +- `getAvailableBranches(level, currentBranch)` — filters by levelRequired and parentId +- `applyBranchBonuses(player, branchId)` — applies passive stat multipliers +- `getUnlockedGunCategories(branchIds)` — union of categories + +### Validation: `validation.ts` +- `validateMovement()`: speed <= max * (1 + moveSpeedStat) +- `validateAimAngle()`: change <= max_turn_rate per tick +- `validateFireRate()`: timeSinceLastShot >= cooldown / (1 + reloadStat) +- `validateRecoil()`: server-calculated vs client-reported within tolerance + +## Client (`packages/client/src/`) + +### Entry Point: `main.ts` +- Wait for DOM ready +- Show menu, get player name +- On PLAY click: connect to Colyseus room, hide menu, start game loop + +### Renderer: `renderer.ts` +Canvas 2D rendering engine: +- `render()`: called every requestAnimationFrame +- Clear canvas → save → apply camera transform → render world → restore → render HUD + +**Render order (world space):** +1. Arena background (solid color + grid lines) +2. Obstacles (walls = grey rects, crates = brown rects, slow zones = blue tint, cover = green) +3. XP orbs (small colored squares/circles) +4. Bullets (filled circles with slight glow, semi-transparent trail circles behind) +5. Players (circles: blue = self, red = others) +6. Gun rendering (rectangle barrel + square body, rotated at aimAngle + recoilOffset) +7. Name tags (above circle, always visible) +8. HP bars (below name tag, green fill / dark bg) + +**Gun rendering details:** +- Barrel: rectangle, `GUN_BARREL_WIDTH` x `GUN_BARREL_LENGTH`, attached to player center +- Body: small square `GUN_BODY_SIZE` behind barrel +- Rotation: around player center at `angle + recoilOffset` +- Recovery: lerp recoilOffset toward 0 each frame + +**Bullet rendering:** +- Main circle: radius = bulletSize, filled with bulletColor +- Trail: 3-5 semi-transparent smaller circles behind at velocity * -dt positions +- Crit: red outline stroke (2px) when isCritical = true + +### Camera: `camera.ts` +- Follow local player with slight lag (lerp at ~0.1 per frame) +- Clamp to arena bounds + viewport padding +- Transform: translate(canvas/2 - camX, canvas/2 - camY) + +### Input: `input.ts` +- WASD: track key states, compute moveAngle from active keys +- Mouse: track position, convert screen → world for aimAngle +- Left click: isShooting flag +- Send input at 60Hz (setInterval) + +### Networking: `network.ts` +- Connect to Colyseus server via WebSocket +- Client-side prediction: immediately move on WASD, apply velocity +- Server reconciliation: compare predicted pos to server pos, smooth correction +- Entity interpolation: for other players/bullets, lerp between prev and current state +- Bullet confirmation: predict spawn locally, server confirms trajectory + +### HUD: `hud.ts` +- HP bar (top-left): green fill, shows current/max +- XP bar (below HP): yellow fill, shows progress to next level +- Ammo counter (bottom-right): current/max, reload indicator +- Level indicator (top-right): large number +- Leaderboard (top-right below level): sorted by XP, shows top 10 +- Name above local player: white text, centered +- Upgrade popup: appears on level-up, shows stat buttons + branch choice at milestones + +### Menu: `menu.ts` +- Name input (max 16 chars) +- PLAY button → connect → hide menu → show HUD + +### Game Loop: `game.ts` +- `update(dt)`: process input, apply prediction, update camera, update HUD +- `render()`: call renderer +- Uses requestAnimationFrame with delta time +- Interpolate entity positions between server ticks + +## Networking Protocol + +### Client → Server (60Hz) +``` +PlayerInput { + seq: uint32 // frame counter + moveAngle: float32 // -1 = none + aimAngle: float32 + isShooting: bool + upgradeChoice?: uint8 +} +``` + +### Server → Client (60Hz, delta compressed via Colyseus) +Full RoomState with Player, Bullet, XPOrb maps. Colyseus automatically sends only changed fields. + +### Bandwidth Budget +- Target: < 4 KB/s per player downstream +- With 50 players + 100 bullets + 50 orbs, delta should be ~2-3 KB per tick +- Colyseus Schema uses binary encoding + delta compression + +## File Structure + +``` +packages/server/src/ + index.ts — server entry point + room.ts — ArenaRoom definition + game-loop.ts — tick loop + physics.ts — spatial hash, collision resolution + player-manager.ts — spawn, death, respawn, upgrades + collision.ts — bullet-player, player-orb, player-obstacle + gun-system.ts — gun config loading, recoil calc + branch-system.ts — class branch loading, bonus application + validation.ts — input validation, anti-cheat + bot-player.ts — AI bot for testing (optional) + +packages/client/src/ + main.ts — client entry, menu, connect + renderer.ts — Canvas 2D rendering + camera.ts — camera follow + smoothing + input.ts — WASD + mouse input + network.ts — Colyseus client, prediction, reconciliation + hud.ts — UI overlays (HP, XP, leaderboard, upgrades) + game.ts — client game loop (update + render) + interpolation.ts — entity interpolation between ticks + asset-loader.ts — optional image/font loading +``` + +## Quality Gates +1. Client: 60 FPS with 50 entities (Chrome, mid-tier laptop) +2. Server: 60 Hz tick with 50 players (<10ms per tick) +3. Bandwidth: < 4 KB/s per player downstream +4. 100ms latency feels playable (prediction hides lag) +5. No memory leaks over 10-minute session + +## Visual Style (diep.io-like) +- Self: blue circle (#3498db) +- Enemies: red circle (#e74c3c) +- Background: dark (#1a1a2e) with subtle grid +- No particles, no screen shake, no muzzle flash +- Only visual feedback: gun recoil animation +- Clean, minimal, readable + +## Skin System (Cosmetic Only) +- Circle color/pattern overrides +- Gun appearance overrides +- Name tag style overrides +- MVP: simple hardcoded palette, shop UI stub diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bc3e379 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +version: '3.8' + +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + server: + build: + context: . + dockerfile: packages/server/Dockerfile + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - REDIS_URL=redis://redis:6379 + - PORT=3000 + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + + client: + build: + context: . + dockerfile: packages/client/Dockerfile + ports: + - "80:80" + depends_on: + - server + restart: unless-stopped + +volumes: + redis-data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b3525b2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2557 @@ +{ + "name": "guncircle.io", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "guncircle.io", + "version": "0.1.0", + "dependencies": { + "@colyseus/schema": "^4.0.25", + "colyseus.js": "^0.16.22", + "vite": "^8.0.11" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "typescript": "^5.9.3" + } + }, + "node_modules/@colyseus/httpie": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/@colyseus/httpie/-/httpie-2.0.1.tgz", + "integrity": "sha512-JvABMZzPLiyrUsVj3ElXGORRDTu+NKzXHWd1uV1R1SThAKMm06cVW6bOyADARD65bs8JJoHNNbUkW8KoRvRDzA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@colyseus/msgpackr": { + "version": "1.11.2", + "resolved": "https://registry.npmmirror.com/@colyseus/msgpackr/-/msgpackr-1.11.2.tgz", + "integrity": "sha512-MuwPFhizFKC3zmGfy0fpo+kcnZdNdnQHFVjw81v4WXHCelDeCX8yNRVtuEm8kGlHqq7qiASLC0pu0RPqYOhxXg==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/@colyseus/schema": { + "version": "4.0.25", + "resolved": "https://registry.npmmirror.com/@colyseus/schema/-/schema-4.0.25.tgz", + "integrity": "sha512-WW+zqfYv1keWewbXmIxnElzfHruqnif4RPHfxTtXqPUVTnd/Jm7MOV2O2O1sBrMZmFmhKusIHxzSF+xAUcSv3g==", + "license": "MIT", + "bin": { + "schema-codegen": "bin/schema-codegen", + "schema-debug": "bin/schema-debug" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmmirror.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmmirror.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmmirror.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmmirror.com/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmmirror.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmmirror.com/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmmirror.com/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmmirror.com/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmmirror.com/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmmirror.com/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.128.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.128.0.tgz", + "integrity": "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-lIDyUAfD7U3+BWKzdxMbJcsYHuqXqmGz40aeRqvuAm3y5TkJSYTBW2RDrn65DJFPQqVjUAUqq5uz8urzQ8aBdQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-apJq2ktnGp27nSInMR5Vcj8kY6xJzDAvfdIFlpDcAK/w4cDO58qVoi1YQsES/SKiFNge/6e4CUzgjfHduYqWpQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-5Ofot8xbs+pxRHJqm9/9N/4sTQOvdrwEsmPE9pdLEEoAbdZtG6F2LMDfO1sp6ZAtXJuJV/21ew2srq3W8NXB5g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.18.tgz", + "integrity": "sha512-7h8eeOTT1eyqJyx64BFCnWZpNm486hGWt2sqeLLgDxA0xI1oGZ9H7gK1S85uNGmBhkdPwa/6reTxfFFKvIsebw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.18.tgz", + "integrity": "sha512-eRcm/HVt9U/JFu5RKAEKwGQYtDCKWLiaH6wOnsSEp6NMBb/3Os8LgHZlNyzMpFVNmiiMFlfb2zEnebfzJrHFmg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-SOrT/cT4ukTmgnrEz/Hg3m7LBnuCLW9psDeMKrimRWY4I8DmnO7Lco8W2vtqPmMkbVu8iJ+g4GFLVLLOVjJ9DQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-QWjdxN1HJCpBTAcZ5N5F7wju3gVPzRzSpmGzx7na0c/1qpN9CFil+xt+l9lV/1M6/gqHSNXCiqPfwhVJPeLnug==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-ugCOyj7a4d9h3q9B+wXmf6g3a68UsjGh6dob5DHevHGMwDUbhsYNbSPxJsENcIttJZ9jv7qGM2UesLw5jqIhdg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-kKWRhbsotpXkGbcd5dllUWg5gEXcDAa8u5YnP9AV5DYNbvJHGzzuwv7dpmhc8NqKMJldl0a+x76IHbspEpEmdA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.18.tgz", + "integrity": "sha512-uCo8ElcCIAMyYAZyuIZ81oFkhTSIllNvUCHCAlbhlN4ji3uC28h7IIdlXyIvGO7HsuqnV9p3rD/bpH7XhIyhRw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.18.tgz", + "integrity": "sha512-XNOQZtuE6yUIvx4rwGemwh8kpL1xvU41FXy/s9K7T/3JVcqGzo3NfKM2HrbrGgfPYGFW42f07Wk++aOC6B9NWA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.18.tgz", + "integrity": "sha512-tSn/kzrfa7tNOXr7sEacDBN4YsIqTyLqh45IO0nHDwtpKIDNDJr+VFojt+4klSpChxB29JLyduSsE0MKEwa65A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.18.tgz", + "integrity": "sha512-+J9YGmc+czgqlhYmwun3S3O0FIZhsH8ep2456xwjAdIOmuJxM7xz4P4PtrxU+Bz17a/5bqPA8o3HAAoX0teUdg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-zsu47DgU0FQzSwi6sU9dZoEdUv7pc1AptSEz/Z8HBg54sV0Pbs3N0+CrIbTsgiu6EyoaNN9CHboqbLaz9lhOyQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.18.tgz", + "integrity": "sha512-7H+3yqGgmnlDTRRhw/xpYY9J1kf4GC681nVc4GqKhExZTDrVVrV2tsOR9kso0fvgBdcTCcQShx4SLLoHgaLwhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.18.tgz", + "integrity": "sha512-CUY5Mnhe64xQBGZEEXQ5WyZwsc1JU3vAZLIxtrsBt3LO6UOb+C8GunVKqe9sT8NeWb4lqSaoJtp2xo6GxT1MNw==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.18", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.18.tgz", + "integrity": "sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmmirror.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmmirror.com/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colyseus.js": { + "version": "0.16.22", + "resolved": "https://registry.npmmirror.com/colyseus.js/-/colyseus.js-0.16.22.tgz", + "integrity": "sha512-xyiajukHvlwOtcziVbXZWmz7yBH3EImovYrGPAe2kVkdubLVYmOjskJuXh2VLlO8XGjyhmNwig9ELz18sTUo9g==", + "license": "MIT", + "dependencies": { + "@colyseus/httpie": "^2.0.0", + "@colyseus/msgpackr": "^1.11.2", + "@colyseus/schema": "^3.0.0", + "tslib": "^2.1.0", + "ws": "^8.13.0" + }, + "engines": { + "node": ">= 12.x" + }, + "funding": { + "url": "https://github.com/sponsors/endel" + } + }, + "node_modules/colyseus.js/node_modules/@colyseus/schema": { + "version": "3.0.76", + "resolved": "https://registry.npmmirror.com/@colyseus/schema/-/schema-3.0.76.tgz", + "integrity": "sha512-i+ceBZyhB7lTn5+BoG/xxYfzW4dKKyLOywsGKVgXHe9fD905AS/Lk180jd1bICEJhebGeiRXEQ2YUPl/xwFg2g==", + "license": "MIT", + "bin": { + "schema-codegen": "bin/schema-codegen", + "schema-debug": "bin/schema-debug" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmmirror.com/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmmirror.com/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmmirror.com/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmmirror.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmmirror.com/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmmirror.com/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmmirror.com/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmmirror.com/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmmirror.com/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmmirror.com/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmmirror.com/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.18", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.18.tgz", + "integrity": "sha512-phmyKBpuBdRYDf4hgyynGAYn/rDDe+iZXKVJ7WX5b1zQzpLkP5oJRPGsfJuHdzPMlyyEO/4sPW6yfSx2gf7lVg==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.128.0", + "@rolldown/pluginutils": "1.0.0-rc.18" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.18", + "@rolldown/binding-darwin-x64": "1.0.0-rc.18", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.18", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.18", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.18", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.18", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.18", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.18", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.18", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.18" + } + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmmirror.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.11", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.11.tgz", + "integrity": "sha512-Jz1mxtUBR5xTT65VOdJZUUeoyLtqljmFkiUXhPTLZka3RDc9vpi/xXkyrnsdRcm2lIi3l3GPMnAidTsEGIj3Ow==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.0-rc.18", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmmirror.com/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ec1fc76 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "guncircle.io", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "pnpm --filter client dev", + "dev:server": "pnpm --filter server dev", + "build": "pnpm --filter shared build && pnpm --filter client build && pnpm --filter server build", + "type-check": "pnpm --filter shared type-check && pnpm --filter client type-check && pnpm --filter server type-check", + "lint": "eslint packages/*/src --ext .ts", + "test": "pnpm --filter shared test && pnpm --filter server test" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^9.0.0", + "typescript": "^5.9.3" + }, + "packageManager": "pnpm@9.0.0", + "dependencies": { + "@colyseus/schema": "^4.0.25", + "colyseus.js": "^0.16.22", + "vite": "^8.0.11" + } +} diff --git a/packages/client/Dockerfile b/packages/client/Dockerfile new file mode 100644 index 0000000..ed3f83e --- /dev/null +++ b/packages/client/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate +COPY pnpm-workspace.yaml package.json ./ +COPY packages/shared/package.json packages/shared/ +COPY packages/client/package.json packages/client/ +RUN pnpm install --frozen-lockfile +COPY packages/shared/ packages/shared/ +COPY packages/client/ packages/client/ +RUN pnpm --filter @guncircle/shared build +RUN pnpm --filter @guncircle/client build + +FROM nginx:alpine +COPY --from=builder /app/packages/client/dist /usr/share/nginx/html +COPY packages/client/nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/packages/client/index.html b/packages/client/index.html new file mode 100644 index 0000000..5321264 --- /dev/null +++ b/packages/client/index.html @@ -0,0 +1,31 @@ + + + + + + GunCircle.io + + + + +
+ +
+
+ + + diff --git a/packages/client/nginx.conf b/packages/client/nginx.conf new file mode 100644 index 0000000..9c76544 --- /dev/null +++ b/packages/client/nginx.conf @@ -0,0 +1,18 @@ +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /ws { + proxy_pass http://server:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..b70138a --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@guncircle/client", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@colyseus/schema": "^3.0.0", + "colyseus.js": "^0.16.0", + "@guncircle/shared": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vite": "^5.0.0" + } +} diff --git a/packages/client/src/camera.ts b/packages/client/src/camera.ts new file mode 100644 index 0000000..d08a255 --- /dev/null +++ b/packages/client/src/camera.ts @@ -0,0 +1,89 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Camera System +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + ARENA_WIDTH, + ARENA_HEIGHT, + lerp, +} from '@guncircle/shared'; + +/** Camera padding — keep this much arena visible beyond camera edge */ +const CAMERA_PADDING = 100; + +/** Camera follow lerp rate (units per second toward target) */ +const CAMERA_FOLLOW_RATE = 8; + +export class Camera { + /** Camera center X in world space */ + x = ARENA_WIDTH / 2; + /** Camera center Y in world space */ + y = ARENA_HEIGHT / 2; + /** Viewport width in pixels */ + width = 0; + /** Viewport height in pixels */ + height = 0; + + constructor(width: number, height: number) { + this.width = width; + this.height = height; + } + + /** Resize the camera viewport */ + resize(width: number, height: number): void { + this.width = width; + this.height = height; + this.clampToArena(); + } + + /** + * Smoothly follow a target position. + * @param target - Target world position {x, y} + * @param dt - Delta time in seconds + */ + follow(target: { x: number; y: number }, dt: number): void { + const rate = Math.min(CAMERA_FOLLOW_RATE * dt, 1); + this.x = lerp(this.x, target.x, rate); + this.y = lerp(this.y, target.y, rate); + this.clampToArena(); + } + + /** Clamp camera center so viewport stays within arena bounds + padding */ + clampToArena(): void { + const halfW = this.width / 2; + const halfH = this.height / 2; + + const minX = halfH - CAMERA_PADDING; + const maxX = ARENA_WIDTH - halfW + CAMERA_PADDING; + const minY = halfH - CAMERA_PADDING; + const maxY = ARENA_HEIGHT - halfH + CAMERA_PADDING; + + this.x = Math.max(minX, Math.min(maxX, this.x)); + this.y = Math.max(minY, Math.min(maxY, this.y)); + } + + /** Convert world coordinates to screen coordinates */ + worldToScreen(worldX: number, worldY: number): { sx: number; sy: number } { + return { + sx: worldX - this.x + this.width / 2, + sy: worldY - this.y + this.height / 2, + }; + } + + /** Convert screen coordinates to world coordinates */ + screenToWorld(screenX: number, screenY: number): { wx: number; wy: number } { + return { + wx: screenX + this.x - this.width / 2, + wy: screenY + this.y - this.height / 2, + }; + } + + /** Check if an entity with given radius is within the camera viewport */ + isInView(worldX: number, worldY: number, radius: number): boolean { + const halfW = this.width / 2 + radius; + const halfH = this.height / 2 + radius; + const dx = worldX - this.x; + const dy = worldY - this.y; + return Math.abs(dx) <= halfW && Math.abs(dy) <= halfH; + } +} diff --git a/packages/client/src/game.ts b/packages/client/src/game.ts new file mode 100644 index 0000000..3ea4ff8 --- /dev/null +++ b/packages/client/src/game.ts @@ -0,0 +1,250 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Main Game Controller +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + RoomState, + ARENA_WIDTH, + ARENA_HEIGHT, + PLAYER_BASE_SPEED, + lerp, +} from '@guncircle/shared'; +import type { PlayerInput } from '@guncircle/shared'; +import { Camera } from './camera.js'; +import { InputHandler } from './input.js'; +import { Renderer } from './renderer.js'; +import { NetworkManager } from './network.js'; +import { HUD } from './hud.js'; + +/** Maximum delta time to prevent spiral of death */ +const MAX_DT = 0.1; + +/** Camera follow lerp factor per second */ +const CAMERA_LERP_SPEED = 8; + +/** Local player visual recoil recovery rate */ +const RECOIL_RECOVERY_RATE = 0.15; + +/** Game state container */ +interface GameState { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + camera: Camera; + input: InputHandler; + renderer: Renderer; + network: NetworkManager; + hud: HUD; + localPlayerId: string | null; + lastTime: number; + running: boolean; + /** Visual recoil offset for local player (purely visual) */ + localRecoilOffset: number; + /** Animation time accumulator */ + animTime: number; +} + +/** Active game state instance */ +let game: GameState | null = null; + +/** RAF handle for cleanup */ +let rafHandle = 0; + +// ─── Initialization ────────────────────────────────────────────────────────── + +/** + * Start the game with the given player name. + * This is the main entry point called from main.ts. + */ +export async function startGame(playerName: string): Promise { + // Clean up any existing game + if (game) { + stopGame(); + } + + // Get canvas + const canvas = document.getElementById('game') as HTMLCanvasElement | null; + if (!canvas) { + throw new Error('Game canvas element not found'); + } + + // Size canvas to window + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + + // Get 2D context with alpha disabled for performance + const ctx = canvas.getContext('2d', { alpha: false }); + if (!ctx) { + throw new Error('Could not get 2D canvas context'); + } + + // Initialize subsystems + const camera = new Camera(canvas.width, canvas.height); + const input = new InputHandler(canvas); + const renderer = new Renderer(ctx, canvas); + const network = new NetworkManager(); + const hud = new HUD(); + + // Create game state + game = { + canvas, + ctx, + camera, + input, + renderer, + network, + hud, + localPlayerId: null, + lastTime: performance.now(), + running: true, + localRecoilOffset: 0, + animTime: 0, + }; + + // Handle window resize + const handleResize = (): void => { + if (!game) return; + const w = window.innerWidth; + const h = window.innerHeight; + game.canvas.width = w; + game.canvas.height = h; + game.camera.resize(w, h); + game.renderer.resize(w, h); + }; + window.addEventListener('resize', handleResize); + + // Store resize handler on game for cleanup + (game as unknown as Record)['_resizeHandler'] = handleResize; + + // Setup HUD upgrade callback + hud.onUpgrade((statIdx: number) => { + if (game?.network) { + game.network.sendUpgrade(statIdx); + } + }); + + // Connect to server + await network.connect('arena', { name: playerName }); + game.localPlayerId = network.localPlayerId; + hud.show(); + + // Setup disconnect handling + network.onDisconnect(() => { + if (game) { + game.running = false; + hud.hide(); + } + }); + + // Start sending input at 60Hz + input.startSending((inputData: PlayerInput) => { + if (game?.network) { + game.network.sendInput(inputData); + } + }); + + // Start the game loop + game.lastTime = performance.now(); + gameLoop(performance.now()); +} + +// ─── Game Loop ─────────────────────────────────────────────────────────────── + +function gameLoop(now: number): void { + if (!game || !game.running) return; + + // Calculate delta time + let dt = (now - game.lastTime) / 1000; + game.lastTime = now; + + // Cap dt to prevent spiral of death + if (dt > MAX_DT) { + dt = MAX_DT; + } + + game.animTime += dt; + + // Get server state + const roomState = game.network.state; + + if (roomState && game.localPlayerId) { + // ── Process input & client-side prediction ── + const inputState = game.input.getInput(); + game.network.applyPrediction(inputState, dt); + + // ── Update camera to follow local player ── + updateCamera(game, roomState, dt); + + // ── Update local visual recoil ── + updateRecoil(game, dt); + + // ── Update HUD ── + game.hud.update(roomState, game.localPlayerId); + } + + // ── Render frame ── + if (roomState) { + game.renderer.render( + roomState, + game.camera, + game.localPlayerId, + dt + ); + } + + // Queue next frame + rafHandle = requestAnimationFrame(gameLoop); +} + +// ─── Camera Update ─────────────────────────────────────────────────────────── + +function updateCamera(game: GameState, roomState: RoomState, dt: number): void { + if (!game.localPlayerId) return; + + const localPlayer = roomState.players.get(game.localPlayerId); + if (!localPlayer || !localPlayer.alive) return; + + // Use predicted position for camera if available, else server position + const predPos = game.network.getLocalPosition(); + const targetX = predPos ? predPos.x : localPlayer.x; + const targetY = predPos ? predPos.y : localPlayer.y; + + // Lerp camera toward target + const rate = Math.min(CAMERA_LERP_SPEED * dt, 1); + game.camera.x = lerp(game.camera.x, targetX, rate); + game.camera.y = lerp(game.camera.y, targetY, rate); + game.camera.clampToArena(); +} + +// ─── Recoil Update ─────────────────────────────────────────────────────────── + +function updateRecoil(game: GameState, _dt: number): void { + // Lerp visual recoil offset toward 0 each frame + game.localRecoilOffset = lerp( + game.localRecoilOffset, + 0, + RECOIL_RECOVERY_RATE + ); +} + +// ─── Cleanup ───────────────────────────────────────────────────────────────── + +export function stopGame(): void { + if (!game) return; + + game.running = false; + cancelAnimationFrame(rafHandle); + + game.input.destroy(); + game.network.disconnect(); + game.hud.destroy(); + + // Remove resize listener + const resizeHandler = (game as unknown as Record)['_resizeHandler'] as + | (() => void) + | undefined; + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler); + } + + game = null; +} diff --git a/packages/client/src/hud.ts b/packages/client/src/hud.ts new file mode 100644 index 0000000..37e9613 --- /dev/null +++ b/packages/client/src/hud.ts @@ -0,0 +1,374 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — HUD Renderer (HTML Overlays) +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + COLORS, + STAT_COUNT, + STAT_BONUSES, + Stat, + BRANCH_LEVELS, +} from '@guncircle/shared'; +import type { RoomState, Player } from '@guncircle/shared'; + +/** Stat display names */ +const STAT_NAMES: string[] = [ + 'Max HP', + 'HP Regen', + 'Move Speed', + 'Bullet Dmg', + 'Bullet Spd', + 'Reload Spd', + 'Recoil Stab', + 'Crit Chance', + 'Crit Dmg', +]; + +/** HUD bar dimensions */ +const BAR_WIDTH_HP = 200; +const BAR_HEIGHT_HP = 16; +const BAR_WIDTH_XP = 200; +const BAR_HEIGHT_XP = 10; + +export class HUD { + private readonly hudEl: HTMLDivElement; + private visible = false; + + // ─── HUD Element References ──────────────────────────────────────────────── + private hpBarEl: HTMLDivElement | null = null; + private hpBarFillEl: HTMLDivElement | null = null; + private hpTextEl: HTMLDivElement | null = null; + private xpBarEl: HTMLDivElement | null = null; + private xpBarFillEl: HTMLDivElement | null = null; + private xpTextEl: HTMLDivElement | null = null; + private ammoEl: HTMLDivElement | null = null; + private leaderboardEl: HTMLDivElement | null = null; + private upgradePopupEl: HTMLDivElement | null = null; + private upgradeGridEl: HTMLDivElement | null = null; + + /** Callback when a stat upgrade is chosen */ + private upgradeCallback: ((stat: number) => void) | null = null; + + constructor() { + const hudEl = document.getElementById('hud') as HTMLDivElement | null; + if (!hudEl) { + throw new Error('HUD: #hud element not found'); + } + this.hudEl = hudEl; + this.buildUI(); + } + + // ─── UI Construction ─────────────────────────────────────────────────────── + + private buildUI(): void { + this.hudEl.style.position = 'absolute'; + this.hudEl.style.top = '0'; + this.hudEl.style.left = '0'; + this.hudEl.style.width = '100%'; + this.hudEl.style.height = '100%'; + this.hudEl.style.pointerEvents = 'none'; + this.hudEl.style.fontFamily = 'sans-serif'; + + // ── HP Bar (top-left) ── + const hpContainer = this.createBarContainer('10px', '10px'); + this.hpTextEl = this.createTextEl('HP: --/--', '#ecf0f1', '12px'); + this.hpBarEl = this.createBar(BAR_WIDTH_HP, BAR_HEIGHT_HP, COLORS.hpBarBg); + this.hpBarFillEl = this.createBarFill(BAR_WIDTH_HP, BAR_HEIGHT_HP, COLORS.hpBar); + this.hpBarEl.appendChild(this.hpBarFillEl); + hpContainer.appendChild(this.hpTextEl); + hpContainer.appendChild(this.hpBarEl); + this.hudEl.appendChild(hpContainer); + + // ── XP Bar (below HP) ── + const xpContainer = this.createBarContainer('10px', '36px'); + this.xpTextEl = this.createTextEl('Level 1', '#ecf0f1', '12px'); + this.xpBarEl = this.createBar(BAR_WIDTH_XP, BAR_HEIGHT_XP, COLORS.hpBarBg); + this.xpBarFillEl = this.createBarFill(BAR_WIDTH_XP, BAR_HEIGHT_XP, '#f1c40f'); + this.xpBarEl.appendChild(this.xpBarFillEl); + xpContainer.appendChild(this.xpTextEl); + xpContainer.appendChild(this.xpBarEl); + this.hudEl.appendChild(xpContainer); + + // ── Ammo (bottom-right) ── + const ammoContainer = this.createBarContainer('auto', 'auto'); + ammoContainer.style.right = '10px'; + ammoContainer.style.bottom = '10px'; + this.ammoEl = this.createTextEl('Ammo: --/--', '#ecf0f1', '16px'); + this.ammoEl.style.fontWeight = 'bold'; + ammoContainer.appendChild(this.ammoEl); + this.hudEl.appendChild(ammoContainer); + + // ── Leaderboard (top-right) ── + const lbContainer = this.createBarContainer('auto', '10px'); + lbContainer.style.right = '10px'; + this.leaderboardEl = document.createElement('div'); + this.leaderboardEl.style.background = COLORS.uiBg; + this.leaderboardEl.style.border = `1px solid ${COLORS.uiBorder}`; + this.leaderboardEl.style.borderRadius = '6px'; + this.leaderboardEl.style.padding = '8px 12px'; + this.leaderboardEl.style.minWidth = '200px'; + this.leaderboardEl.style.pointerEvents = 'auto'; + lbContainer.appendChild(this.leaderboardEl); + this.hudEl.appendChild(lbContainer); + + // ── Upgrade Popup (centered, hidden by default) ── + this.upgradePopupEl = document.createElement('div'); + this.upgradePopupEl.style.position = 'absolute'; + this.upgradePopupEl.style.top = '50%'; + this.upgradePopupEl.style.left = '50%'; + this.upgradePopupEl.style.transform = 'translate(-50%, -50%)'; + this.upgradePopupEl.style.background = 'rgba(0,0,0,0.85)'; + this.upgradePopupEl.style.border = `2px solid ${COLORS.uiBorder}`; + this.upgradePopupEl.style.borderRadius = '10px'; + this.upgradePopupEl.style.padding = '20px'; + this.upgradePopupEl.style.display = 'none'; + this.upgradePopupEl.style.pointerEvents = 'auto'; + this.upgradePopupEl.style.zIndex = '50'; + this.upgradePopupEl.style.minWidth = '340px'; + + const upgradeTitle = this.createTextEl('Upgrade Points Available', '#f1c40f', '20px'); + upgradeTitle.style.textAlign = 'center'; + upgradeTitle.style.marginBottom = '12px'; + upgradeTitle.style.fontWeight = 'bold'; + this.upgradePopupEl.appendChild(upgradeTitle); + + this.upgradeGridEl = document.createElement('div'); + this.upgradeGridEl.style.display = 'grid'; + this.upgradeGridEl.style.gridTemplateColumns = 'repeat(3, 1fr)'; + this.upgradeGridEl.style.gap = '8px'; + this.upgradePopupEl.appendChild(this.upgradeGridEl); + + this.hudEl.appendChild(this.upgradePopupEl); + } + + // ─── DOM Helpers ─────────────────────────────────────────────────────────── + + private createBarContainer(left: string, top: string): HTMLDivElement { + const el = document.createElement('div'); + el.style.position = 'absolute'; + el.style.left = left; + el.style.top = top; + el.style.pointerEvents = 'auto'; + return el; + } + + private createTextEl(text: string, color: string, fontSize: string): HTMLDivElement { + const el = document.createElement('div'); + el.textContent = text; + el.style.color = color; + el.style.fontSize = fontSize; + el.style.marginBottom = '2px'; + el.style.textShadow = '0 1px 2px rgba(0,0,0,0.8)'; + return el; + } + + private createBar(width: number, height: number, bgColor: string): HTMLDivElement { + const el = document.createElement('div'); + el.style.width = `${width}px`; + el.style.height = `${height}px`; + el.style.background = bgColor; + el.style.borderRadius = '3px'; + el.style.overflow = 'hidden'; + el.style.position = 'relative'; + return el; + } + + private createBarFill(width: number, height: number, color: string): HTMLDivElement { + const el = document.createElement('div'); + el.style.width = '100%'; + el.style.height = `${height}px`; + el.style.background = color; + el.style.transition = 'width 0.2s ease'; + return el; + } + + // ─── Visibility ──────────────────────────────────────────────────────────── + + show(): void { + this.visible = true; + this.hudEl.style.display = 'block'; + } + + hide(): void { + this.visible = false; + this.hudEl.style.display = 'none'; + } + + // ─── Update from State ───────────────────────────────────────────────────── + + update(state: RoomState, localPlayerId: string | null): void { + if (!this.visible || !localPlayerId) return; + + const player = state.players.get(localPlayerId); + if (!player) return; + + this.updateHP(player); + this.updateXP(player); + this.updateAmmo(player); + this.updateLeaderboard(state); + this.updateUpgrades(player); + } + + private updateHP(player: Player): void { + if (!this.hpTextEl || !this.hpBarFillEl) return; + + const hpPct = Math.max(0, player.hp / player.maxHp); + const hpText = `HP: ${Math.ceil(player.hp)}/${player.maxHp}`; + + this.hpTextEl.textContent = hpText; + this.hpBarFillEl.style.width = `${hpPct * 100}%`; + + // Change color based on HP level + if (hpPct > 0.5) { + this.hpBarFillEl.style.background = COLORS.hpBar; + } else if (hpPct > 0.25) { + this.hpBarFillEl.style.background = '#f39c12'; + } else { + this.hpBarFillEl.style.background = '#e74c3c'; + } + } + + private updateXP(player: Player): void { + if (!this.xpTextEl || !this.xpBarFillEl) return; + + const xpPct = Math.min(1, player.xp / player.xpToNext); + + this.xpTextEl.textContent = `Level ${player.level} (${player.xp}/${player.xpToNext} XP)`; + this.xpBarFillEl.style.width = `${xpPct * 100}%`; + } + + private updateAmmo(player: Player): void { + if (!this.ammoEl) return; + + if (player.isReloading) { + this.ammoEl.textContent = 'RELOADING...'; + this.ammoEl.style.color = '#e74c3c'; + } else { + this.ammoEl.textContent = `Ammo: ${player.ammo}/${player.maxAmmo}`; + this.ammoEl.style.color = '#ecf0f1'; + } + } + + private updateLeaderboard(state: RoomState): void { + if (!this.leaderboardEl) return; + + // Build leaderboard HTML from state + const entries: Array<{ name: string; level: number; score: number; xp: number; id: string }> = []; + for (const [id, p] of state.players) { + if (p.alive) { + entries.push({ + name: p.name, + level: p.level, + score: p.score, + xp: p.xp, + id, + }); + } + } + + entries.sort((a, b) => b.xp - a.xp); + const top10 = entries.slice(0, 10); + + let html = '
Leaderboard
'; + for (let i = 0; i < top10.length; i++) { + const e = top10[i]; + const color = i === 0 ? '#f1c40f' : i === 1 ? '#bdc3c7' : i === 2 ? '#cd7f32' : '#ecf0f1'; + html += `
${i + 1}. ${e.name} — Lvl ${e.level} — ${e.score} kills
`; + } + + this.leaderboardEl.innerHTML = html; + } + + // ─── Upgrade Popup ───────────────────────────────────────────────────────── + + private updateUpgrades(player: Player): void { + if (!this.upgradePopupEl || !this.upgradeGridEl) return; + + if (player.upgradePoints > 0) { + this.upgradePopupEl.style.display = 'block'; + this.renderUpgradeButtons(player); + } else { + this.upgradePopupEl.style.display = 'none'; + } + } + + private renderUpgradeButtons(player: Player): void { + if (!this.upgradeGridEl) return; + + // Only rebuild if needed (simple check: compare point count) + const currentPoints = this.upgradeGridEl.dataset.points; + if (currentPoints === String(player.upgradePoints) && + this.upgradeGridEl.dataset.stats === String(player.stats)) { + return; + } + this.upgradeGridEl.dataset.points = String(player.upgradePoints); + this.upgradeGridEl.dataset.stats = String(player.stats); + + this.upgradeGridEl.innerHTML = ''; + + for (let i = 0; i < STAT_COUNT; i++) { + const statLevel = player.stats[i] ?? 0; + const statName = STAT_NAMES[i]; + const bonus = STAT_BONUSES[i as Stat]; + const bonusText = bonus.isPct + ? `+${Math.round(bonus.perLevel * 100)}%` + : `+${bonus.perLevel}`; + + const btn = document.createElement('button'); + btn.style.background = '#2c3e50'; + btn.style.border = '1px solid #34495e'; + btn.style.borderRadius = '6px'; + btn.style.padding = '8px 4px'; + btn.style.color = '#ecf0f1'; + btn.style.fontSize = '11px'; + btn.style.cursor = 'pointer'; + btn.style.textAlign = 'center'; + btn.style.transition = 'background 0.15s'; + btn.style.pointerEvents = 'auto'; + + btn.innerHTML = ` +
${statName}
+
${bonusText}
+
Lvl ${statLevel}/7
+ `; + + btn.addEventListener('mouseenter', () => { + btn.style.background = '#34495e'; + }); + btn.addEventListener('mouseleave', () => { + btn.style.background = '#2c3e50'; + }); + + const statIdx = i; + btn.addEventListener('click', () => { + if (this.upgradeCallback) { + this.upgradeCallback(statIdx); + } + }); + + this.upgradeGridEl.appendChild(btn); + } + + // Points remaining label + const pointsLabel = document.createElement('div'); + pointsLabel.style.gridColumn = '1 / -1'; + pointsLabel.style.textAlign = 'center'; + pointsLabel.style.color = '#f1c40f'; + pointsLabel.style.fontSize = '14px'; + pointsLabel.style.marginTop = '4px'; + pointsLabel.textContent = `${player.upgradePoints} point${player.upgradePoints > 1 ? 's' : ''} remaining`; + this.upgradeGridEl.appendChild(pointsLabel); + } + + /** Register callback for stat upgrade selection */ + onUpgrade(callback: (stat: number) => void): void { + this.upgradeCallback = callback; + } + + // ─── Cleanup ─────────────────────────────────────────────────────────────── + + destroy(): void { + this.hide(); + this.hudEl.innerHTML = ''; + } +} diff --git a/packages/client/src/input.ts b/packages/client/src/input.ts new file mode 100644 index 0000000..b6f1574 --- /dev/null +++ b/packages/client/src/input.ts @@ -0,0 +1,208 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Input Handler (WASD + Mouse) +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + INPUT_MS, + type PlayerInput, +} from '@guncircle/shared'; + +/** Input sequence counter */ +let globalSeq = 0; + +/** Movement angle for each WASD key (in radians, standard math convention) */ +const KEY_ANGLES: Record = { + KeyW: -Math.PI / 2, // Up + KeyS: Math.PI / 2, // Down + KeyA: Math.PI, // Left + KeyD: 0, // Right +}; + +export class InputHandler { + /** Currently pressed keys (WASD) */ + private readonly keys = new Set(); + /** Mouse X in screen coordinates */ + mouseX = 0; + /** Mouse Y in screen coordinates */ + mouseY = 0; + /** Is left mouse button held down */ + mouseDown = false; + /** Canvas element for center calculations */ + private readonly canvas: HTMLCanvasElement; + /** Bound event handlers for cleanup */ + private readonly boundKeyDown: (e: KeyboardEvent) => void; + private readonly boundKeyUp: (e: KeyboardEvent) => void; + private readonly boundMouseMove: (e: MouseEvent) => void; + private readonly boundMouseDown: (e: MouseEvent) => void; + private readonly boundMouseUp: (e: MouseEvent) => void; + private readonly boundContextMenu: (e: Event) => void; + /** Input send interval ID */ + private inputInterval: number | null = null; + /** Callback to send input to network */ + private sendCallback: ((input: PlayerInput) => void) | null = null; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + + this.boundKeyDown = this.handleKeyDown.bind(this); + this.boundKeyUp = this.handleKeyUp.bind(this); + this.boundMouseMove = this.handleMouseMove.bind(this); + this.boundMouseDown = this.handleMouseDown.bind(this); + this.boundMouseUp = this.handleMouseUp.bind(this); + this.boundContextMenu = this.handleContextMenu.bind(this); + + window.addEventListener('keydown', this.boundKeyDown); + window.addEventListener('keyup', this.boundKeyUp); + window.addEventListener('mousemove', this.boundMouseMove); + window.addEventListener('mousedown', this.boundMouseDown); + window.addEventListener('mouseup', this.boundMouseUp); + window.addEventListener('contextmenu', this.boundContextMenu); + } + + // ─── Event Handlers ──────────────────────────────────────────────────────── + + private handleKeyDown(e: KeyboardEvent): void { + // Skip WASD handling when typing in an input/textarea + const activeTag = (document.activeElement?.tagName ?? '').toLowerCase(); + if (activeTag === 'input' || activeTag === 'textarea') { + return; + } + + if (e.code in KEY_ANGLES) { + e.preventDefault(); + this.keys.add(e.code); + } + } + + private handleKeyUp(e: KeyboardEvent): void { + const activeTag = (document.activeElement?.tagName ?? '').toLowerCase(); + if (activeTag === 'input' || activeTag === 'textarea') { + return; + } + + if (e.code in KEY_ANGLES) { + e.preventDefault(); + this.keys.delete(e.code); + } + } + + private handleMouseMove(e: MouseEvent): void { + this.mouseX = e.clientX; + this.mouseY = e.clientY; + } + + private handleMouseDown(e: MouseEvent): void { + if (e.button === 0) { + this.mouseDown = true; + } + } + + private handleMouseUp(e: MouseEvent): void { + if (e.button === 0) { + this.mouseDown = false; + } + } + + private handleContextMenu(e: Event): void { + e.preventDefault(); + } + + // ─── Query Methods ───────────────────────────────────────────────────────── + + /** + * Compute movement angle from active WASD keys. + * Returns -1 if no movement keys are pressed. + * Diagonal movement averages the angles of active keys. + */ + getMoveAngle(): number { + if (this.keys.size === 0) { + return -1; + } + + let sumX = 0; + let sumY = 0; + + for (const key of this.keys) { + const angle = KEY_ANGLES[key]; + if (angle !== undefined) { + sumX += Math.cos(angle); + sumY += Math.sin(angle); + } + } + + return Math.atan2(sumY, sumX); + } + + /** + * Compute aim angle in world space from mouse position. + * Converts screen mouse coords to world coords using camera position, + * then computes angle from player to mouse in world space. + */ + getAimAngle(cameraX: number, cameraY: number, playerX: number, playerY: number): number { + // Convert screen mouse to world coordinates + const worldMouseX = this.mouseX - this.canvas.width / 2 + cameraX; + const worldMouseY = this.mouseY - this.canvas.height / 2 + cameraY; + return Math.atan2(worldMouseY - playerY, worldMouseX - playerX); + } + + /** Is the player currently shooting (left mouse held) */ + isShooting(): boolean { + return this.mouseDown; + } + + // ─── Serialization ───────────────────────────────────────────────────────── + + /** Build a PlayerInput from the current input state */ + getInput(cameraX: number, cameraY: number, playerX: number, playerY: number): PlayerInput { + globalSeq++; + return { + seq: globalSeq, + moveAngle: this.getMoveAngle(), + aimAngle: this.getAimAngle(cameraX, cameraY, playerX, playerY), + isShooting: this.mouseDown, + }; + } + + // ─── Input Sending ───────────────────────────────────────────────────────── + + /** + * Start sending input at 60Hz. + * @param getCamera - Returns current camera position [x, y] + * @param getPlayer - Returns current player position [x, y] + * @param sendFn - Callback that receives the PlayerInput + */ + startSending( + getCamera: () => [number, number], + getPlayer: () => [number, number], + sendFn: (input: PlayerInput) => void + ): void { + this.sendCallback = sendFn; + this.inputInterval = window.setInterval(() => { + const [camX, camY] = getCamera(); + const [plX, plY] = getPlayer(); + const input = this.getInput(camX, camY, plX, plY); + sendFn(input); + }, INPUT_MS); + } + + /** Stop the input sending interval */ + stopSending(): void { + if (this.inputInterval !== null) { + clearInterval(this.inputInterval); + this.inputInterval = null; + } + } + + // ─── Cleanup ─────────────────────────────────────────────────────────────── + + /** Remove all event listeners */ + destroy(): void { + this.stopSending(); + window.removeEventListener('keydown', this.boundKeyDown); + window.removeEventListener('keyup', this.boundKeyUp); + window.removeEventListener('mousemove', this.boundMouseMove); + window.removeEventListener('mousedown', this.boundMouseDown); + window.removeEventListener('mouseup', this.boundMouseUp); + window.removeEventListener('contextmenu', this.boundContextMenu); + } +} diff --git a/packages/client/src/interpolation.ts b/packages/client/src/interpolation.ts new file mode 100644 index 0000000..89ea1eb --- /dev/null +++ b/packages/client/src/interpolation.ts @@ -0,0 +1,167 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Entity Interpolation (Pure Functions) +// ═══════════════════════════════════════════════════════════════════════════════ + +import { lerp, lerpAngle, vec2 } from '@guncircle/shared'; +import type { Player, Bullet } from '@guncircle/shared'; +import { POS_DRIFT_THRESHOLD } from '@guncircle/shared'; + +// ─── Snapshot Types ────────────────────────────────────────────────────────── + +/** A lightweight snapshot of player state for interpolation */ +export interface PlayerSnapshot { + x: number; + y: number; + angle: number; + hp: number; + recoilOffset: number; + timestamp: number; +} + +/** A lightweight snapshot of bullet state for interpolation */ +export interface BulletSnapshot { + x: number; + y: number; + angle: number; + timestamp: number; +} + +/** A server state snapshot at a given time */ +export interface StateSnapshot { + timestamp: number; + tick: number; + players: Map; + bullets: Map; +} + +// ─── Player Interpolation ──────────────────────────────────────────────────── + +/** + * Interpolate between two player snapshots. + * @param prev - Previous snapshot + * @param curr - Current snapshot + * @param t - Interpolation factor [0, 1] + * @returns Interpolated snapshot + */ +export function interpolatePlayer( + prev: PlayerSnapshot, + curr: PlayerSnapshot, + t: number +): PlayerSnapshot { + return { + x: lerp(prev.x, curr.x, t), + y: lerp(prev.y, curr.y, t), + angle: lerpAngle(prev.angle, curr.angle, t), + hp: lerp(prev.hp, curr.hp, t), + recoilOffset: lerp(prev.recoilOffset, curr.recoilOffset, t), + timestamp: lerp(prev.timestamp, curr.timestamp, t), + }; +} + +// ─── Bullet Interpolation ──────────────────────────────────────────────────── + +/** + * Interpolate between two bullet snapshots. + * @param prev - Previous snapshot + * @param curr - Current snapshot + * @param t - Interpolation factor [0, 1] + * @returns Interpolated snapshot + */ +export function interpolateBullet( + prev: BulletSnapshot, + curr: BulletSnapshot, + t: number +): BulletSnapshot { + return { + x: lerp(prev.x, curr.x, t), + y: lerp(prev.y, curr.y, t), + angle: lerpAngle(prev.angle, curr.angle, t), + timestamp: lerp(prev.timestamp, curr.timestamp, t), + }; +} + +// ─── Server Reconciliation ─────────────────────────────────────────────────── + +/** + * Check if predicted position has drifted too far from server position. + */ +export function shouldCorrect( + predicted: { x: number; y: number }, + server: { x: number; y: number } +): boolean { + const dx = predicted.x - server.x; + const dy = predicted.y - server.y; + return dx * dx + dy * dy > POS_DRIFT_THRESHOLD * POS_DRIFT_THRESHOLD; +} + +/** + * Smoothly correct predicted position toward server position. + * Uses a lerp at ~15 units/sec for smooth visual correction. + */ +export function smoothCorrection( + predicted: { x: number; y: number }, + server: { x: number; y: number }, + dt: number +): { x: number; y: number } { + // Correction speed: ~15 units per second + const correctionSpeed = 15; + const diff = vec2.sub(server, predicted); + const dist = vec2.len(diff); + + if (dist < 0.1) { + return { x: server.x, y: server.y }; + } + + // Move toward server position at fixed speed, but don't overshoot + const maxMove = correctionSpeed * dt; + if (dist <= maxMove) { + return { x: server.x, y: server.y }; + } + + const t = maxMove / dist; + return { + x: predicted.x + diff.x * t, + y: predicted.y + diff.y * t, + }; +} + +// ─── Snapshot Builders ─────────────────────────────────────────────────────── + +/** + * Build a state snapshot from the current Colyseus room state. + */ +export function buildSnapshot( + players: Map, + bullets: Map, + tick: number +): StateSnapshot { + const playerSnaps = new Map(); + const bulletSnaps = new Map(); + + for (const [id, p] of players) { + playerSnaps.set(id, { + x: p.x, + y: p.y, + angle: p.angle, + hp: p.hp, + recoilOffset: p.recoilOffset, + timestamp: performance.now(), + }); + } + + for (const [id, b] of bullets) { + bulletSnaps.set(id, { + x: b.x, + y: b.y, + angle: b.angle, + timestamp: performance.now(), + }); + } + + return { + timestamp: performance.now(), + tick, + players: playerSnaps, + bullets: bulletSnaps, + }; +} diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts new file mode 100644 index 0000000..df7c9c1 --- /dev/null +++ b/packages/client/src/main.ts @@ -0,0 +1,66 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Client Entry Point +// ═══════════════════════════════════════════════════════════════════════════════ + +import { Menu } from './menu.js'; +import { startGame, stopGame } from './game.js'; +import { startOfflineGame, stopOfflineGame } from './offline-game.js'; + +// ─── Application State ─────────────────────────────────────────────────────── + +/** Is the game currently running */ +let isPlaying = false; +/** Is offline mode active */ +let isOffline = false; + +// ─── DOM Ready ─────────────────────────────────────────────────────────────── + +function init(): void { + // Create menu handler + const menu = new Menu(); + + // Register play callback + menu.onPlay((name: string) => { + if (isPlaying) return; + + isPlaying = true; + menu.hide(); + + // Start in offline mode immediately (no server required) + isOffline = true; + startOfflineGame(name); + }); + + // Show menu initially + menu.show(); + + // Handle Enter key on menu + const nameInput = document.getElementById('nameInput') as HTMLInputElement | null; + if (nameInput) { + nameInput.focus(); + } + + // Handle page unload — clean up + window.addEventListener('beforeunload', () => { + stopGame(); + stopOfflineGame(); + }); + + // Handle escape to return to menu + window.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape' && isPlaying) { + stopGame(); + stopOfflineGame(); + isPlaying = false; + isOffline = false; + menu.show(); + } + }); +} + +// Start when DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/packages/client/src/menu.ts b/packages/client/src/menu.ts new file mode 100644 index 0000000..02f62fb --- /dev/null +++ b/packages/client/src/menu.ts @@ -0,0 +1,86 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Menu Handler +// ═══════════════════════════════════════════════════════════════════════════════ + +export class Menu { + private readonly menuEl: HTMLDivElement; + private readonly nameInput: HTMLInputElement; + private readonly playBtn: HTMLButtonElement; + private playCallback: ((name: string) => void) | null = null; + private keyHandler: ((e: KeyboardEvent) => void) | null = null; + + constructor() { + const menuEl = document.getElementById('menu') as HTMLDivElement | null; + const nameInput = document.getElementById('nameInput') as HTMLInputElement | null; + const playBtn = document.getElementById('playBtn') as HTMLButtonElement | null; + + if (!menuEl || !nameInput || !playBtn) { + throw new Error('Menu: required DOM elements not found'); + } + + this.menuEl = menuEl; + this.nameInput = nameInput; + this.playBtn = playBtn; + + this.setupListeners(); + } + + private setupListeners(): void { + // Play button click + this.playBtn.addEventListener('click', (e) => { + e.preventDefault(); + this.handlePlay(); + }); + + // Enter key in input field + this.keyHandler = (e: KeyboardEvent): void => { + e.preventDefault(); + if (e.key === 'Enter') { + this.handlePlay(); + } + }; + this.nameInput.addEventListener('keydown', this.keyHandler); + } + + private handlePlay(): void { + const name = this.getName(); + if (this.playCallback) { + this.playCallback(name); + } + } + + /** Get trimmed name from input, defaulting to "Player" */ + getName(): string { + const raw = this.nameInput.value.trim(); + return raw.length > 0 ? raw.slice(0, 16) : 'Player'; + } + + /** Register callback for when player clicks PLAY */ + onPlay(callback: (name: string) => void): void { + this.playCallback = callback; + } + + /** Show the menu overlay */ + show(): void { + this.menuEl.style.display = 'flex'; + this.nameInput.focus(); + } + + /** Hide the menu overlay */ + hide(): void { + this.menuEl.style.display = 'none'; + this.nameInput.blur(); + // Move focus to canvas so it receives keyboard events + const canvas = document.getElementById('game') as HTMLCanvasElement | null; + if (canvas) { + canvas.focus(); + } + } + + /** Clean up event listeners */ + destroy(): void { + if (this.keyHandler) { + this.nameInput.removeEventListener('keydown', this.keyHandler); + } + } +} diff --git a/packages/client/src/network.ts b/packages/client/src/network.ts new file mode 100644 index 0000000..f8483be --- /dev/null +++ b/packages/client/src/network.ts @@ -0,0 +1,402 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Network Manager (Colyseus Client) +// ═══════════════════════════════════════════════════════════════════════════════ + +import { Client, Room } from 'colyseus.js'; +import { + RoomState, + Player, + type PlayerInput, + POS_DRIFT_THRESHOLD, + PLAYER_BASE_SPEED, + PLAYER_FRICTION, + ARENA_WIDTH, + ARENA_HEIGHT, + lerp, + vec2, +} from '@guncircle/shared'; +import { + buildSnapshot, + shouldCorrect, + smoothCorrection, + type StateSnapshot, + type PlayerSnapshot, +} from './interpolation.js'; + +/** Interpolation delay in ms — render 100ms behind server */ +const INTERP_DELAY_MS = 100; + +/** Max snapshots to keep in buffer */ +const MAX_SNAPSHOTS = 32; + +/** Smooth correction lerp factor per second */ +const CORRECTION_LERP_RATE = 5; + +/** Pending input with sequence number */ +interface PendingInput { + seq: number; + input: PlayerInput; + predictedX: number; + predictedY: number; +} + +/** Predicted player state for client-side prediction */ +interface PredictedState { + x: number; + y: number; + vx: number; + vy: number; + lastProcessedSeq: number; +} + +export class NetworkManager { + private client: Client | null = null; + private room: Room | null = null; + /** Local player session ID */ + localPlayerId: string | null = null; + /** Current room state */ + state: RoomState | null = null; + /** Snapshot buffer for interpolation [oldest ... newest] */ + private snapshots: StateSnapshot[] = []; + /** Pending inputs for reconciliation */ + private pendingInputs: PendingInput[] = []; + /** Predicted local player state */ + predictedState: PredictedState | null = null; + /** Is connected */ + connected = false; + /** Connection error message */ + error: string | null = null; + /** On disconnect callback */ + private disconnectCallback: (() => void) | null = null; + /** On connect callback */ + private connectCallback: ((localId: string) => void) | null = null; + /** Current input sequence number */ + private inputSeq = 0; + + // ─── Connection ──────────────────────────────────────────────────────────── + + /** + * Connect to Colyseus server and join a room. + */ + async connect(roomName: string, options: { name: string }): Promise { + try { + // Connect via WebSocket proxy + this.client = new Client('ws://localhost:3000'); + + this.room = await Promise.race([ + this.client.joinOrCreate(roomName, { + name: options.name.slice(0, 16), + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Connection timed out')), 5000) + ), + ]); + + this.localPlayerId = this.room.sessionId; + this.connected = true; + this.error = null; + + this.setupRoomHandlers(); + + if (this.connectCallback) { + this.connectCallback(this.localPlayerId); + } + } catch (err) { + this.connected = false; + this.error = err instanceof Error ? err.message : 'Connection failed'; + throw err; + } + } + + private setupRoomHandlers(): void { + if (!this.room) return; + + this.room.onStateChange((state) => { + this.handleStateChange(state); + }); + + this.room.onMessage('error', (msg: string) => { + console.error('[Network] Server error:', msg); + }); + + this.room.onLeave((code) => { + console.log(`[Network] Left room (code: ${code})`); + this.connected = false; + this.localPlayerId = null; + if (this.disconnectCallback) { + this.disconnectCallback(); + } + }); + + this.room.onError((code: number) => { + console.error('[Network] Room error, code:', code); + this.error = `Connection error (code: ${code})`; + }); + } + + private handleStateChange(newState: RoomState): void { + this.state = newState; + + // Build and store snapshot for interpolation + const snapshot = buildSnapshot( + newState.players, + newState.bullets, + newState.tick + ); + this.addSnapshot(snapshot); + + // Server reconciliation for local player + if (this.localPlayerId) { + this.reconcileLocalPlayer(snapshot); + } + } + + private addSnapshot(snapshot: StateSnapshot): void { + this.snapshots.push(snapshot); + if (this.snapshots.length > MAX_SNAPSHOTS) { + this.snapshots.shift(); + } + } + + // ─── Client-Side Prediction ──────────────────────────────────────────────── + + /** + * Apply local movement prediction immediately. + * Call this each frame with the current input to predict local position. + */ + applyPrediction(input: PlayerInput, dt: number): void { + if (!this.localPlayerId || !this.state) return; + + const serverPlayer = this.state.players.get(this.localPlayerId); + if (!serverPlayer) return; + + // Initialize predicted state from server if needed + if (!this.predictedState) { + this.predictedState = { + x: serverPlayer.x, + y: serverPlayer.y, + vx: serverPlayer.vx, + vy: serverPlayer.vy, + lastProcessedSeq: input.seq, + }; + } + + // Apply movement based on input + if (input.moveAngle >= 0) { + const speed = PLAYER_BASE_SPEED * dt; + this.predictedState.vx += Math.cos(input.moveAngle) * speed * 5; + this.predictedState.vy += Math.sin(input.moveAngle) * speed * 5; + } + + // Apply friction + this.predictedState.vx *= PLAYER_FRICTION; + this.predictedState.vy *= PLAYER_FRICTION; + + // Update position + this.predictedState.x += this.predictedState.vx * dt; + this.predictedState.y += this.predictedState.vy * dt; + + // Clamp to arena + this.predictedState.x = Math.max(0, Math.min(ARENA_WIDTH, this.predictedState.x)); + this.predictedState.y = Math.max(0, Math.min(ARENA_HEIGHT, this.predictedState.y)); + + // Store pending input for reconciliation + this.pendingInputs.push({ + seq: input.seq, + input, + predictedX: this.predictedState.x, + predictedY: this.predictedState.y, + }); + + // Keep only last ~2 seconds of pending inputs + const maxPending = 120; + if (this.pendingInputs.length > maxPending) { + this.pendingInputs = this.pendingInputs.slice(-maxPending); + } + } + + // ─── Server Reconciliation ───────────────────────────────────────────────── + + /** + * Compare predicted position to server position and correct if needed. + */ + private reconcileLocalPlayer(serverSnapshot: StateSnapshot): void { + if (!this.predictedState || !this.localPlayerId) return; + + const serverPlayer = serverSnapshot.players.get(this.localPlayerId); + if (!serverPlayer) return; + + // Remove acknowledged inputs (anything older than the server tick) + // Since we don't have per-ack sequence numbers from server snapshots, + // we keep recent inputs and use a sliding window approach + const tooOld = this.pendingInputs.filter( + (pi) => pi.seq < serverSnapshot.tick - 4 + ); + if (tooOld.length > 0) { + this.pendingInputs = this.pendingInputs.filter( + (pi) => pi.seq >= serverSnapshot.tick - 4 + ); + } + + // Check for drift between predicted and server positions + if ( + shouldCorrect( + { x: this.predictedState.x, y: this.predictedState.y }, + { x: serverPlayer.x, y: serverPlayer.y } + ) + ) { + // Snap predicted state toward server position + this.predictedState.x = lerp( + this.predictedState.x, + serverPlayer.x, + 0.3 + ); + this.predictedState.y = lerp( + this.predictedState.y, + serverPlayer.y, + 0.3 + ); + // Reset velocity to zero (server sends velocity, but PlayerSnapshot doesn't include it) + // Velocity gets rebuilt by next frame's prediction + this.predictedState.vx *= 0.5; + this.predictedState.vy *= 0.5; + } + } + + /** + * Get the reconciled local player position for rendering. + * Returns the predicted position (which has been nudged toward server). + */ + getLocalPosition(): { x: number; y: number } | null { + if (this.predictedState) { + return { x: this.predictedState.x, y: this.predictedState.y }; + } + return null; + } + + // ─── Entity Interpolation ────────────────────────────────────────────────── + + /** + * Get interpolated player data for rendering. + * Returns interpolated position between server snapshots at (now - INTERP_DELAY_MS). + */ + getInterpolatedPlayer(playerId: string): PlayerSnapshot | null { + const renderTime = performance.now() - INTERP_DELAY_MS; + return this.getInterpolatedPlayerAtTime(playerId, renderTime); + } + + private getInterpolatedPlayerAtTime( + playerId: string, + renderTime: number + ): PlayerSnapshot | null { + const snaps = this.snapshots; + if (snaps.length < 2) { + // Not enough history — return latest known state + const latest = snaps[snaps.length - 1]; + if (latest) { + return latest.players.get(playerId) ?? null; + } + return null; + } + + // Find two snapshots that bracket renderTime + let beforeIdx = -1; + for (let i = snaps.length - 2; i >= 0; i--) { + if (snaps[i]!.timestamp <= renderTime) { + beforeIdx = i; + break; + } + } + + if (beforeIdx === -1) { + // renderTime is before all snapshots — return earliest + const p = snaps[0]!.players.get(playerId); + return p ?? null; + } + + const before = snaps[beforeIdx]!; + const after = snaps[beforeIdx + 1]!; + const playerBefore = before.players.get(playerId); + const playerAfter = after.players.get(playerId); + + if (!playerBefore || !playerAfter) { + // Player missing from one snapshot + return playerBefore ?? playerAfter ?? null; + } + + // Calculate interpolation factor + const timeSpan = after.timestamp - before.timestamp; + if (timeSpan === 0) return playerBefore; + + const t = Math.max(0, Math.min(1, (renderTime - before.timestamp) / timeSpan)); + + return { + x: lerp(playerBefore.x, playerAfter.x, t), + y: lerp(playerBefore.y, playerAfter.y, t), + angle: lerp(playerBefore.angle, playerAfter.angle, t), + hp: lerp(playerBefore.hp, playerAfter.hp, t), + recoilOffset: lerp(playerBefore.recoilOffset, playerAfter.recoilOffset, t), + timestamp: renderTime, + }; + } + + /** + * Get the interpolated position for a remote entity at render time. + * Used by the renderer for other players. + */ + getEntityPosition(entityId: string): { x: number; y: number } | null { + const interp = this.getInterpolatedPlayer(entityId); + if (interp) { + return { x: interp.x, y: interp.y }; + } + return null; + } + + // ─── Input Sending ───────────────────────────────────────────────────────── + + /** Send player input to the server */ + sendInput(input: PlayerInput): void { + if (!this.room || !this.connected) return; + + this.inputSeq++; + const inputWithSeq: PlayerInput = { + ...input, + seq: this.inputSeq, + }; + + this.room.send('input', inputWithSeq); + } + + /** Send upgrade choice to the server */ + sendUpgrade(choice: number): void { + if (!this.room || !this.connected) return; + this.room.send('upgrade', { choice }); + } + + // ─── Callbacks ───────────────────────────────────────────────────────────── + + onConnect(callback: (localId: string) => void): void { + this.connectCallback = callback; + } + + onDisconnect(callback: () => void): void { + this.disconnectCallback = callback; + } + + // ─── Disconnect ──────────────────────────────────────────────────────────── + + /** Leave the room and clean up */ + disconnect(): void { + if (this.room) { + this.room.leave(); + this.room = null; + } + this.client = null; + this.connected = false; + this.localPlayerId = null; + this.state = null; + this.snapshots = []; + this.pendingInputs = []; + this.predictedState = null; + } +} diff --git a/packages/client/src/offline-game.ts b/packages/client/src/offline-game.ts new file mode 100644 index 0000000..858f03c --- /dev/null +++ b/packages/client/src/offline-game.ts @@ -0,0 +1,176 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Offline Game Bridge +// ═══════════════════════════════════════════════════════════════════════════════ +// Connects OfflineEngine to Renderer, Camera, Input, and HUD for a fully +// playable single-player experience without a server. +// ═══════════════════════════════════════════════════════════════════════════════ + +import { OfflineEngine } from './offline-mode.js'; +import { Renderer } from './renderer.js'; +import { Camera } from './camera.js'; +import { InputHandler } from './input.js'; +import { HUD } from './hud.js'; +import type { PlayerInput } from '@guncircle/shared'; + +/** Max delta time to prevent spiral of death */ +const MAX_DT = 0.1; +/** Camera follow lerp rate */ +const CAMERA_LERP_SPEED = 8; + +/** Active offline game state */ +interface OfflineGameState { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + camera: Camera; + input: InputHandler; + renderer: Renderer; + hud: HUD; + engine: OfflineEngine; + lastTime: number; + running: boolean; +} + +let game: OfflineGameState | null = null; +let rafHandle = 0; +let inputInterval = 0; + +/** + * Start the offline game with the given player name. + */ +export function startOfflineGame(playerName: string): void { + // Clean up any existing game + stopOfflineGame(); + + // Get canvas + const canvas = document.getElementById('game') as HTMLCanvasElement | null; + if (!canvas) throw new Error('Game canvas not found'); + + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + + const ctx = canvas.getContext('2d', { alpha: false }); + if (!ctx) throw new Error('Could not get 2D context'); + + // Initialize subsystems + const camera = new Camera(canvas.width, canvas.height); + const input = new InputHandler(canvas); + const renderer = new Renderer(ctx, canvas); + const hud = new HUD(); + const engine = new OfflineEngine(); + + // Create game state + game = { + canvas, + ctx, + camera, + input, + renderer, + hud, + engine, + lastTime: performance.now(), + running: true, + }; + + // Handle resize + const handleResize = (): void => { + if (!game) return; + const w = window.innerWidth; + const h = window.innerHeight; + game.canvas.width = w; + game.canvas.height = h; + game.camera.resize(w, h); + game.renderer.resize(w, h); + }; + window.addEventListener('resize', handleResize); + (game as unknown as Record)['_resizeHandler'] = handleResize; + + // Start engine + engine.start(playerName); + engine.onUpgrade((choice: number) => { + engine.applyUpgrade(choice); + }); + + // Setup HUD + hud.show(); + hud.onUpgrade((statIdx: number) => { + engine.applyUpgrade(statIdx); + }); + + // Send input at 60Hz to the offline engine + input.startSending( + // getCamera + () => [camera.x, camera.y], + // getPlayer + () => { + const p = engine.state.players.get(engine.localPlayerId); + return p ? [p.x, p.y] : [camera.x, camera.y]; + }, + // sendFn + (inputData: PlayerInput) => { + if (!game) return; + game.engine.processInput( + inputData.moveAngle, + inputData.aimAngle, + inputData.isShooting, + inputData.seq + ); + } + ); + + // Start game loop + game.lastTime = performance.now(); + gameLoop(performance.now()); +} + +/** + * Main game loop using requestAnimationFrame. + */ +function gameLoop(now: number): void { + if (!game || !game.running) return; + + let dt = (now - game.lastTime) / 1000; + game.lastTime = now; + if (dt > MAX_DT) dt = MAX_DT; + + // Update camera to follow local player + const state = game.engine.state; + const localPlayer = state.players.get(game.engine.localPlayerId); + if (localPlayer && localPlayer.alive) { + const rate = Math.min(CAMERA_LERP_SPEED * dt, 1); + game.camera.x += (localPlayer.x - game.camera.x) * rate; + game.camera.y += (localPlayer.y - game.camera.y) * rate; + game.camera.clampToArena(); + } + + // Update HUD + game.hud.update(state, game.engine.localPlayerId); + + // Render + game.renderer.render(state, game.camera, game.engine.localPlayerId, dt); + + // Queue next frame + rafHandle = requestAnimationFrame(gameLoop); +} + +/** + * Stop the offline game and clean up all resources. + */ +export function stopOfflineGame(): void { + if (!game) return; + + game.running = false; + cancelAnimationFrame(rafHandle); + clearInterval(inputInterval); + + game.input.destroy(); + game.hud.destroy(); + + const resizeHandler = (game as unknown as Record)['_resizeHandler'] as + | (() => void) + | undefined; + if (resizeHandler) { + window.removeEventListener('resize', resizeHandler); + } + + game = null; +} diff --git a/packages/client/src/offline-mode.ts b/packages/client/src/offline-mode.ts new file mode 100644 index 0000000..a8b392a --- /dev/null +++ b/packages/client/src/offline-mode.ts @@ -0,0 +1,568 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Offline Demo Mode (Local Single-Player) +// ═══════════════════════════════════════════════════════════════════════════════ +// Provides a fully playable local mode when no server is available. +// AI bots move randomly, shoot periodically, drop XP on death. +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + RoomState, Player, Bullet, XPOrb, LeaderboardEntry, + ARENA_WIDTH, ARENA_HEIGHT, PLAYER_BASE_HP, PLAYER_BASE_SPEED, + PLAYER_BASE_RADIUS, PLAYER_FRICTION, TICK_MS, + XP_LEVELS, lerp, clamp, randFloat, randInt, randSign, vec2, + GUN_BARREL_LENGTH, + COLORS, + type GunConfig, +} from '@guncircle/shared'; + +// ─── Gun Configs (embedded for offline) ────────────────────────────────────── + +const GUNS: GunConfig[] = [ + { id: 'pistol', name: 'Pistol', category: 0, damage: 12, fireRate: 4, bulletSpeed: 400, pelletCount: 1, spread: 0, recoilOffset: 0.06, recoilRecoveryMs: 120, kickbackForce: 30, ammoMax: 12, reloadTimeMs: 1500, bulletSize: 4, penetration: 1, bulletLifetimeMs: 2000, bulletColor: '#f1c40f' }, + { id: 'rifle', name: 'Rifle', category: 1, damage: 18, fireRate: 3, bulletSpeed: 500, pelletCount: 1, spread: 0.02, recoilOffset: 0.08, recoilRecoveryMs: 180, kickbackForce: 45, ammoMax: 8, reloadTimeMs: 2000, bulletSize: 4.5, penetration: 2, bulletLifetimeMs: 2500, bulletColor: '#e67e22' }, + { id: 'shotgun', name: 'Shotgun', category: 2, damage: 8, fireRate: 1.2, bulletSpeed: 320, pelletCount: 5, spread: 0.25, recoilOffset: 0.15, recoilRecoveryMs: 300, kickbackForce: 80, ammoMax: 5, reloadTimeMs: 2500, bulletSize: 3, penetration: 1, bulletLifetimeMs: 1500, bulletColor: '#9b59b6' }, + { id: 'sniper', name: 'Sniper', category: 3, damage: 45, fireRate: 0.8, bulletSpeed: 700, pelletCount: 1, spread: 0, recoilOffset: 0.2, recoilRecoveryMs: 500, kickbackForce: 120, ammoMax: 3, reloadTimeMs: 3000, bulletSize: 6, penetration: 3, bulletLifetimeMs: 3500, bulletColor: '#e74c3c' }, + { id: 'smg', name: 'SMG', category: 4, damage: 6, fireRate: 10, bulletSpeed: 380, pelletCount: 1, spread: 0.06, recoilOffset: 0.04, recoilRecoveryMs: 80, kickbackForce: 20, ammoMax: 30, reloadTimeMs: 1800, bulletSize: 3, penetration: 1, bulletLifetimeMs: 1800, bulletColor: '#2ecc71' }, +]; + +// ─── Bot AI ────────────────────────────────────────────────────────────────── + +interface BotState { + id: string; + changeDirTimer: number; + shootTimer: number; + aimAngle: number; + moveAngle: number; +} + +// ─── OfflineEngine ─────────────────────────────────────────────────────────── + +export class OfflineEngine { + state = new RoomState(); + localPlayerId = 'local'; + private bots: Map = new Map(); + private bulletLifetimes: Map = new Map(); + private nextBulletId = 0; + private tickCount = 0; + private inputSeq = 0; + private lastShotTime = 0; + private reloadTimer = 0; + private isReloading = false; + private ammo = 12; + private localRecoil = 0; + private localVx = 0; + private localVy = 0; + private animTime = 0; + private upgradeCallback: ((choice: number) => void) | null = null; + private lastBotId = 0; + + /** Start the offline mode with the given player name */ + start(playerName: string): void { + // Create local player + const local = new Player(); + local.x = ARENA_WIDTH / 2; + local.y = ARENA_HEIGHT / 2; + local.name = playerName.substring(0, 16) || 'Player'; + local.hp = PLAYER_BASE_HP; + local.maxHp = PLAYER_BASE_HP; + local.level = 1; + local.xp = 0; + local.xpToNext = XP_LEVELS[1] ?? 50; + local.gunType = 0; + local.score = 0; + local.alive = true; + local.radius = PLAYER_BASE_RADIUS; + local.ammo = GUNS[0]!.ammoMax; + local.maxAmmo = GUNS[0]!.ammoMax; + local.isReloading = false; + local.recoilOffset = 0; + local.angle = 0; + local.vx = 0; + local.vy = 0; + this.state.players.set(this.localPlayerId, local); + + // Spawn bots + this.spawnBots(8); + + // Spawn some initial XP orbs + for (let i = 0; i < 20; i++) { + this.spawnRandomOrb(); + } + + // Start tick loop + setInterval(() => this.tick(), TICK_MS); + } + + private spawnBots(count: number): void { + const names = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Ghost', 'Hunter', 'Inferno', 'Juggernaut', 'Kraken', 'Lynx']; + for (let i = 0; i < count; i++) { + this.lastBotId++; + const id = `bot_${this.lastBotId}`; + const bot = new Player(); + bot.x = randFloat(200, ARENA_WIDTH - 200); + bot.y = randFloat(200, ARENA_HEIGHT - 200); + bot.name = names[i % names.length]!; + bot.hp = PLAYER_BASE_HP; + bot.maxHp = PLAYER_BASE_HP; + bot.level = randInt(1, 6); + bot.xp = 0; + bot.xpToNext = XP_LEVELS[bot.level] ?? 50; + bot.gunType = randInt(0, 5); + bot.score = 0; + bot.alive = true; + bot.radius = PLAYER_BASE_RADIUS; + // Give bots XP so they drop meaningful orbs on death + bot.xp = Math.floor((XP_LEVELS[bot.level] ?? 50) * 0.4); + const gun = GUNS[bot.gunType]!; + bot.ammo = gun.ammoMax; + bot.maxAmmo = gun.ammoMax; + bot.isReloading = false; + bot.recoilOffset = 0; + bot.angle = randFloat(0, Math.PI * 2); + bot.vx = 0; + bot.vy = 0; + this.state.players.set(id, bot); + + this.bots.set(id, { + id, + changeDirTimer: randFloat(1, 3), + shootTimer: randFloat(0.5, 2), + aimAngle: bot.angle, + moveAngle: randFloat(0, Math.PI * 2), + }); + } + } + + private spawnRandomOrb(): void { + const orb = new XPOrb(); + orb.x = randFloat(100, ARENA_WIDTH - 100); + orb.y = randFloat(100, ARENA_HEIGHT - 100); + orb.value = randInt(5, 25); + orb.lifetime = 30; + this.state.xpOrbs.set(`orb_${randInt(0, 1000000)}`, orb); + } + + // ─── Input ─────────────────────────────────────────────────────────────────── + + processInput(moveAngle: number, aimAngle: number, isShooting: boolean, _seq: number): void { + this.inputSeq++; + const local = this.state.players.get(this.localPlayerId); + if (!local || !local.alive) return; + + // Update aim + local.angle = aimAngle; + + // Movement + if (moveAngle >= 0) { + const speed = PLAYER_BASE_SPEED * 0.15; + this.localVx += Math.cos(moveAngle) * speed; + this.localVy += Math.sin(moveAngle) * speed; + const spd = Math.sqrt(this.localVx * this.localVx + this.localVy * this.localVy); + if (spd > PLAYER_BASE_SPEED) { + const s = PLAYER_BASE_SPEED / spd; + this.localVx *= s; + this.localVy *= s; + } + } + + // Shooting + if (isShooting) { + this.localShoot(local, aimAngle); + } + + // Apply movement + this.localVx *= PLAYER_FRICTION; + this.localVy *= PLAYER_FRICTION; + local.x += this.localVx * (TICK_MS / 1000); + local.y += this.localVy * (TICK_MS / 1000); + local.x = clamp(local.x, 0, ARENA_WIDTH); + local.y = clamp(local.y, 0, ARENA_HEIGHT); + local.vx = this.localVx; + local.vy = this.localVy; + + // Recoil recovery + this.localRecoil = lerp(this.localRecoil, 0, 0.15); + local.recoilOffset = this.localRecoil; + + // Reload + this.updateReload(); + } + + private localShoot(player: Player, aimAngle: number): void { + if (this.isReloading) return; + if (this.ammo <= 0) { + this.startReload(player); + return; + } + + const gun = GUNS[0]!; // Always pistol in offline mode + const now = performance.now(); + if (now - this.lastShotTime < 1000 / gun.fireRate) return; + + // Apply recoil + this.localRecoil = randSign() * gun.recoilOffset; + player.recoilOffset = this.localRecoil; + + // Kickback + const kb = vec2.fromAngle(aimAngle + Math.PI, gun.kickbackForce * 0.5); + this.localVx += kb.x; + this.localVy += kb.y; + + // Spawn bullet + const spawnPos = vec2.fromAngle(aimAngle + this.localRecoil, GUN_BARREL_LENGTH); + const bullet = new Bullet(); + bullet.x = player.x + spawnPos.x; + bullet.y = player.y + spawnPos.y; + const bVel = vec2.fromAngle(aimAngle + this.localRecoil, gun.bulletSpeed); + bullet.vx = bVel.x; + bullet.vy = bVel.y; + bullet.angle = aimAngle + this.localRecoil; + bullet.ownerId = this.localPlayerId; + bullet.damage = gun.damage; + bullet.penetration = gun.penetration; + bullet.bulletType = 0; + bullet.isCritical = Math.random() < 0.1; + bullet.size = gun.bulletSize; + bullet.color = gun.bulletColor; + + const bId = `b_${this.nextBulletId++}`; + this.state.bullets.set(bId, bullet); + this.bulletLifetimes.set(bId, now); + + this.ammo--; + this.lastShotTime = now; + if (this.ammo <= 0) { + this.startReload(player); + } + } + + private startReload(player: Player): void { + if (this.isReloading) return; + this.isReloading = true; + this.reloadTimer = GUNS[0]!.reloadTimeMs; + player.isReloading = true; + } + + private updateReload(): void { + if (!this.isReloading) return; + this.reloadTimer -= TICK_MS; + if (this.reloadTimer <= 0) { + this.isReloading = false; + this.ammo = GUNS[0]!.ammoMax; + const local = this.state.players.get(this.localPlayerId); + if (local) { + local.ammo = this.ammo; + local.isReloading = false; + } + } + } + + // ─── Game Tick ─────────────────────────────────────────────────────────────── + + private tick(): void { + this.tickCount++; + this.state.tick = this.tickCount; + const dt = TICK_MS / 1000; + + // Update bots + this.updateBots(dt); + + // Update bullets + this.updateBullets(dt); + + // Check bullet-player collisions + this.checkCollisions(); + + // Update orb lifetimes + this.updateOrbs(dt); + + // Regen HP + this.regenHp(dt); + + // Rebuild leaderboard + this.rebuildLeaderboard(); + + this.animTime += dt; + } + + private updateBots(dt: number): void { + for (const [botId, botState] of this.bots) { + const bot = this.state.players.get(botId); + if (!bot || !bot.alive) continue; + + // Change direction periodically + botState.changeDirTimer -= dt; + if (botState.changeDirTimer <= 0) { + botState.changeDirTimer = randFloat(1, 4); + botState.moveAngle = randFloat(0, Math.PI * 2); + if (Math.random() < 0.3) { + botState.moveAngle = -1; // Stop moving + } + } + + // Move + if (botState.moveAngle >= 0) { + const speed = PLAYER_BASE_SPEED * 0.1; + bot.vx += Math.cos(botState.moveAngle) * speed; + bot.vy += Math.sin(botState.moveAngle) * speed; + const spd = Math.sqrt(bot.vx * bot.vx + bot.vy * bot.vy); + if (spd > PLAYER_BASE_SPEED) { + const s = PLAYER_BASE_SPEED / spd; + bot.vx *= s; + bot.vy *= s; + } + } + + bot.vx *= PLAYER_FRICTION; + bot.vy *= PLAYER_FRICTION; + bot.x += bot.vx * dt; + bot.y += bot.vy * dt; + bot.x = clamp(bot.x, 0, ARENA_WIDTH); + bot.y = clamp(bot.y, 0, ARENA_HEIGHT); + + // Aim at local player + const local = this.state.players.get(this.localPlayerId); + if (local && local.alive) { + botState.aimAngle = Math.atan2(local.y - bot.y, local.x - bot.x); + } + bot.angle = botState.aimAngle; + + // Shoot periodically + botState.shootTimer -= dt; + if (botState.shootTimer <= 0) { + botState.shootTimer = randFloat(0.5, 3); + if (local && local.alive && Math.random() < 0.6) { + this.botShoot(botId, bot); + } + } + + // Recoil recovery + bot.recoilOffset = lerp(bot.recoilOffset, 0, 0.15); + } + } + + private botShoot(botId: string, bot: Player): void { + const gun = GUNS[bot.gunType] ?? GUNS[0]!; + const recoil = randSign() * gun.recoilOffset * 0.5; + bot.recoilOffset = recoil; + + const spawnPos = vec2.fromAngle(bot.angle + recoil, GUN_BARREL_LENGTH); + const bullet = new Bullet(); + bullet.x = bot.x + spawnPos.x; + bullet.y = bot.y + spawnPos.y; + const bVel = vec2.fromAngle(bot.angle + recoil, gun.bulletSpeed); + bullet.vx = bVel.x; + bullet.vy = bVel.y; + bullet.angle = bot.angle + recoil; + bullet.ownerId = botId; + bullet.damage = gun.damage * 0.3; // Bots do less damage + bullet.penetration = 1; + bullet.bulletType = bot.gunType; + bullet.isCritical = Math.random() < 0.05; + bullet.size = gun.bulletSize; + bullet.color = gun.bulletColor; + + const bId = `bb_${this.nextBulletId++}`; + this.state.bullets.set(bId, bullet); + this.bulletLifetimes.set(bId, performance.now()); + } + + private updateBullets(dt: number): void { + const now = performance.now(); + const toRemove: string[] = []; + + for (const [id, bullet] of this.state.bullets) { + bullet.x += bullet.vx * dt; + bullet.y += bullet.vy * dt; + + const spawnTime = this.bulletLifetimes.get(id) ?? now; + if (now - spawnTime > 3000 || bullet.x < -100 || bullet.x > ARENA_WIDTH + 100 || bullet.y < -100 || bullet.y > ARENA_HEIGHT + 100) { + toRemove.push(id); + } + } + + for (const id of toRemove) { + this.state.bullets.delete(id); + this.bulletLifetimes.delete(id); + } + } + + private checkCollisions(): void { + const toRemove: string[] = []; + + for (const [bId, bullet] of this.state.bullets) { + for (const [pId, player] of this.state.players) { + if (!player.alive) continue; + if (bullet.ownerId === pId) continue; + + const dx = bullet.x - player.x; + const dy = bullet.y - player.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < player.radius + bullet.size) { + // Hit! + player.hp -= bullet.damage; + + if (bullet.penetration <= 1) { + toRemove.push(bId); + } else { + bullet.penetration--; + } + + // Check death + if (player.hp <= 0) { + this.handleDeath(pId, bullet.ownerId); + } + break; + } + } + } + + for (const id of toRemove) { + this.state.bullets.delete(id); + this.bulletLifetimes.delete(id); + } + + // Player-orb collisions + for (const [oId, orb] of this.state.xpOrbs) { + const local = this.state.players.get(this.localPlayerId); + if (!local || !local.alive) continue; + const dx = local.x - orb.x; + const dy = local.y - orb.y; + if (dx * dx + dy * dy < 900) { + local.xp += orb.value; + this.state.xpOrbs.delete(oId); + this.checkLevelUp(local); + } + } + } + + private handleDeath(playerId: string, killerId: string): void { + const player = this.state.players.get(playerId); + if (!player) return; + + // Drop XP orbs + const dropXP = Math.floor(player.xp * 0.5); + if (dropXP > 0) { + for (let i = 0; i < 5; i++) { + const orb = new XPOrb(); + const angle = randFloat(0, Math.PI * 2); + const dist = randFloat(10, 40); + 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 = Math.floor(dropXP / 5); + orb.lifetime = 30; + this.state.xpOrbs.set(`orb_death_${randInt(0, 1000000)}`, orb); + } + } + + // Award kill + const killer = this.state.players.get(killerId); + if (killer && killer.alive) { + killer.score += 1; + killer.xp += Math.floor(player.xp * 0.5); + this.checkLevelUp(killer); + } + + if (playerId === this.localPlayerId) { + // Local player respawns after 2s + player.hp = 0; + player.alive = false; + setTimeout(() => { + player.x = ARENA_WIDTH / 2 + randFloat(-200, 200); + player.y = ARENA_HEIGHT / 2 + randFloat(-200, 200); + player.hp = player.maxHp; + player.alive = true; + player.xp = 0; + player.level = 1; + player.xpToNext = XP_LEVELS[1] ?? 50; + player.score = 0; + this.ammo = GUNS[0]!.ammoMax; + player.ammo = this.ammo; + this.localVx = 0; + this.localVy = 0; + player.vx = 0; + player.vy = 0; + }, 2000); + } else { + // Bot respawns immediately elsewhere + player.x = randFloat(100, ARENA_WIDTH - 100); + player.y = randFloat(100, ARENA_HEIGHT - 100); + player.hp = player.maxHp; + player.alive = true; + player.xp = 0; + player.level = 1; + player.xpToNext = XP_LEVELS[1] ?? 50; + } + } + + private checkLevelUp(player: Player): void { + while (player.level < 45 && player.xp >= (XP_LEVELS[player.level] ?? Infinity)) { + player.level += 1; + player.upgradePoints += 1; + player.xpToNext = player.level < 45 ? (XP_LEVELS[player.level] ?? 999999) : 999999; + // Heal on level up + player.hp = player.maxHp; + } + } + + private updateOrbs(dt: number): void { + const toRemove: string[] = []; + for (const [id, orb] of this.state.xpOrbs) { + orb.lifetime -= dt; + if (orb.lifetime <= 0) { + toRemove.push(id); + } + } + for (const id of toRemove) { + this.state.xpOrbs.delete(id); + } + // Spawn new orbs occasionally + if (this.state.xpOrbs.size < 15 && Math.random() < 0.02) { + this.spawnRandomOrb(); + } + } + + private regenHp(dt: number): void { + for (const player of this.state.players.values()) { + if (!player.alive) continue; + if (player.hp < player.maxHp) { + player.hp = Math.min(player.maxHp, player.hp + 1 * dt); + } + } + } + + private rebuildLeaderboard(): void { + const entries: { name: string; level: number; score: number; xp: number }[] = []; + for (const p of this.state.players.values()) { + entries.push({ name: p.name, level: p.level, score: p.score, xp: p.xp }); + } + entries.sort((a, b) => b.xp - a.xp); + this.state.leaderboard.clear(); + for (let i = 0; i < Math.min(10, entries.length); i++) { + const e = entries[i]!; + const entry = new LeaderboardEntry(); + entry.name = e.name; + entry.level = e.level; + entry.score = e.score; + entry.xp = e.xp; + this.state.leaderboard.push(entry); + } + } + + applyUpgrade(choice: number): void { + const local = this.state.players.get(this.localPlayerId); + if (!local || local.upgradePoints <= 0) return; + if (choice < 0 || choice >= 9) return; + if ((local.stats[choice] ?? 0) >= 7) return; + local.stats[choice] = (local.stats[choice] ?? 0) + 1; + local.upgradePoints -= 1; + if (choice === 0) { // MaxHP + local.maxHp += 20; + local.hp += 20; + } + } + + onUpgrade(callback: (choice: number) => void): void { + this.upgradeCallback = callback; + } +} diff --git a/packages/client/src/renderer.ts b/packages/client/src/renderer.ts new file mode 100644 index 0000000..0ae1f5b --- /dev/null +++ b/packages/client/src/renderer.ts @@ -0,0 +1,397 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Canvas 2D Rendering Engine +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + COLORS, + ARENA_WIDTH, + ARENA_HEIGHT, + GUN_BARREL_WIDTH, + GUN_BARREL_LENGTH, + GUN_BODY_SIZE, + lerp, +} from '@guncircle/shared'; +import type { RoomState, Player, Bullet, XPOrb, Obstacle } from '@guncircle/shared'; +import type { Camera } from './camera.js'; + +// ─── Grid Constants ────────────────────────────────────────────────────────── + +const GRID_SIZE = 100; +const XP_ORB_SIZE = 6; +const XP_ORB_PULSE_SPEED = 3; +const XP_ORB_PULSE_AMP = 1.5; +const TRAIL_COUNT = 3; +const NAME_TAG_OFFSET = 28; +const HP_BAR_HEIGHT = 4; +const HP_BAR_OFFSET = NAME_TAG_OFFSET + 14; +const STAT_TEXT_SIZE = 12; + +/** Bullet trail history entry */ +interface BulletTrail { + positions: Array<{ x: number; y: number }>; +} + +export class Renderer { + private readonly ctx: CanvasRenderingContext2D; + private readonly canvas: HTMLCanvasElement; + /** Bullet trail history: bulletId → ring buffer of positions */ + private bulletTrails = new Map(); + private readonly maxTrailLen = 5; + /** Time accumulator for animations */ + private animTime = 0; + + constructor(ctx: CanvasRenderingContext2D, canvas: HTMLCanvasElement) { + this.ctx = ctx; + this.canvas = canvas; + } + + // ─── Main Render ─────────────────────────────────────────────────────────── + + /** + * Main render entry point. + * @param state - Current Colyseus room state + * @param camera - Camera system + * @param localPlayerId - Session ID of the local player + * @param dt - Delta time in seconds + */ + render( + state: RoomState, + camera: Camera, + localPlayerId: string | null, + dt: number + ): void { + this.animTime += dt; + + const ctx = this.ctx; + const w = this.canvas.width; + const h = this.canvas.height; + + // Clear canvas + ctx.clearRect(0, 0, w, h); + + // Apply camera transform + ctx.save(); + ctx.translate(w / 2, h / 2); + ctx.translate(-camera.x, -camera.y); + + // Render world + this.renderArenaBackground(camera); + this.renderObstacles(state, camera); + this.renderXPOrbs(state, camera); + this.renderBullets(state, camera, localPlayerId, dt); + this.renderPlayers(state, camera, localPlayerId, dt); + + ctx.restore(); + } + + // ─── Arena Background ────────────────────────────────────────────────────── + + private renderArenaBackground(camera: Camera): void { + const ctx = this.ctx; + + // Solid background fill for visible area + const startX = Math.max(0, camera.x - camera.width / 2); + const startY = Math.max(0, camera.y - camera.height / 2); + const endX = Math.min(ARENA_WIDTH, camera.x + camera.width / 2); + const endY = Math.min(ARENA_HEIGHT, camera.y + camera.height / 2); + + ctx.fillStyle = COLORS.arenaBg; + ctx.fillRect(startX, startY, endX - startX, endY - startY); + + // Grid lines + ctx.strokeStyle = COLORS.arenaGrid; + ctx.lineWidth = 1; + ctx.beginPath(); + + // Vertical lines + const firstGridX = Math.floor(startX / GRID_SIZE) * GRID_SIZE; + for (let x = firstGridX; x <= endX; x += GRID_SIZE) { + ctx.moveTo(x, startY); + ctx.lineTo(x, endY); + } + + // Horizontal lines + const firstGridY = Math.floor(startY / GRID_SIZE) * GRID_SIZE; + for (let y = firstGridY; y <= endY; y += GRID_SIZE) { + ctx.moveTo(startX, y); + ctx.lineTo(endX, y); + } + + ctx.stroke(); + + // Arena border + ctx.strokeStyle = '#34495e'; + ctx.lineWidth = 3; + ctx.strokeRect(0, 0, ARENA_WIDTH, ARENA_HEIGHT); + } + + // ─── Obstacles ───────────────────────────────────────────────────────────── + + private renderObstacles(state: RoomState, camera: Camera): void { + for (const obs of state.obstacles.values()) { + if (!camera.isInView(obs.x + obs.width / 2, obs.y + obs.height / 2, Math.max(obs.width, obs.height))) { + continue; + } + this.renderObstacle(obs); + } + } + + private renderObstacle(obs: Obstacle): void { + const ctx = this.ctx; + + switch (obs.type) { + case 0: { // Wall + ctx.fillStyle = COLORS.wall; + ctx.fillRect(obs.x, obs.y, obs.width, obs.height); + ctx.strokeStyle = '#6c7a7d'; + ctx.lineWidth = 1; + ctx.strokeRect(obs.x, obs.y, obs.width, obs.height); + break; + } + case 1: { // Crate + ctx.fillStyle = COLORS.crate; + ctx.fillRect(obs.x, obs.y, obs.width, obs.height); + ctx.strokeStyle = '#6d3a10'; + ctx.lineWidth = 1; + ctx.strokeRect(obs.x, obs.y, obs.width, obs.height); + // Cross pattern + ctx.strokeStyle = '#6d3a10'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(obs.x, obs.y); + ctx.lineTo(obs.x + obs.width, obs.y + obs.height); + ctx.moveTo(obs.x + obs.width, obs.y); + ctx.lineTo(obs.x, obs.y + obs.height); + ctx.stroke(); + break; + } + case 2: { // Slow zone + ctx.fillStyle = 'rgba(52, 152, 219, 0.3)'; + ctx.fillRect(obs.x, obs.y, obs.width, obs.height); + // Subtle wave pattern + ctx.strokeStyle = 'rgba(52, 152, 219, 0.2)'; + ctx.lineWidth = 1; + for (let i = 0; i < 3; i++) { + const y = obs.y + obs.height * (0.25 + i * 0.25); + ctx.beginPath(); + ctx.moveTo(obs.x, y); + for (let x = 0; x < obs.width; x += 5) { + ctx.lineTo( + obs.x + x, + y + Math.sin(x * 0.05 + this.animTime * 2) * 3 + ); + } + ctx.stroke(); + } + break; + } + case 3: { // Cover + ctx.fillStyle = 'rgba(39, 174, 96, 0.5)'; + ctx.fillRect(obs.x, obs.y, obs.width, obs.height); + ctx.strokeStyle = 'rgba(39, 174, 96, 0.7)'; + ctx.lineWidth = 2; + ctx.strokeRect(obs.x, obs.y, obs.width, obs.height); + break; + } + } + } + + // ─── XP Orbs ─────────────────────────────────────────────────────────────── + + private renderXPOrbs(state: RoomState, camera: Camera): void { + for (const orb of state.xpOrbs.values()) { + if (!camera.isInView(orb.x, orb.y, XP_ORB_SIZE * 2)) { + continue; + } + this.renderXPOrb(orb); + } + } + + private renderXPOrb(orb: XPOrb): void { + const ctx = this.ctx; + const pulse = Math.sin(this.animTime * XP_ORB_PULSE_SPEED + orb.x * 0.1) * XP_ORB_PULSE_AMP; + const size = XP_ORB_SIZE + pulse; + + ctx.fillStyle = COLORS.xpOrb; + ctx.shadowColor = COLORS.xpOrb; + ctx.shadowBlur = 6; + ctx.fillRect(orb.x - size / 2, orb.y - size / 2, size, size); + ctx.shadowBlur = 0; + } + + // ─── Bullets ─────────────────────────────────────────────────────────────── + + private renderBullets(state: RoomState, camera: Camera, localPlayerId: string | null, dt: number): void { + // Update trail history + for (const [id, bullet] of state.bullets) { + let trail = this.bulletTrails.get(id); + if (!trail) { + trail = { positions: [] }; + this.bulletTrails.set(id, trail); + } + // Add current position to trail + trail.positions.unshift({ x: bullet.x, y: bullet.y }); + if (trail.positions.length > this.maxTrailLen) { + trail.positions.pop(); + } + } + + // Clean up trails for removed bullets + const activeIds = new Set(state.bullets.keys()); + for (const id of this.bulletTrails.keys()) { + if (!activeIds.has(id)) { + this.bulletTrails.delete(id); + } + } + + // Render bullets + for (const [bulletId, bullet] of state.bullets) { + if (!camera.isInView(bullet.x, bullet.y, bullet.size * 3)) { + continue; + } + const isOwn = bullet.ownerId === localPlayerId; + this.renderBullet(bullet, bulletId, isOwn); + } + } + + private renderBullet(bullet: Bullet, bulletId: string, isOwn: boolean): void { + const ctx = this.ctx; + const trail = this.bulletTrails.get(bulletId); + const bulletColor = isOwn ? COLORS.self : COLORS.enemy; + + // Render trail + if (trail && trail.positions.length > 1) { + for (let i = 1; i < Math.min(trail.positions.length, TRAIL_COUNT + 1); i++) { + const pos = trail.positions[i]; + const t = i / (TRAIL_COUNT + 1); + const alpha = 0.3 * (1 - t); + const r = bullet.size * (1 - t * 0.5); + + ctx.globalAlpha = alpha; + ctx.fillStyle = bulletColor; + ctx.beginPath(); + ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2); + ctx.fill(); + } + ctx.globalAlpha = 1; + } + + // Main bullet circle + ctx.fillStyle = bulletColor; + ctx.shadowColor = bulletColor; + ctx.shadowBlur = 4; + ctx.beginPath(); + ctx.arc(bullet.x, bullet.y, bullet.size, 0, Math.PI * 2); + ctx.fill(); + ctx.shadowBlur = 0; + + // Crit outline + if (bullet.isCritical) { + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(bullet.x, bullet.y, bullet.size + 2, 0, Math.PI * 2); + ctx.stroke(); + } + } + + // ─── Players ───────────────────────────────────────────────────────────────── + + private renderPlayers( + state: RoomState, + camera: Camera, + localPlayerId: string | null, + _dt: number + ): void { + for (const [playerId, player] of state.players) { + if (!player.alive) continue; + if (!camera.isInView(player.x, player.y, player.radius + 30)) { + continue; + } + const isSelf = playerId === localPlayerId; + this.renderPlayer(player, isSelf); + } + } + + private renderPlayer(player: Player, isSelf: boolean): void { + const ctx = this.ctx; + const x = player.x; + const y = player.y; + const r = player.radius; + + // ─── Gun rendering (behind player circle) ─── + ctx.save(); + ctx.translate(x, y); + ctx.rotate(player.angle + player.recoilOffset); + + // Barrel + ctx.fillStyle = '#34495e'; + ctx.fillRect( + 0, + -GUN_BARREL_WIDTH / 2, + GUN_BARREL_LENGTH, + GUN_BARREL_WIDTH + ); + + // Gun body (square behind barrel) + ctx.fillStyle = '#2c3e50'; + ctx.fillRect( + -GUN_BODY_SIZE / 2, + -GUN_BODY_SIZE / 2, + GUN_BODY_SIZE, + GUN_BODY_SIZE + ); + + ctx.restore(); + + // ─── Player circle ─── + ctx.fillStyle = isSelf ? COLORS.self : COLORS.enemy; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + + // Darker border stroke + ctx.strokeStyle = isSelf ? '#2980b9' : '#c0392b'; + ctx.lineWidth = 2; + ctx.stroke(); + + // ─── Name tag ─── + if (player.name) { + ctx.fillStyle = COLORS.nameTag; + ctx.font = `${STAT_TEXT_SIZE}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + ctx.shadowColor = 'rgba(0,0,0,0.8)'; + ctx.shadowBlur = 3; + ctx.fillText(player.name, x, y - NAME_TAG_OFFSET); + ctx.shadowBlur = 0; + } + + // ─── HP bar ─── + const hpBarWidth = r * 2; + const hpBarX = x - hpBarWidth / 2; + const hpBarY = y - HP_BAR_OFFSET; + const hpPct = player.hp / player.maxHp; + + // Background + ctx.fillStyle = COLORS.hpBarBg; + ctx.fillRect(hpBarX, hpBarY, hpBarWidth, HP_BAR_HEIGHT); + + // Fill + const hpFillWidth = hpBarWidth * hpPct; + ctx.fillStyle = COLORS.hpBar; + ctx.fillRect(hpBarX, hpBarY, hpFillWidth, HP_BAR_HEIGHT); + + // Border + ctx.strokeStyle = '#1a252f'; + ctx.lineWidth = 1; + ctx.strokeRect(hpBarX, hpBarY, hpBarWidth, HP_BAR_HEIGHT); + } + + // ─── Resize ──────────────────────────────────────────────────────────────── + + resize(width: number, height: number): void { + this.canvas.width = width; + this.canvas.height = height; + } +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..d484dcc --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "./dist", + "rootDir": "../..", + "jsx": "preserve", + "skipLibCheck": true, + "noEmit": true + }, + "include": ["./src", "../shared/src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts new file mode 100644 index 0000000..4a2cd69 --- /dev/null +++ b/packages/client/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite'; +import path from 'path'; + +export default defineConfig({ + root: '.', + server: { + port: 5173, + proxy: { + '/ws': { + target: 'ws://localhost:3000', + ws: true, + }, + }, + }, + build: { + outDir: './dist', + emptyOutDir: true, + target: 'es2020', + }, + esbuild: { + target: 'es2020', + }, + resolve: { + alias: { + '@guncircle/shared': path.resolve(__dirname, '../shared/src'), + }, + }, +}); diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile new file mode 100644 index 0000000..4ad1a89 --- /dev/null +++ b/packages/server/Dockerfile @@ -0,0 +1,24 @@ +FROM node:22-alpine AS builder +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate +COPY pnpm-workspace.yaml package.json ./ +COPY packages/shared/package.json packages/shared/ +COPY packages/server/package.json packages/server/ +RUN pnpm install --frozen-lockfile +COPY packages/shared/ packages/shared/ +COPY packages/server/ packages/server/ +RUN pnpm --filter @guncircle/shared build +RUN pnpm --filter @guncircle/server build + +FROM node:22-alpine +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate +COPY pnpm-workspace.yaml package.json ./ +COPY --from=builder /app/packages/shared/dist packages/shared/dist +COPY --from=builder /app/packages/shared/package.json packages/shared/ +COPY --from=builder /app/packages/server/dist packages/server/dist +COPY --from=builder /app/packages/server/package.json packages/server/ +COPY --from=builder /app/packages/server/config packages/server/config +RUN pnpm install --prod --frozen-lockfile +EXPOSE 3000 +CMD ["node", "packages/server/dist/index.js"] diff --git a/packages/server/config/branches.json b/packages/server/config/branches.json new file mode 100644 index 0000000..ebad73e --- /dev/null +++ b/packages/server/config/branches.json @@ -0,0 +1,175 @@ +{ + "branches": [ + { + "id": "fighter", + "name": "Fighter", + "description": "Balanced combatant. +10% Max HP, +5% Bullet Damage.", + "levelRequired": 5, + "parentId": null, + "unlocksCategories": [0, 1], + "passiveBonuses": { "0": 0.10, "3": 0.05 } + }, + { + "id": "shotgunner", + "name": "Shotgunner", + "description": "Close-range powerhouse. Unlocks shotguns. +15% Max HP, -10% Recoil.", + "levelRequired": 5, + "parentId": null, + "unlocksCategories": [2], + "passiveBonuses": { "0": 0.15, "6": 0.10 } + }, + { + "id": "scout", + "name": "Scout", + "description": "Fast and evasive. +15% Movement Speed, +5% Reload Speed.", + "levelRequired": 5, + "parentId": null, + "unlocksCategories": [4], + "passiveBonuses": { "2": 0.15, "5": 0.05 } + }, + { + "id": "soldier", + "name": "Soldier", + "description": "Veteran warrior. +15% Bullet Damage, +10% Bullet Speed.", + "levelRequired": 10, + "parentId": "fighter", + "unlocksCategories": [1], + "passiveBonuses": { "3": 0.15, "4": 0.10 } + }, + { + "id": "bruiser", + "name": "Bruiser", + "description": "Tough tank. +20% Max HP, +10% HP Regen.", + "levelRequired": 10, + "parentId": "fighter", + "unlocksCategories": [], + "passiveBonuses": { "0": 0.20, "1": 0.10 } + }, + { + "id": "blaster", + "name": "Blaster", + "description": "Explosive force. +20% Bullet Damage, +10% Crit Chance.", + "levelRequired": 15, + "parentId": "soldier", + "unlocksCategories": [], + "passiveBonuses": { "3": 0.20, "7": 0.10 } + }, + { + "id": "slayer", + "name": "Slayer", + "description": "Ultimate hunter. +25% Bullet Damage, +15% Crit Damage.", + "levelRequired": 30, + "parentId": "blaster", + "unlocksCategories": [], + "passiveBonuses": { "3": 0.25, "8": 0.15 } + }, + { + "id": "destroyer", + "name": "Destroyer", + "description": "Peak power. +20% all damage stats, +10% Max HP.", + "levelRequired": 45, + "parentId": "slayer", + "unlocksCategories": [], + "passiveBonuses": { "0": 0.10, "3": 0.20, "7": 0.10, "8": 0.10 } + }, + { + "id": "charger", + "name": "Charger", + "description": "Bull rush expert. +15% Movement Speed, +10% Bullet Damage.", + "levelRequired": 15, + "parentId": "bruiser", + "unlocksCategories": [], + "passiveBonuses": { "2": 0.15, "3": 0.10 } + }, + { + "id": "juggernaut", + "name": "Juggernaut", + "description": "Unstoppable. +25% Max HP, +15% HP Regen.", + "levelRequired": 30, + "parentId": "charger", + "unlocksCategories": [], + "passiveBonuses": { "0": 0.25, "1": 0.15 } + }, + { + "id": "colossus", + "name": "Colossus", + "description": "Living fortress. +30% Max HP, +20% HP Regen, +10% Damage.", + "levelRequired": 45, + "parentId": "juggernaut", + "unlocksCategories": [], + "passiveBonuses": { "0": 0.30, "1": 0.20, "3": 0.10 } + }, + { + "id": "scatter", + "name": "Scatter", + "description": "Spread shot master. +15% Reload Speed, -15% Recoil.", + "levelRequired": 10, + "parentId": "shotgunner", + "unlocksCategories": [2], + "passiveBonuses": { "5": 0.15, "6": 0.15 } + }, + { + "id": "breaker", + "name": "Breaker", + "description": "Armor breaker. +20% Bullet Damage, +10% Bullet Speed.", + "levelRequired": 15, + "parentId": "scatter", + "unlocksCategories": [], + "passiveBonuses": { "3": 0.20, "4": 0.10 } + }, + { + "id": "havoc", + "name": "Havoc", + "description": "Chaos incarnate. +25% Reload Speed, +15% Movement Speed.", + "levelRequired": 30, + "parentId": "breaker", + "unlocksCategories": [], + "passiveBonuses": { "2": 0.15, "5": 0.25 } + }, + { + "id": "annihilator", + "name": "Annihilator", + "description": "Total devastation. +20% all stats.", + "levelRequired": 45, + "parentId": "havoc", + "unlocksCategories": [], + "passiveBonuses": { "0": 0.10, "2": 0.10, "3": 0.15, "5": 0.15 } + }, + { + "id": "runner", + "name": "Runner", + "description": "Speed demon. +20% Movement Speed, +10% Reload Speed.", + "levelRequired": 10, + "parentId": "scout", + "unlocksCategories": [4], + "passiveBonuses": { "2": 0.20, "5": 0.10 } + }, + { + "id": "stalker", + "name": "Stalker", + "description": "Stealth marksman. +15% Bullet Speed, +10% Crit Chance.", + "levelRequired": 15, + "parentId": "runner", + "unlocksCategories": [3], + "passiveBonuses": { "4": 0.15, "7": 0.10 } + }, + { + "id": "phantom", + "name": "Phantom", + "description": "Ghost warrior. +20% Movement Speed, +15% Crit Damage.", + "levelRequired": 30, + "parentId": "stalker", + "unlocksCategories": [], + "passiveBonuses": { "2": 0.20, "8": 0.15 } + }, + { + "id": "assassin", + "name": "Assassin", + "description": "Silent death. +25% Crit Chance, +25% Crit Damage.", + "levelRequired": 45, + "parentId": "phantom", + "unlocksCategories": [], + "passiveBonuses": { "7": 0.25, "8": 0.25 } + } + ] +} diff --git a/packages/server/config/guns.json b/packages/server/config/guns.json new file mode 100644 index 0000000..60ba3e1 --- /dev/null +++ b/packages/server/config/guns.json @@ -0,0 +1,99 @@ +{ + "guns": [ + { + "id": "pistol", + "name": "Pistol", + "category": 0, + "damage": 12, + "fireRate": 4, + "bulletSpeed": 400, + "pelletCount": 1, + "spread": 0, + "recoilOffset": 0.06, + "recoilRecoveryMs": 120, + "kickbackForce": 30, + "ammoMax": 12, + "reloadTimeMs": 1500, + "bulletSize": 4, + "penetration": 1, + "bulletLifetimeMs": 2000, + "bulletColor": "#f1c40f" + }, + { + "id": "rifle", + "name": "Rifle", + "category": 1, + "damage": 18, + "fireRate": 3, + "bulletSpeed": 500, + "pelletCount": 1, + "spread": 0.02, + "recoilOffset": 0.08, + "recoilRecoveryMs": 180, + "kickbackForce": 45, + "ammoMax": 8, + "reloadTimeMs": 2000, + "bulletSize": 4.5, + "penetration": 2, + "bulletLifetimeMs": 2500, + "bulletColor": "#e67e22" + }, + { + "id": "shotgun", + "name": "Shotgun", + "category": 2, + "damage": 8, + "fireRate": 1.2, + "bulletSpeed": 320, + "pelletCount": 5, + "spread": 0.25, + "recoilOffset": 0.15, + "recoilRecoveryMs": 300, + "kickbackForce": 80, + "ammoMax": 5, + "reloadTimeMs": 2500, + "bulletSize": 3, + "penetration": 1, + "bulletLifetimeMs": 1500, + "bulletColor": "#9b59b6" + }, + { + "id": "sniper", + "name": "Sniper", + "category": 3, + "damage": 45, + "fireRate": 0.8, + "bulletSpeed": 700, + "pelletCount": 1, + "spread": 0, + "recoilOffset": 0.2, + "recoilRecoveryMs": 500, + "kickbackForce": 120, + "ammoMax": 3, + "reloadTimeMs": 3000, + "bulletSize": 6, + "penetration": 3, + "bulletLifetimeMs": 3500, + "bulletColor": "#e74c3c" + }, + { + "id": "smg", + "name": "SMG", + "category": 4, + "damage": 6, + "fireRate": 10, + "bulletSpeed": 380, + "pelletCount": 1, + "spread": 0.06, + "recoilOffset": 0.04, + "recoilRecoveryMs": 80, + "kickbackForce": 20, + "ammoMax": 30, + "reloadTimeMs": 1800, + "bulletSize": 3, + "penetration": 1, + "bulletLifetimeMs": 1800, + "bulletColor": "#2ecc71" + } + ] +} diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..9f9c53a --- /dev/null +++ b/packages/server/package.json @@ -0,0 +1,25 @@ +{ + "name": "@guncircle/server", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsx watch src/index.ts", + "start": "node dist/index.js", + "type-check": "tsc --noEmit", + "test": "vitest run" + }, + "dependencies": { + "@colyseus/core": "^0.16.0", + "@colyseus/schema": "^3.0.0", + "@colyseus/uwebsockets-transport": "^0.16.0", + "@guncircle/shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.0.0", + "typescript": "^5.6.0", + "vitest": "^2.0.0" + } +} diff --git a/packages/server/src/branch-system.ts b/packages/server/src/branch-system.ts new file mode 100644 index 0000000..25d6330 --- /dev/null +++ b/packages/server/src/branch-system.ts @@ -0,0 +1,241 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Branch (Class) System +// ═══════════════════════════════════════════════════════════════════════════════ + +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { + type ClassBranch, + type Player, + STAT_COUNT, + Stat, + STAT_BONUSES, + STAT_MAX_LEVEL, +} from '@guncircle/shared'; + +// ─── Branch Config Container ───────────────────────────────────────────────── + +interface BranchesJson { + branches: ClassBranch[]; +} + +// ─── BranchSystem ──────────────────────────────────────────────────────────── + +export class BranchSystem { + private branches: ClassBranch[] = []; + + constructor() { + this.loadConfigs(); + } + + private loadConfigs(): void { + try { + const __dirname = dirname(fileURLToPath(import.meta.url)); + const configPath = join(__dirname, '../config/branches.json'); + const raw = readFileSync(configPath, 'utf-8'); + const parsed: BranchesJson = JSON.parse(raw) as BranchesJson; + this.branches = parsed.branches ?? []; + } catch { + this.branches = []; + } + } + + /** Total branches loaded */ + getBranchCount(): number { + return this.branches.length; + } + + /** Get branch by string id */ + getBranchById(id: string): ClassBranch | undefined { + return this.branches.find((b) => b.id === id); + } + + /** Get branch by 0-based index (used for classType field) */ + getBranchByIndex(index: number): ClassBranch | undefined { + if (index < 0 || index >= this.branches.length) return undefined; + return this.branches[index]; + } + + /** Get the index of a branch by its string id */ + getBranchIndexById(id: string): number { + return this.branches.findIndex((b) => b.id === id); + } + + /** Get all loaded branches */ + getAllBranches(): readonly ClassBranch[] { + return this.branches; + } + + /** + * Get branches available to a player at their current level. + * Filters by: levelRequired <= player level, parent matches current branch path. + */ + getAvailableBranches(level: number, currentBranchId: string | null): ClassBranch[] { + return this.branches.filter((branch) => { + // Must meet level requirement + if (branch.levelRequired > level) return false; + + // Root branches (parentId === null) are always available at their level + if (branch.parentId === null) { + // Only show root branches if player hasn't chosen a branch yet + // or if they're at the exact milestone level + return currentBranchId === null || this.isDirectRootChoice(branch, currentBranchId); + } + + // Non-root branches require parent to be in the player's branch path + return this.isInBranchPath(branch.parentId, currentBranchId); + }); + } + + /** + * Check if a branch is a direct root choice for a player. + * A root branch is a direct choice if the player has no branch yet. + */ + private isDirectRootChoice(rootBranch: ClassBranch, currentBranchId: string | null): boolean { + if (currentBranchId === null) return true; + // If player has a branch, root branches are not direct choices anymore + return false; + } + + /** + * Check if `ancestorId` is in the branch path leading to `descendantId`. + * This includes the descendant itself. + */ + isInBranchPath(ancestorId: string, descendantId: string | null): boolean { + if (descendantId === null) return false; + if (ancestorId === descendantId) return true; + + let current: ClassBranch | undefined = this.getBranchById(descendantId); + while (current !== undefined && current.parentId !== null) { + if (current.parentId === ancestorId) return true; + current = this.getBranchById(current.parentId); + } + return false; + } + + /** + * Get the root branch id in a player's branch path. + * Walks up the parent chain to find the top-level ancestor. + */ + getRootBranchId(branchId: string | null): string | null { + if (branchId === null) return null; + let current: ClassBranch | undefined = this.getBranchById(branchId); + while (current !== undefined && current.parentId !== null) { + current = this.getBranchById(current.parentId); + } + return current?.id ?? null; + } + + /** + * Get all branch IDs in the path from root to the given branch. + */ + getBranchPath(branchId: string | null): string[] { + const path: string[] = []; + if (branchId === null) return path; + + // Walk up to root first + const ancestors: string[] = []; + let current: ClassBranch | undefined = this.getBranchById(branchId); + while (current !== undefined) { + ancestors.unshift(current.id); + if (current.parentId === null) break; + current = this.getBranchById(current.parentId); + } + + return ancestors; + } + + /** + * Apply passive stat bonuses from a branch to a player's stat levels. + * This returns the total passive bonus for each stat from the branch tree. + */ + calculateBranchBonuses(player: Player): Record { + const bonuses: Record = { + [Stat.MaxHp]: 0, + [Stat.HpRegen]: 0, + [Stat.MovementSpeed]: 0, + [Stat.BulletDamage]: 0, + [Stat.BulletSpeed]: 0, + [Stat.ReloadSpeed]: 0, + [Stat.RecoilStability]: 0, + [Stat.CritChance]: 0, + [Stat.CritDamage]: 0, + }; + + // Get the branch path and sum all passive bonuses + const branchId: string | null = + player.classType > 0 ? this.getBranchByIndex(player.classType)?.id ?? null : null; + + if (branchId === null) return bonuses; + + const path: string[] = this.getBranchPath(branchId); + for (const bid of path) { + const branch: ClassBranch | undefined = this.getBranchById(bid); + if (branch === undefined) continue; + + for (const [statKeyStr, value] of Object.entries(branch.passiveBonuses)) { + const statIdx: number = parseInt(statKeyStr, 10); + if (statIdx >= 0 && statIdx < STAT_COUNT && value !== undefined) { + bonuses[statIdx as Stat] += value; + } + } + } + + return bonuses; + } + + /** + * Get the effective stat value for a player, including branch bonuses. + * Returns the player's invested stat level + branch passive bonus. + */ + getEffectiveStatLevel(player: Player, stat: Stat): number { + const investedLevel: number = player.stats[stat] ?? 0; + const bonuses: Record = this.calculateBranchBonuses(player); + return investedLevel + bonuses[stat]; + } + + /** + * Apply branch selection to a player. Sets their classType index. + */ + applyBranch(player: Player, branchId: string): boolean { + const branchIdx: number = this.getBranchIndexById(branchId); + if (branchIdx < 0) return false; + player.classType = branchIdx; + return true; + } + + /** + * Get all gun category indices unlocked by a player's branch path. + */ + getUnlockedGunCategories(player: Player): number[] { + const branchId: string | null = + player.classType > 0 ? this.getBranchByIndex(player.classType)?.id ?? null : null; + + if (branchId === null) return [0]; // Default: only pistols + + const path: string[] = this.getBranchPath(branchId); + const categories: Set = new Set(); + + // Always include pistol + categories.add(0); + + for (const bid of path) { + const branch: ClassBranch | undefined = this.getBranchById(bid); + if (branch === undefined) continue; + for (const cat of branch.unlocksCategories) { + categories.add(cat); + } + } + + return Array.from(categories).sort((a, b) => a - b); + } + + /** + * Check if a gun category is unlocked for a player. + */ + isGunCategoryUnlocked(player: Player, category: number): boolean { + const unlocked: number[] = this.getUnlockedGunCategories(player); + return unlocked.includes(category); + } +} diff --git a/packages/server/src/collision.ts b/packages/server/src/collision.ts new file mode 100644 index 0000000..a0b80d8 --- /dev/null +++ b/packages/server/src/collision.ts @@ -0,0 +1,306 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Collision System +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + type Player, + type Bullet, + type XPOrb, + type Obstacle, + type RoomState, + SpatialHashGrid, + circleCollisionResolve, + vec2, + type Vec2, + SPATIAL_CELL_SIZE, + ARENA_WIDTH, + ARENA_HEIGHT, + PLAYER_BASE_RADIUS, +} from '@guncircle/shared'; + +// ─── Identifiable Entity for Spatial Hash ──────────────────────────────────── + +interface SpatialEntity { + id: number; + x: number; + y: number; +} + +// ─── CollisionCallbacks ────────────────────────────────────────────────────── + +export interface BulletHitCallback { + (bullet: Bullet, bulletId: string, victim: Player, victimId: string): void; +} + +export interface OrbCollectCallback { + (player: Player, playerId: string, orb: XPOrb, orbId: string): void; +} + +export interface ObstacleCollideCallback { + (player: Player, playerId: string, obstacle: Obstacle): void; +} + +// ─── CollisionSystem ───────────────────────────────────────────────────────── + +export class CollisionSystem { + private bulletGrid: SpatialHashGrid; + private playerGrid: SpatialHashGrid; + + constructor() { + this.bulletGrid = new SpatialHashGrid(SPATIAL_CELL_SIZE); + this.playerGrid = new SpatialHashGrid(SPATIAL_CELL_SIZE); + } + + // ─── Grid Building ───────────────────────────────────────────────────────── + + /** Rebuild the spatial hash grid for bullets. */ + buildBulletGrid(bullets: Map): void { + this.bulletGrid.clear(); + let idx = 0; + for (const [id, bullet] of bullets.entries()) { + const entity: SpatialEntity = { + id: idx++, + x: bullet.x, + y: bullet.y, + }; + this.bulletGrid.insert(entity); + } + } + + /** Rebuild the spatial hash grid for players. */ + buildPlayerGrid(players: Map): void { + this.playerGrid.clear(); + let idx = 0; + for (const [id, player] of players.entries()) { + if (!player.alive) continue; + const entity: SpatialEntity = { + id: idx++, + x: player.x, + y: player.y, + }; + this.playerGrid.insert(entity); + } + } + + // ─── Bullet vs Player ────────────────────────────────────────────────────── + + /** + * Check all bullet-player collisions. For each bullet, query nearby players. + * Excludes the bullet's owner. Calls onHit for each valid collision. + */ + checkBulletPlayerCollisions( + bullets: Map, + players: Map, + onHit: BulletHitCallback + ): void { + for (const [bulletId, bullet] of bullets.entries()) { + // Skip bullets with no penetration left + if (bullet.penetration <= 0) continue; + + for (const [playerId, player] of players.entries()) { + // Don't hit the owner + if (bullet.ownerId === playerId) continue; + if (!player.alive) continue; + + // Quick distance check + const dx: number = bullet.x - player.x; + const dy: number = bullet.y - player.y; + const r: number = bullet.size + player.radius; + const distSq: number = dx * dx + dy * dy; + + if (distSq < r * r) { + onHit(bullet, bulletId, player, playerId); + if (bullet.penetration <= 0) break; + } + } + } + } + + // ─── Player vs XP Orb ────────────────────────────────────────────────────── + + /** + * Check all player-orb collisions. Pickup radius is ~30 units. + */ + checkPlayerOrbCollisions( + players: Map, + orbs: Map, + onCollect: OrbCollectCallback + ): void { + const PICKUP_RADIUS: number = 30; + const PICKUP_RADIUS_SQ: number = PICKUP_RADIUS * PICKUP_RADIUS; + + for (const [playerId, player] of players.entries()) { + if (!player.alive) continue; + + for (const [orbId, orb] of orbs.entries()) { + const dx: number = player.x - orb.x; + const dy: number = player.y - orb.y; + const distSq: number = dx * dx + dy * dy; + + if (distSq < PICKUP_RADIUS_SQ) { + onCollect(player, playerId, orb, orbId); + } + } + } + } + + // ─── Player vs Obstacle ──────────────────────────────────────────────────── + + /** + * Check all player-obstacle collisions. Resolve overlap by pushing player out. + * Obstacles are treated as AABB (axis-aligned bounding boxes) with circle overlap. + */ + checkPlayerObstacleCollisions( + players: Map, + obstacles: Map, + onCollide?: ObstacleCollideCallback + ): void { + for (const [playerId, player] of players.entries()) { + if (!player.alive) continue; + + for (const [obsId, obstacle] of obstacles.entries()) { + this.resolvePlayerObstacle(player, obstacle, onCollide, playerId); + } + } + } + + /** + * Resolve a single player-obstacle collision. Obstacle is AABB. + */ + private resolvePlayerObstacle( + player: Player, + obstacle: Obstacle, + onCollide: ObstacleCollideCallback | undefined, + playerId: string + ): void { + // Find closest point on obstacle rectangle to player center + const closestX: number = Math.max( + obstacle.x, + Math.min(obstacle.x + obstacle.width, player.x) + ); + const closestY: number = Math.max( + obstacle.y, + Math.min(obstacle.y + obstacle.height, player.y) + ); + + const dx: number = player.x - closestX; + const dy: number = player.y - closestY; + const distSq: number = dx * dx + dy * dy; + + if (distSq < player.radius * player.radius) { + const dist: number = Math.sqrt(distSq); + const overlap: number = player.radius - dist; + + if (dist === 0) { + // Player center is inside obstacle — push toward nearest edge + const leftDist: number = Math.abs(player.x - obstacle.x); + const rightDist: number = Math.abs(player.x - (obstacle.x + obstacle.width)); + const topDist: number = Math.abs(player.y - obstacle.y); + const bottomDist: number = Math.abs(player.y - (obstacle.y + obstacle.height)); + + const minDist: number = Math.min(leftDist, rightDist, topDist, bottomDist); + + if (minDist === leftDist) { + player.x -= overlap; + } else if (minDist === rightDist) { + player.x += overlap; + } else if (minDist === topDist) { + player.y -= overlap; + } else { + player.y += overlap; + } + } else { + const nx: number = dx / dist; + const ny: number = dy / dist; + player.x += nx * overlap; + player.y += ny * overlap; + } + + onCollide?.(player, playerId, obstacle); + } + } + + // ─── Player vs Player ────────────────────────────────────────────────────── + + /** + * Check all player-player collisions. Apply soft push apart. + * Uses O(n^2) for small player counts; spatial hash can be used for optimization. + */ + checkPlayerPlayerCollisions(players: Map): void { + const playerList: { id: string; player: Player }[] = []; + for (const [id, player] of players.entries()) { + if (player.alive) { + playerList.push({ id, player }); + } + } + + for (let i = 0; i < playerList.length; i++) { + for (let j = i + 1; j < playerList.length; j++) { + const a = playerList[i].player; + const b = playerList[j].player; + + const dx: number = a.x - b.x; + const dy: number = a.y - b.y; + const distSq: number = dx * dx + dy * dy; + const minDist: number = a.radius + b.radius; + + if (distSq < minDist * minDist && distSq > 0) { + const dist: number = Math.sqrt(distSq); + const overlap: number = minDist - dist; + const nx: number = dx / dist; + const ny: number = dy / dist; + + // Soft push — each moves half the overlap + const pushX: number = nx * overlap * 0.5; + const pushY: number = ny * overlap * 0.5; + + a.x += pushX; + a.y += pushY; + b.x -= pushX; + b.y -= pushY; + } + } + } + } + + // ─── Arena Bounds ────────────────────────────────────────────────────────── + + /** + * Clamp all alive players to arena bounds. + */ + checkArenaBounds(players: Map): void { + for (const player of players.values()) { + if (!player.alive) continue; + + const minX: number = player.radius; + const minY: number = player.radius; + const maxX: number = ARENA_WIDTH - player.radius; + const maxY: number = ARENA_HEIGHT - player.radius; + + if (player.x < minX) player.x = minX; + if (player.x > maxX) player.x = maxX; + if (player.y < minY) player.y = minY; + if (player.y > maxY) player.y = maxY; + } + } + + /** + * Clamp bullets to arena bounds. Returns set of bullet IDs that are out of bounds. + */ + checkBulletBounds(bullets: Map): Set { + const outOfBounds: Set = new Set(); + + for (const [id, bullet] of bullets.entries()) { + if ( + bullet.x < -bullet.size || + bullet.x > ARENA_WIDTH + bullet.size || + bullet.y < -bullet.size || + bullet.y > ARENA_HEIGHT + bullet.size + ) { + outOfBounds.add(id); + } + } + + return outOfBounds; + } +} diff --git a/packages/server/src/game-loop.ts b/packages/server/src/game-loop.ts new file mode 100644 index 0000000..a966a74 --- /dev/null +++ b/packages/server/src/game-loop.ts @@ -0,0 +1,417 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Game Loop +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + RoomState, + Player, + Bullet, + XPOrb, + LeaderboardEntry, + type PlayerInput, + TICK_MS, + PLAYER_FRICTION, +} from '@guncircle/shared'; +import { PlayerManager } from './player-manager.js'; +import { CollisionSystem } from './collision.js'; +import { PhysicsEngine } from './physics.js'; +import { GunSystem } from './gun-system.js'; +import { BranchSystem } from './branch-system.js'; + +// ─── Pending Input per Player ──────────────────────────────────────────────── + +interface PendingInput { + input: PlayerInput; + playerId: string; +} + +// ─── GameLoop ──────────────────────────────────────────────────────────────── + +export class GameLoop { + private state: RoomState; + private playerManager: PlayerManager; + private collisionSystem: CollisionSystem; + private physics: PhysicsEngine; + private gunSystem: GunSystem; + private branchSystem: BranchSystem; + + private pendingInputs: PendingInput[] = []; + private bulletLifetimes: Map = new Map(); + private startTime: number; + private tickCount: number = 0; + + // Callbacks for room to handle player events + onPlayerDeath: ((playerId: string, killerId?: string) => void) | undefined; + onPlayerLevelUp: ((playerId: string) => void) | undefined; + + constructor( + state: RoomState, + playerManager: PlayerManager, + collisionSystem: CollisionSystem, + physics: PhysicsEngine, + gunSystem: GunSystem, + branchSystem: BranchSystem + ) { + this.state = state; + this.playerManager = playerManager; + this.collisionSystem = collisionSystem; + this.physics = physics; + this.gunSystem = gunSystem; + this.branchSystem = branchSystem; + this.startTime = Date.now(); + } + + /** + * Queue an input to be processed on the next tick. + */ + queueInput(playerId: string, input: PlayerInput): void { + this.pendingInputs.push({ playerId, input }); + } + + /** + * The core tick function. Called every TICK_MS. + * Order of operations is critical for deterministic simulation. + */ + tick(): void { + this.tickCount++; + const now: number = Date.now(); + const dt: number = TICK_MS / 1000; // seconds + const dtMs: number = TICK_MS; // milliseconds + + // 1. Increment tick counter + this.state.tick = this.tickCount; + + // 2. Process pending inputs + this.processInputs(now, dt); + + // 3. Update bullet positions + this.updateBullets(dt); + + // 4. Update player positions (apply velocity, friction, clamp) + this.updatePlayers(dt); + + // 5. Check all collisions + this.checkCollisions(); + + // 6. Apply recoil recovery + this.updateRecoil(dt); + + // 7. Update reload timers, auto-reload when empty + this.playerManager.updateReloads(this.state.players, dtMs); + this.checkAutoReload(); + + // 8. Regenerate HP for alive players + this.regenerateHp(dt); + + // 9. Update XP orb lifetimes, remove expired + this.updateXpOrbs(dt); + + // 10. Process pending respawns + this.playerManager.processRespawns(this.state); + + // 11. Rebuild leaderboard (top 10 by XP) + this.rebuildLeaderboard(); + + // 12. Remove expired bullets (lifetime exceeded) + this.removeExpiredBullets(now); + + // Clean up processed inputs + this.pendingInputs = []; + } + + // ─── Input Processing ────────────────────────────────────────────────────── + + private processInputs(now: number, dt: number): void { + for (const pending of this.pendingInputs) { + const player: Player | undefined = this.state.players.get(pending.playerId); + if (player === undefined) continue; + if (!player.alive) continue; + + this.playerManager.applyInput( + player, + pending.input, + pending.playerId, + this.state, + now, + dt + ); + + // Handle upgrade choices + if (pending.input.upgradeChoice !== undefined && player.upgradePoints > 0) { + const success: boolean = this.playerManager.applyUpgrade( + player, + pending.input.upgradeChoice + ); + if (success && this.onPlayerLevelUp !== undefined) { + this.onPlayerLevelUp(pending.playerId); + } + } + } + } + + // ─── Bullet Update ───────────────────────────────────────────────────────── + + private updateBullets(dt: number): void { + for (const [bulletId, bullet] of this.state.bullets.entries()) { + // Update position + bullet.x += bullet.vx * dt; + bullet.y += bullet.vy * dt; + } + } + + // ─── Player Update ───────────────────────────────────────────────────────── + + private updatePlayers(dt: number): void { + for (const player of this.state.players.values()) { + if (!player.alive) continue; + + // Apply friction to velocity + this.physics.applyFrictionToPlayer(player, PLAYER_FRICTION); + + // Update position + this.physics.updatePosition(player, dt); + + // Clamp to arena + this.physics.clampToArena(player); + } + } + + // ─── Collision Checks ────────────────────────────────────────────────────── + + private checkCollisions(): void { + // Bullet vs Player + this.collisionSystem.checkBulletPlayerCollisions( + this.state.bullets, + this.state.players, + (bullet, bulletId, victim, victimId) => { + this.handleBulletHit(bullet, bulletId, victim, victimId); + } + ); + + // Player vs XP Orb + this.collisionSystem.checkPlayerOrbCollisions( + this.state.players, + this.state.xpOrbs, + (player, playerId, orb, orbId) => { + this.handleOrbCollect(player, playerId, orb, orbId); + } + ); + + // Player vs Obstacle + this.collisionSystem.checkPlayerObstacleCollisions( + this.state.players, + this.state.obstacles + ); + + // Player vs Player (soft push) + this.collisionSystem.checkPlayerPlayerCollisions(this.state.players); + + // Arena bounds + this.collisionSystem.checkArenaBounds(this.state.players); + + // Bullet out of bounds + const outOfBounds: Set = + this.collisionSystem.checkBulletBounds(this.state.bullets); + for (const id of outOfBounds) { + this.state.bullets.delete(id); + this.bulletLifetimes.delete(id); + } + } + + // ─── Bullet Hit Handling ─────────────────────────────────────────────────── + + private handleBulletHit( + bullet: Bullet, + bulletId: string, + victim: Player, + victimId: string + ): void { + if (!victim.alive) return; + if (bullet.penetration <= 0) return; + + // Apply damage + const died: boolean = this.playerManager.takeDamage( + victim, + bullet.damage, + bullet.isCritical + ); + + // Reduce bullet penetration + bullet.penetration -= 1; + + // Remove bullet if no penetration left + if (bullet.penetration <= 0) { + this.state.bullets.delete(bulletId); + this.bulletLifetimes.delete(bulletId); + } + + // Check death + if (died) { + this.playerManager.onDeath(victim, victimId, this.state, bullet.ownerId); + if (this.onPlayerDeath !== undefined) { + this.onPlayerDeath(victimId, bullet.ownerId); + } + } + } + + // ─── XP Orb Collection ───────────────────────────────────────────────────── + + private handleOrbCollect( + player: Player, + playerId: string, + orb: XPOrb, + orbId: string + ): void { + if (!player.alive) return; + + player.xp += orb.value; + this.state.xpOrbs.delete(orbId); + + // Check level up + const leveledUp: boolean = this.playerManager.checkLevelUp(player); + if (leveledUp && this.onPlayerLevelUp !== undefined) { + this.onPlayerLevelUp(playerId); + } + } + + // ─── Recoil Recovery ─────────────────────────────────────────────────────── + + private updateRecoil(dt: number): void { + for (const player of this.state.players.values()) { + if (!player.alive) continue; + if (player.recoilOffset === 0) continue; + + const gun = this.gunSystem.getGunConfig(player.gunType); + if (gun === undefined) continue; + + this.physics.applyRecoilRecovery(player, dt, gun.recoilRecoveryMs); + } + } + + // ─── Auto Reload ─────────────────────────────────────────────────────────── + + private checkAutoReload(): void { + for (const [playerId, player] of this.state.players.entries()) { + if (!player.alive) continue; + if (player.isReloading) continue; + if (player.ammo <= 0) { + this.playerManager.startReload(player, playerId); + } + } + } + + // ─── HP Regeneration ─────────────────────────────────────────────────────── + + private regenerateHp(dt: number): void { + for (const player of this.state.players.values()) { + if (!player.alive) continue; + if (player.hp >= player.maxHp) continue; + + const regenPerSec: number = this.playerManager.calculateHpRegen(player); + player.hp = Math.min(player.maxHp, player.hp + regenPerSec * dt); + } + } + + // ─── XP Orb Lifetime ─────────────────────────────────────────────────────── + + private updateXpOrbs(dt: number): void { + const toRemove: string[] = []; + + for (const [orbId, orb] of this.state.xpOrbs.entries()) { + orb.lifetime -= dt; + if (orb.lifetime <= 0) { + toRemove.push(orbId); + } + } + + for (const id of toRemove) { + this.state.xpOrbs.delete(id); + } + } + + // ─── Leaderboard ─────────────────────────────────────────────────────────── + + private rebuildLeaderboard(): void { + // Collect all players + const entries: { name: string; level: number; score: number; xp: number }[] = []; + + for (const player of this.state.players.values()) { + entries.push({ + name: player.name, + level: player.level, + score: player.score, + xp: player.xp, + }); + } + + // Sort by XP descending + entries.sort((a, b) => b.xp - a.xp); + + // Take top 10 + const topN: number = Math.min(10, entries.length); + + // Clear and rebuild + this.state.leaderboard.clear(); + for (let i = 0; i < topN; i++) { + const entry = entries[i]; + const lbEntry: LeaderboardEntry = new LeaderboardEntry(); + lbEntry.name = entry.name; + lbEntry.level = entry.level; + lbEntry.score = entry.score; + lbEntry.xp = entry.xp; + this.state.leaderboard.push(lbEntry); + } + } + + // ─── Expired Bullet Cleanup ──────────────────────────────────────────────── + + private removeExpiredBullets(now: number): void { + const toRemove: string[] = []; + + for (const [bulletId, bullet] of this.state.bullets.entries()) { + const gun = this.gunSystem.getGunConfig(bullet.bulletType); + if (gun === undefined) continue; + + // Track bullet spawn time via lifetime map + let spawnTime: number | undefined = this.bulletLifetimes.get(bulletId); + if (spawnTime === undefined) { + spawnTime = now; + this.bulletLifetimes.set(bulletId, spawnTime); + } + + const elapsed: number = now - spawnTime; + if (elapsed > gun.bulletLifetimeMs) { + toRemove.push(bulletId); + } + } + + for (const id of toRemove) { + this.state.bullets.delete(id); + this.bulletLifetimes.delete(id); + } + } + + // ─── Utility ─────────────────────────────────────────────────────────────── + + /** + * Get current tick number. + */ + getTick(): number { + return this.tickCount; + } + + /** + * Get elapsed time since game loop started. + */ + getElapsedTime(): number { + return Date.now() - this.startTime; + } + + /** + * Clean up all resources. + */ + destroy(): void { + this.pendingInputs = []; + this.bulletLifetimes.clear(); + } +} diff --git a/packages/server/src/gun-system.ts b/packages/server/src/gun-system.ts new file mode 100644 index 0000000..64b4563 --- /dev/null +++ b/packages/server/src/gun-system.ts @@ -0,0 +1,162 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// 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; + } +} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..aca9ca0 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,23 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Server Entry Point +// ═══════════════════════════════════════════════════════════════════════════════ + +import { Server } from '@colyseus/core'; +import { uWebSocketsTransport } from '@colyseus/uwebsockets-transport'; +import { ArenaRoom } from './room.js'; + +const port: number = parseInt(process.env.PORT ?? '3000', 10); +const transport = new uWebSocketsTransport(); +const server = new Server({ transport }); + +server.define('arena', ArenaRoom); + +server + .listen(port) + .then(() => { + console.log(`GunCircle.io server listening on port ${port}`); + }) + .catch((err: Error) => { + console.error('Failed to start server:', err.message); + process.exit(1); + }); diff --git a/packages/server/src/physics.ts b/packages/server/src/physics.ts new file mode 100644 index 0000000..a587b1f --- /dev/null +++ b/packages/server/src/physics.ts @@ -0,0 +1,239 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Physics Engine +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + vec2, + type Vec2, + type Bullet, + type Player, + clamp, + ARENA_WIDTH, + ARENA_HEIGHT, + PLAYER_FRICTION, + GUN_RECOVERY_LERP, +} from '@guncircle/shared'; + +// ─── Entity with position + velocity (duck typing for updatePosition) ──────── + +export interface PhysicsEntity { + x: number; + y: number; + vx: number; + vy: number; +} + +// ─── PhysicsEngine ─────────────────────────────────────────────────────────── + +export class PhysicsEngine { + /** + * Update entity position by applying velocity over delta time. + * x += vx * dt, y += vy * dt + */ + updatePosition(entity: PhysicsEntity, dt: number): void { + entity.x += entity.vx * dt; + entity.y += entity.vy * dt; + } + + /** + * Apply friction to velocity by multiplying by friction factor per tick. + * For per-second friction: factor = Math.pow(frictionPerTick, TICK_RATE) + * Here we use per-tick friction directly. + */ + applyFriction(velocity: Vec2, friction: number): Vec2 { + return { + x: velocity.x * friction, + y: velocity.y * friction, + }; + } + + /** + * Apply friction directly to a Player's velocity components. + */ + applyFrictionToPlayer(player: Player, friction: number): void { + player.vx *= friction; + player.vy *= friction; + } + + /** + * Resolve circle-circle collision by pushing entity A out of entity B. + * Returns true if a collision was resolved. + */ + resolveCollision( + a: { x: number; y: number; radius: number }, + b: { x: number; y: number; radius: number }, + softness: number = 0.5 + ): boolean { + const dx: number = a.x - b.x; + const dy: number = a.y - b.y; + const distSq: number = dx * dx + dy * dy; + const minDist: number = a.radius + b.radius; + + if (distSq >= minDist * minDist || distSq === 0) return false; + + const dist: number = Math.sqrt(distSq); + const overlap: number = minDist - dist; + const nx: number = dx / dist; + const ny: number = dy / dist; + + a.x += nx * overlap * softness; + a.y += ny * overlap * softness; + + return true; + } + + /** + * Clamp player position to arena bounds, accounting for player radius. + */ + clampToArena(player: Player): void { + const minX: number = player.radius; + const minY: number = player.radius; + const maxX: number = ARENA_WIDTH - player.radius; + const maxY: number = ARENA_HEIGHT - player.radius; + + player.x = clamp(player.x, minX, maxX); + player.y = clamp(player.y, minY, maxY); + } + + /** + * Clamp any entity position to arena bounds (for bullets, orbs, etc). + */ + clampEntityToArena(entity: { x: number; y: number }, radius: number = 0): void { + entity.x = clamp(entity.x, radius, ARENA_WIDTH - radius); + entity.y = clamp(entity.y, radius, ARENA_HEIGHT - radius); + } + + /** + * Raycast against a circle. Returns true if the ray from origin in direction dir + * intersects the circle at (cx, cy) with radius cr within maxDist. + */ + raycastCircle( + origin: Vec2, + dir: Vec2, + circle: { x: number; y: number; radius: number }, + maxDist: number + ): boolean { + const oc: Vec2 = vec2.sub(origin, { x: circle.x, y: circle.y }); + const a: number = vec2.dot(dir, dir); + const b: number = 2 * vec2.dot(oc, dir); + const c: number = vec2.dot(oc, oc) - circle.radius * circle.radius; + const discriminant: number = b * b - 4 * a * c; + + if (discriminant < 0) return false; + + const sqrtDisc: number = Math.sqrt(discriminant); + const t1: number = (-b - sqrtDisc) / (2 * a); + const t2: number = (-b + sqrtDisc) / (2 * a); + + // Check if either intersection is within [0, maxDist] + const tMin: number = Math.min(t1, t2); + const tMax: number = Math.max(t1, t2); + + return (tMin >= 0 && tMin <= maxDist) || (tMax >= 0 && tMax <= maxDist); + } + + /** + * Check if a bullet (treated as a moving circle) overlaps a player circle. + * Uses swept-circle approximation: check endpoint overlap + mid-point. + */ + bulletHitsPlayer( + bullet: Bullet, + player: Player, + dt: number + ): boolean { + const bulletSpeed: number = Math.sqrt(bullet.vx * bullet.vx + bullet.vy * bullet.vy); + if (bulletSpeed === 0) { + // Stationary bullet — just check overlap + const dx: number = bullet.x - player.x; + const dy: number = bullet.y - player.y; + const r: number = bullet.size + player.radius; + return dx * dx + dy * dy < r * r; + } + + // Current position overlap + const dx: number = bullet.x - player.x; + const dy: number = bullet.y - player.y; + const r: number = bullet.size + player.radius; + if (dx * dx + dy * dy < r * r) return true; + + // Check previous position (approximate by stepping back one frame) + const prevX: number = bullet.x - bullet.vx * dt; + const prevY: number = bullet.y - bullet.vy * dt; + const dpx: number = prevX - player.x; + const dpy: number = prevY - player.y; + if (dpx * dpx + dpy * dpy < r * r) return true; + + // Check if line segment from prev to current intersects player circle + return this.segmentCircleIntersect( + { x: prevX, y: prevY }, + { x: bullet.x, y: bullet.y }, + { x: player.x, y: player.y }, + r + ); + } + + /** + * Check if a line segment intersects a circle. + */ + segmentCircleIntersect( + segStart: Vec2, + segEnd: Vec2, + circleCenter: Vec2, + circleRadius: number + ): boolean { + const segDir: Vec2 = vec2.sub(segEnd, segStart); + const segLenSq: number = vec2.lenSq(segDir); + + if (segLenSq === 0) { + // Degenerate segment — just check point vs circle + const d: number = vec2.dist(segStart, circleCenter); + return d < circleRadius; + } + + // Project circle center onto segment + const toCircle: Vec2 = vec2.sub(circleCenter, segStart); + const t: number = clamp(vec2.dot(toCircle, segDir) / segLenSq, 0, 1); + const closest: Vec2 = vec2.add(segStart, vec2.mul(segDir, t)); + const dist: number = vec2.dist(closest, circleCenter); + + return dist < circleRadius; + } + + /** + * Apply recoil recovery: lerp recoilOffset toward 0. + * factor = 1 - exp(-dt / recoveryMs) + */ + applyRecoilRecovery(player: Player, dt: number, recoveryMs: number): void { + if (recoveryMs <= 0) { + player.recoilOffset = 0; + return; + } + const factor: number = 1 - Math.exp(-dt / (recoveryMs / 1000)); + player.recoilOffset = player.recoilOffset * (1 - factor); + if (Math.abs(player.recoilOffset) < 0.001) { + player.recoilOffset = 0; + } + } + + /** + * Apply recoil recovery using the simpler lerp method (alternative). + */ + applyRecoilRecoveryLerp(player: Player): void { + player.recoilOffset *= 1 - GUN_RECOVERY_LERP; + if (Math.abs(player.recoilOffset) < 0.0001) { + player.recoilOffset = 0; + } + } + + /** + * Get distance squared between two entities. + */ + distSqBetween( + a: { x: number; y: number }, + b: { x: number; y: number } + ): number { + const dx: number = a.x - b.x; + const dy: number = a.y - b.y; + return dx * dx + dy * dy; + } +} diff --git a/packages/server/src/player-manager.ts b/packages/server/src/player-manager.ts new file mode 100644 index 0000000..0460702 --- /dev/null +++ b/packages/server/src/player-manager.ts @@ -0,0 +1,662 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// 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; + } +} diff --git a/packages/server/src/room.ts b/packages/server/src/room.ts new file mode 100644 index 0000000..625ac4f --- /dev/null +++ b/packages/server/src/room.ts @@ -0,0 +1,355 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Arena Room +// ═══════════════════════════════════════════════════════════════════════════════ + +import { Room, Client } from '@colyseus/core'; +import { + RoomState, + Player, + Obstacle, + MAX_PLAYERS_PER_ROOM, + TICK_MS, + ARENA_WIDTH, + ARENA_HEIGHT, +} from '@guncircle/shared'; +import { GunSystem } from './gun-system.js'; +import { BranchSystem } from './branch-system.js'; +import { PlayerManager } from './player-manager.js'; +import { CollisionSystem } from './collision.js'; +import { PhysicsEngine } from './physics.js'; +import { GameLoop } from './game-loop.js'; +import { InputValidator } from './validation.js'; + +// ─── Join Options ──────────────────────────────────────────────────────────── + +interface JoinOptions { + name?: string; +} + +// ─── ArenaRoom ─────────────────────────────────────────────────────────────── + +export class ArenaRoom extends Room { + readonly maxClients: number = MAX_PLAYERS_PER_ROOM; + autoDispose: boolean = false; + + // Subsystems + private gunSystem: GunSystem; + private branchSystem: BranchSystem; + private playerManager: PlayerManager; + private collisionSystem: CollisionSystem; + private physics: PhysicsEngine; + private inputValidator: InputValidator; + private gameLoop: GameLoop; + + // Room lifecycle + private gameLoopInterval: ReturnType | undefined; + private disposeTimer: ReturnType | undefined; + private emptySince: number = 0; + private readonly DISPOSE_AFTER_EMPTY_MS: number = 60000; + + // Client tracking + private clientNames: Map = new Map(); + + // ─── Colyseus Lifecycle ──────────────────────────────────────────────────── + + onCreate(_options: unknown): void { + // Initialize state + this.setState(new RoomState()); + + // Initialize subsystems + this.gunSystem = new GunSystem(); + this.branchSystem = new BranchSystem(); + this.playerManager = new PlayerManager(this.gunSystem, this.branchSystem); + this.collisionSystem = new CollisionSystem(); + this.physics = new PhysicsEngine(); + this.inputValidator = new InputValidator(); + + // Initialize game loop + this.gameLoop = new GameLoop( + this.state, + this.playerManager, + this.collisionSystem, + this.physics, + this.gunSystem, + this.branchSystem + ); + + // Set up game loop callbacks + this.gameLoop.onPlayerDeath = (playerId: string, killerId?: string) => { + this.broadcast('playerDeath', { victim: playerId, killer: killerId }); + }; + this.gameLoop.onPlayerLevelUp = (playerId: string) => { + this.broadcast('playerLevelUp', { playerId }); + }; + + // Spawn some initial obstacles + this.spawnObstacles(); + + // Start game loop + this.gameLoopInterval = setInterval(() => { + this.gameLoop.tick(); + }, TICK_MS); + + // Handle 'input' messages + this.onMessage('input', (client: Client, data: unknown) => { + this.handleInputMessage(client, data); + }); + + // Handle 'upgrade' messages + this.onMessage('upgrade', (client: Client, data: unknown) => { + this.handleUpgradeMessage(client, data); + }); + + // Handle 'reload' messages + this.onMessage('reload', (client: Client, _data: unknown) => { + this.handleReloadMessage(client); + }); + + console.log(`[ArenaRoom] Created — maxClients=${this.maxClients}`); + } + + onJoin(client: Client, options: JoinOptions): void { + const name: string = (options.name ?? `Player ${client.sessionId.substring(0, 4)}`).trim(); + const sanitizedName: string = name.substring(0, 16) || 'Player'; + + this.clientNames.set(client.sessionId, sanitizedName); + + // Spawn player + const player: Player = this.playerManager.spawnPlayer( + client.sessionId, + sanitizedName, + this.state + ); + + // Send gun configs to client + client.send('gunConfigs', { guns: this.gunSystem.getAllGuns() }); + + // Send branch configs to client + client.send('branchConfigs', { branches: this.branchSystem.getAllBranches() }); + + // Cancel dispose timer if active + if (this.disposeTimer !== undefined) { + clearTimeout(this.disposeTimer); + this.disposeTimer = undefined; + } + + console.log(`[ArenaRoom] Joined: ${sanitizedName} (${client.sessionId}) — total: ${this.state.players.size}`); + } + + onLeave(client: Client, consented: boolean): void { + const player: Player | undefined = this.state.players.get(client.sessionId); + if (player !== undefined) { + // Mark as dead + player.alive = false; + player.hp = 0; + + // Clean up tracking + this.playerManager.removePlayer(client.sessionId); + } + + this.clientNames.delete(client.sessionId); + + // Remove player from state after a short delay to let clients process + setTimeout(() => { + this.state.players.delete(client.sessionId); + }, 1000); + + const remainingClients: number = this.clientNames.size; + console.log(`[ArenaRoom] Left: ${client.sessionId} (consented=${consented}) — remaining: ${remainingClients}`); + + // Start dispose timer if room is empty (use clientNames, not players, since players has delayed deletion) + if (remainingClients === 0) { + this.emptySince = Date.now(); + this.disposeTimer = setTimeout(() => { + if (this.clientNames.size === 0) { + console.log('[ArenaRoom] Disposing empty room'); + this.disconnect(); + } + }, this.DISPOSE_AFTER_EMPTY_MS); + } + } + + onDispose(): void { + console.log('[ArenaRoom] Disposed'); + + // Clean up game loop + if (this.gameLoopInterval !== undefined) { + clearInterval(this.gameLoopInterval); + this.gameLoopInterval = undefined; + } + + if (this.disposeTimer !== undefined) { + clearTimeout(this.disposeTimer); + this.disposeTimer = undefined; + } + + this.gameLoop.destroy(); + this.playerManager.reset(); + this.clientNames.clear(); + } + + // ─── Message Handlers ────────────────────────────────────────────────────── + + private handleInputMessage(client: Client, data: unknown): void { + const player: Player | undefined = this.state.players.get(client.sessionId); + if (player === undefined) return; + if (!player.alive) return; + + // Parse and validate input + const input: PlayerInput | null = this.parseInput(data); + if (input === null) return; + + // Sanitize + const sanitized: PlayerInput = this.inputValidator.sanitizeInput(input); + + // Validate aim angle + const aimResult = this.inputValidator.validateAimAngle(player.angle, sanitized.aimAngle); + sanitized.aimAngle = aimResult.angle; + + // Queue for game loop + this.gameLoop.queueInput(client.sessionId, sanitized); + } + + private handleUpgradeMessage(client: Client, data: unknown): void { + const player: Player | undefined = this.state.players.get(client.sessionId); + if (player === undefined) return; + if (player.upgradePoints <= 0) return; + + let choice: number | undefined; + + if (typeof data === 'number') { + choice = data; + } else if ( + typeof data === 'object' && + data !== null && + 'choice' in data && + typeof (data as Record).choice === 'number' + ) { + choice = (data as Record).choice as number; + } + + if (choice === undefined) return; + + const success: boolean = this.playerManager.applyUpgrade(player, choice); + if (success) { + client.send('upgradeAck', { choice, pointsRemaining: player.upgradePoints }); + } + } + + private handleReloadMessage(client: Client): void { + const player: Player | undefined = this.state.players.get(client.sessionId); + if (player === undefined) return; + if (!player.alive) return; + + this.playerManager.startReload(player, client.sessionId); + } + + // ─── Input Parsing ───────────────────────────────────────────────────────── + + private parseInput(data: unknown): PlayerInput | null { + if (typeof data !== 'object' || data === null) return null; + + const d = data as Record; + + const seq: number = typeof d.seq === 'number' ? d.seq : 0; + const moveAngle: number = typeof d.moveAngle === 'number' ? d.moveAngle : -1; + const aimAngle: number = typeof d.aimAngle === 'number' ? d.aimAngle : 0; + const isShooting: boolean = Boolean(d.isShooting); + const upgradeChoice: number | undefined = + typeof d.upgradeChoice === 'number' ? d.upgradeChoice : undefined; + + return { seq, moveAngle, aimAngle, isShooting, upgradeChoice }; + } + + // ─── Obstacle Generation ─────────────────────────────────────────────────── + + private spawnObstacles(): void { + // Spawn some indestructible walls around the arena + const wallThickness: number = 50; + + // Top wall segments + for (let i = 0; i < 6; i++) { + this.createObstacle( + 200 + i * 500, + 100, + wallThickness * 4, + wallThickness, + 0 // IndestructibleWall + ); + } + + // Bottom wall segments + for (let i = 0; i < 6; i++) { + this.createObstacle( + 200 + i * 500, + ARENA_HEIGHT - 150, + wallThickness * 4, + wallThickness, + 0 + ); + } + + // Left wall segments + for (let i = 0; i < 6; i++) { + this.createObstacle( + 100, + 200 + i * 500, + wallThickness, + wallThickness * 4, + 0 + ); + } + + // Right wall segments + for (let i = 0; i < 6; i++) { + this.createObstacle( + ARENA_WIDTH - 150, + 200 + i * 500, + wallThickness, + wallThickness * 4, + 0 + ); + } + + // Central cover blocks + this.createObstacle(ARENA_WIDTH / 2 - 100, ARENA_HEIGHT / 2 - 100, 200, 50, 3); // Cover + this.createObstacle(ARENA_WIDTH / 2 - 50, ARENA_HEIGHT / 2 + 50, 100, 100, 3); + + // Scattered crates + const cratePositions: Array<[number, number]> = [ + [400, 400], + [ARENA_WIDTH - 400, 400], + [400, ARENA_HEIGHT - 400], + [ARENA_WIDTH - 400, ARENA_HEIGHT - 400], + [ARENA_WIDTH / 2, 300], + [ARENA_WIDTH / 2, ARENA_HEIGHT - 300], + [300, ARENA_HEIGHT / 2], + [ARENA_WIDTH - 300, ARENA_HEIGHT / 2], + ]; + + for (const [x, y] of cratePositions) { + this.createObstacle(x, y, 60, 60, 1); // DestructibleCrate + } + + // Slow zones + this.createObstacle(ARENA_WIDTH / 4, ARENA_HEIGHT / 4, 200, 200, 2); + this.createObstacle((ARENA_WIDTH * 3) / 4, (ARENA_HEIGHT * 3) / 4, 200, 200, 2); + } + + private createObstacle( + x: number, + y: number, + width: number, + height: number, + type: number + ): void { + const obs: Obstacle = new Obstacle(); + obs.x = x; + obs.y = y; + obs.width = width; + obs.height = height; + obs.type = type; + const id: string = `obs_${this.state.obstacles.size}`; + this.state.obstacles.set(id, obs); + } +} diff --git a/packages/server/src/validation.ts b/packages/server/src/validation.ts new file mode 100644 index 0000000..471f601 --- /dev/null +++ b/packages/server/src/validation.ts @@ -0,0 +1,204 @@ +// ═══════════════════════════════════════════════════════════════════════════════ +// GunCircle.io — Input Validator (Anti-cheat) +// ═══════════════════════════════════════════════════════════════════════════════ + +import { + type Player, + type GunConfig, + type PlayerInput, + PLAYER_BASE_SPEED, + PLAYER_ACCELERATION, + ARENA_WIDTH, + ARENA_HEIGHT, + STAT_MAX_LEVEL, + STAT_COUNT, + Stat, + AIM_ANGLE_TOLERANCE, + MAX_LEVEL, +} from '@guncircle/shared'; + +// ─── InputValidator ────────────────────────────────────────────────────────── + +export class InputValidator { + /** Maximum position change per tick at max speed */ + private readonly maxDistPerTick: number; + + constructor() { + // At 60Hz, max distance per tick = speed / 60 + small tolerance + this.maxDistPerTick = PLAYER_BASE_SPEED / 60 + 2; + } + + /** + * Validate movement input. Speed must not exceed base * (1 + moveSpeedStat). + * Returns clamped (newX, newY) that are within allowed bounds. + */ + validateMovement( + player: Player, + moveAngle: number, + dt: number + ): { x: number; y: number; valid: boolean } { + if (moveAngle < -0.5) { + // -1 or negative means no movement input + return { x: player.x, y: player.y, valid: true }; + } + + const moveSpeedStat: number = player.stats[Stat.MovementSpeed] ?? 0; + const maxSpeed: number = PLAYER_BASE_SPEED * (1 + moveSpeedStat * 0.05); + const maxStep: number = maxSpeed * dt; + + // Validate position delta from current position + const requestedVx: number = Math.cos(moveAngle) * maxSpeed; + const requestedVy: number = Math.sin(moveAngle) * maxSpeed; + + const newX: number = player.x + requestedVx * dt; + const newY: number = player.y + requestedVy * dt; + + // Clamp to arena + const clampedX: number = Math.max( + player.radius, + Math.min(ARENA_WIDTH - player.radius, newX) + ); + const clampedY: number = Math.max( + player.radius, + Math.min(ARENA_HEIGHT - player.radius, newY) + ); + + // Check if the step size is reasonable + const dx: number = clampedX - player.x; + const dy: number = clampedY - player.y; + const stepSize: number = Math.sqrt(dx * dx + dy * dy); + + if (stepSize > maxStep * 1.5) { + // Possible speed hack — reject movement + return { x: player.x, y: player.y, valid: false }; + } + + return { x: clampedX, y: clampedY, valid: true }; + } + + /** + * Validate aim angle change. Change per tick must be within tolerance. + */ + validateAimAngle(oldAngle: number, newAngle: number): { angle: number; valid: boolean } { + let diff: number = newAngle - oldAngle; + // Normalize to [-PI, PI] + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + + const absDiff: number = Math.abs(diff); + if (absDiff > AIM_ANGLE_TOLERANCE * 10) { + // Suspiciously large angle change — could be aimbot + // Cap it to max allowed + const cappedDiff: number = Math.sign(diff) * AIM_ANGLE_TOLERANCE * 10; + let cappedAngle: number = oldAngle + cappedDiff; + while (cappedAngle > Math.PI) cappedAngle -= Math.PI * 2; + while (cappedAngle < -Math.PI) cappedAngle += Math.PI * 2; + return { angle: cappedAngle, valid: false }; + } + + return { angle: newAngle, valid: true }; + } + + /** + * Validate fire rate: time since last shot must be >= cooldown. + * Returns true if firing is allowed. + */ + validateFireRate( + lastShotTime: number, + currentTime: number, + gun: GunConfig, + reloadSpdStat: number + ): boolean { + const baseCooldown: number = 1000 / gun.fireRate; + const reloadBonus: number = 1 + reloadSpdStat * 0.1; + const adjustedCooldown: number = baseCooldown / reloadBonus; + + const elapsed: number = currentTime - lastShotTime; + return elapsed >= adjustedCooldown - 5; // 5ms tolerance for network jitter + } + + /** + * Validate stat upgrade choice. Returns true if the player can make this upgrade. + */ + validateUpgrade(player: Player, choice: number): { valid: boolean; reason: string } { + // Must have upgrade points + if (player.upgradePoints <= 0) { + return { valid: false, reason: 'No upgrade points available' }; + } + + // Choice must be a valid stat index + if (choice < 0 || choice >= STAT_COUNT) { + return { valid: false, reason: 'Invalid stat index' }; + } + + // Stat must not be maxed + const currentLevel: number = player.stats[choice] ?? 0; + if (currentLevel >= STAT_MAX_LEVEL) { + return { valid: false, reason: 'Stat already at max level' }; + } + + return { valid: true, reason: '' }; + } + + /** + * Validate branch selection choice. Returns true if branch is valid for player. + */ + validateBranchChoice( + player: Player, + branchIndex: number, + availableBranches: number[] + ): { valid: boolean; reason: string } { + if (branchIndex < 0 || branchIndex >= availableBranches.length) { + return { valid: false, reason: 'Invalid branch selection' }; + } + + if (player.upgradePoints <= 0) { + return { valid: false, reason: 'No upgrade points available' }; + } + + return { valid: true, reason: '' }; + } + + /** + * Validate general input bounds. Clamp numeric values to reasonable ranges. + */ + sanitizeInput(input: PlayerInput): PlayerInput { + return { + seq: Math.max(0, input.seq | 0), + moveAngle: isNaN(input.moveAngle) ? -1 : input.moveAngle, + aimAngle: isNaN(input.aimAngle) ? 0 : input.aimAngle, + isShooting: Boolean(input.isShooting), + upgradeChoice: + input.upgradeChoice !== undefined ? Math.max(0, input.upgradeChoice | 0) : undefined, + }; + } + + /** + * Check if a position is within arena bounds. + */ + isInArena(x: number, y: number, radius: number = 0): boolean { + return ( + x >= radius && + x <= ARENA_WIDTH - radius && + y >= radius && + y <= ARENA_HEIGHT - radius + ); + } + + /** + * Validate that velocity is within reasonable bounds. + */ + validateVelocity(vx: number, vy: number, maxSpeed: number): { vx: number; vy: number } { + const speedSq: number = vx * vx + vy * vy; + const maxSpeedSq: number = maxSpeed * maxSpeed; + + if (speedSq > maxSpeedSq * 4) { + // Suspicious velocity — cap it + const speed: number = Math.sqrt(speedSq); + const scale: number = (maxSpeed * 2) / speed; + return { vx: vx * scale, vy: vy * scale }; + } + + return { vx, vy }; + } +} diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..9be0d9c --- /dev/null +++ b/packages/server/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["./src"], + "references": [{ "path": "../shared" }] +} diff --git a/packages/shared/package.json b/packages/shared/package.json new file mode 100644 index 0000000..47b2ce7 --- /dev/null +++ b/packages/shared/package.json @@ -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" + } +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts new file mode 100644 index 0000000..9a25eea --- /dev/null +++ b/packages/shared/src/constants.ts @@ -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.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, +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts new file mode 100644 index 0000000..7653eda --- /dev/null +++ b/packages/shared/src/index.ts @@ -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'; diff --git a/packages/shared/src/math.ts b/packages/shared/src/math.ts new file mode 100644 index 0000000..54ddd4b --- /dev/null +++ b/packages/shared/src/math.ts @@ -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 { + private cells = new Map(); + private itemToCell = new Map(); + + 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(); + } +} diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts new file mode 100644 index 0000000..f43c4ff --- /dev/null +++ b/packages/shared/src/schema.ts @@ -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(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(); + @type({ map: Bullet }) bullets = new MapSchema(); + @type({ map: XPOrb }) xpOrbs = new MapSchema(); + @type({ map: Obstacle }) obstacles = new MapSchema(); + @type('uint32') tick = 0; + @type([LeaderboardEntry]) leaderboard = new ArraySchema(); +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts new file mode 100644 index 0000000..5cdbc95 --- /dev/null +++ b/packages/shared/src/types.ts @@ -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>; +} + +// ─── 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; +} diff --git a/packages/shared/tsconfig.json b/packages/shared/tsconfig.json new file mode 100644 index 0000000..301c885 --- /dev/null +++ b/packages/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true + }, + "include": ["./src"] +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..ce95756 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - packages/shared + - packages/server + - packages/client diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4325ec8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "composite": true, + "baseUrl": ".", + "paths": { + "@guncircle/shared": ["./packages/shared/src"] + } + }, + "include": ["packages/*/src"], + "exclude": ["node_modules", "dist"] +}