diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..dbceae0 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,7 @@ +node_modules/ +coverage/ +dist/ +# ascendants/ is a self-contained subproject with its own ESLint config, +# TypeScript settings and Prettier rules. Linting it with the React Native +# config at this repo's root reports formatting conflicts that mean nothing. +ascendants/ diff --git a/ascendants/.env.example b/ascendants/.env.example new file mode 100644 index 0000000..c3589ab --- /dev/null +++ b/ascendants/.env.example @@ -0,0 +1,6 @@ +# WebSocket relay the client connects to for online play. +# The relay only forwards inputs between two peers; it never simulates a match. +VITE_RELAY_URL=ws://localhost:8787 + +# Port the relay binds to (server/index.ts). +PORT=8787 diff --git a/ascendants/.eslintrc.cjs b/ascendants/.eslintrc.cjs new file mode 100644 index 0000000..f66684d --- /dev/null +++ b/ascendants/.eslintrc.cjs @@ -0,0 +1,46 @@ +/** + * Lint rules. + * + * The layering rule is the one that matters most: the engine must never import + * from the renderer, the UI or the network. That boundary is what keeps the + * simulation deterministic and portable, and a lint rule enforces it far more + * reliably than a convention nobody remembers at 2am. + */ +module.exports = { + root: true, + parser: '@typescript-eslint/parser', + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, + plugins: ['@typescript-eslint'], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], + env: { browser: true, es2022: true, node: true }, + ignorePatterns: ['dist', 'node_modules', '*.cjs'], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + 'no-console': ['error', { allow: ['warn', 'error'] }], + eqeqeq: ['error', 'always'], + 'prefer-const': 'error', + }, + overrides: [ + { + files: ['src/engine/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['**/render/**', '**/ui/**', '**/net/**', '**/client/**', 'three'], + message: + 'The engine must stay free of presentation and transport. Dependencies flow one way.', + }, + ], + }, + ], + // Math.random and Date.now would both break rollback determinism. + 'no-restricted-globals': ['error', { name: 'Date', message: 'The simulation must not read a clock.' }], + }, + }, + ], +}; diff --git a/ascendants/.github/workflows/ci.yml b/ascendants/.github/workflows/ci.yml new file mode 100644 index 0000000..728d757 --- /dev/null +++ b/ascendants/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + - run: npm ci + - name: Typecheck + run: npm run type-check + - name: Lint + run: npm run lint + - name: Test + run: npm test + - name: Build + run: npm run build diff --git a/ascendants/.gitignore b/ascendants/.gitignore new file mode 100644 index 0000000..01344a4 --- /dev/null +++ b/ascendants/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +.env +.env.local +*.log +.DS_Store +coverage/ +.vite/ diff --git a/ascendants/CLAUDE.md b/ascendants/CLAUDE.md new file mode 100644 index 0000000..98de05d --- /dev/null +++ b/ascendants/CLAUDE.md @@ -0,0 +1,117 @@ +# CLAUDE.md — ASCENDANTS + +Repo-specific operating rules. Read `README.md` first for what the game is. + +--- + +## The one rule that breaks everything if you get it wrong + +**`step()` must stay pure and deterministic.** + +`src/engine/sim/step.ts` takes `(state, inputs, config)` and returns a new +state. Rollback re-runs past frames on both peers and compares checksums. If a +single operation is non-deterministic, matches desync and the failure is +silent — two players quietly playing different games. + +Inside `src/engine/**` you may **never**: + +- call `Math.sin`, `Math.cos`, `Math.tan`, `Math.atan`, `Math.atan2` + (use `engine/math/trig.ts` — they are implementation-defined and drift) +- call `Math.random` (use `engine/math/rng.ts`, whose state lives in `MatchState`) +- read a clock (`Date.now`, `performance.now`) +- import from `render`, `ui`, `net`, `client`, or `three` +- store `undefined` in state that gets snapshotted +- mutate a `Vec3` component in place — always assign a whole new vector + (snapshots share vector references precisely because they are immutable) + +`Math.sqrt`, `Math.abs`, `Math.round`, `Math.floor`, `Math.min/max` and +`Math.imul` are all exactly specified and safe. + +The lint config enforces the import boundary. It cannot catch the rest — you +have to. + +After any engine change, run `npm test`. `sim/step.test.ts` and +`net/rollback.test.ts` are the guards; if either fails, determinism is broken +and nothing else matters until it is fixed. + +--- + +## Layering + +``` +engine → (nothing) +net → engine +render → engine +ui → engine, render +client → everything +server → net/protocol only +``` + +Dependencies flow one way. No upward imports, ever. + +Appearance lives in `render/design/`, not in the engine. A character's colours +must never be able to influence a hitbox. + +--- + +## Where things live + +| You want to change | Go to | +|---|---| +| Frame data, hitboxes, move properties | `engine/data/universal.ts`, `engine/data/specials/.ts` | +| Character stats and pronouns | `engine/data/characters.ts` | +| Physics, meters, round timings | `engine/data/constants.ts` | +| Finisher names, the announcer callout | `engine/data/finishers.ts` | +| How a fighter looks | `render/design/index.ts` | +| How a fighter animates | `render/pose.ts` | +| Hit priority (invuln → armor → parry → block → counter) | `engine/combat/resolve.ts` | +| Netcode | `net/rollback.ts` | + +Prefer data over logic. Most balance changes should be a number in a table, not +a branch in a function. + +--- + +## Adding a character + +1. Add stats, lore and `pronoun` to `engine/data/characters.ts`. +2. Add `engine/data/specials/.ts` with three specials and one super, and + register it in `engine/data/moves.ts`. +3. Add an Erasure to `engine/data/finishers.ts`. +4. Add a `FighterDesign` to `render/design/index.ts`. + +Move ownership is derived from the id prefix (`sei.updraft` belongs to `sei`, +`u.*` to everyone), so there is no second list to keep in sync. + +New designs must pass the silhouette test: blacked out at thumbnail size, the +fighter should still be identifiable. Check the shoulder width and height +against the existing six before picking colours. + +--- + +## Conventions + +- TypeScript strict, including `noUncheckedIndexedAccess` and + `exactOptionalPropertyTypes`. No `any`. +- One file, one job. Past ~200 lines, ask whether it should be split. +- Comments explain **why**, never what. If a line needs a comment to say what it + does, rename something instead. +- Tests co-located as `Module.test.ts`. +- Frame counts, not seconds, anywhere near gameplay. A fighting game's feel + lives in exact frame advantage. + +## Before committing + +```bash +npm run type-check && npm run lint && npm test && npm run build +``` + +All four must pass. + +## Commit format + +``` +feat(engine): add beam clash contest +fix(net): correct rollback depth accounting +chore(render): tune impact flash scale +``` diff --git a/ascendants/LICENSE b/ascendants/LICENSE new file mode 100644 index 0000000..e1d36ca --- /dev/null +++ b/ascendants/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Cloudygetty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ascendants/README.md b/ascendants/README.md new file mode 100644 index 0000000..0d21b17 --- /dev/null +++ b/ascendants/README.md @@ -0,0 +1,209 @@ +# ASCENDANTS + +> The sky fell. Some of us got back up. + +A 3D arena fighter for the browser. Street Fighter 6's Drive system and frame +data, Mortal Kombat's round structure and finishers, and Dragon Ball's flight, +ki, beam clashes and transformations — running on a deterministic simulation +with rollback netcode. + +No art assets. No game engine. Every fighter is built at runtime from a table +of numbers, and the whole simulation is one pure function. + +--- + +## Play + +```bash +npm install +npm run dev # http://localhost:5173 +npm run server # relay on :8787, only needed for online play +``` + +| Mode | What it is | +|---|---| +| **Arcade** | You against the CPU, at three difficulties | +| **Local Versus** | Two players, one keyboard | +| **Online** | Rollback netcode; both players enter the same room code | + +### Controls + +| Input | Keyboard (P1) | Pad | +|---|---|---| +| Move / motions | `W` `A` `S` `D` | Stick / D-pad | +| Light · Medium · Heavy | `J` `K` `L` | ▢ · △ · ○ | +| Ki | `U` | ✕ | +| Guard | `Space` | RT | +| Drive (parry / impact) | `I` | LB | +| Dash | `Shift` | RB | +| Charge ki | `C` | RT | +| Vanish | `V` | R3 | + +Player two uses the arrow keys and the numpad, or a second gamepad. + +Directions are **relative to your opponent**: `6` is always toward them, +regardless of which side you are on, so motion inputs never flip. + +--- + +## Systems + +### Drive gauge — from Street Fighter 6 + +Six bars. Spend them on Drive Impact (armoured, wall-splats), Drive Parry +(absorbs and refunds a bar), Drive Rush, and Overdrive specials. Blocking burns +it. Empty the gauge and you enter **Burnout**: no drive options, chip damage +can kill you, and your blockstun grows. It is the game's central risk dial. + +### Ki gauge — from Dragon Ball + +Pays for blasts, flight, Vanish and transformation. It barely regenerates on +its own — the fast way to fill it is the **charge stance**, which leaves you +completely defenceless. That trade is the whole point. + +**Vanish** lets you spend 25 ki mid-combo to blink behind your attacker and +reset to neutral. Without it a single launcher would mean watching an unbroken +juggle; with it, offence has to respect the escape. + +**Ascend** at full ki for +25% damage and +18% speed, paid for by ki draining +continuously until you drop out of it. + +### Beam clash + +Two projectiles meeting head-on enter a contested clash. Beams are just +projectiles with high clash power, so the beam struggle falls out of ordinary +collision rather than needing a bespoke system. + +### Rounds and the Erasure — from Mortal Kombat + +Best of three, 99 seconds. **Fatal Blow** is available once per match, only +below 30% health, fully invulnerable on the way in. + +A match-ending blow does not kill. It leaves the loser standing, swaying and +helpless while the announcer calls: + +> ## PUT HER UNDER. + +The pronoun comes from the loser's character sheet — `HER`, `HIM` or `THEM`. +The winner then has 200 frames to land their character's **Erasure**. Let the +window lapse and the match simply ends. The finisher is an option the player +takes, never a cutscene the game plays at them. + +| Fighter | Erasure | +|---|---| +| VANTA | Event Silence | +| KORVATH | Burial Rite | +| SEI | The Tenth Step | +| MARROW | Long Dark | +| TALON | Final Reply | +| HELIOS | Total Eclipse | + +--- + +## Roster + +| | Fighter | Archetype | Reads as | +|---|---|---|---| +| **V** | **VANTA** · The Even Hand | Balanced reference kit | Even shoulders, long braid | +| **K** | **KORVATH** · Sunken Colossus | Armour and command grabs | A block; widest shoulders | +| **S** | **SEI** · Nine Steps | Rushdown, lowest damage | A sliver; split skirt panels | +| **M** | **MARROW** · The Long Dark | Zoner, layered projectiles | A vertical line; hooded | +| **T** | **TALON** · Counterweight | Counters and punishes | Wiry, chain belt | +| **H** | **HELIOS** · Last Light | Glass cannon | Broad top, burnt half-cape | + +Every design is built to pass the silhouette test: blacked out at thumbnail +size, you should still know who you are looking at. That drove the proportions +far more than the colour did. + +--- + +## Architecture + +``` +src/ +├── engine/ pure simulation — no DOM, no three.js, no network +│ ├── math/ deterministic trig, seeded PRNG, vectors +│ ├── data/ frame data, roster, tuning constants, erasures +│ ├── input/ ring buffer + motion recognition +│ ├── combat/ capsules, damage, drive, ki, projectiles, resolution +│ ├── fighter/ state machine, locomotion, physics +│ ├── match/ round flow and the Erasure window +│ ├── ai/ deterministic CPU opponent +│ └── sim/ step() · snapshot · checksum +├── net/ rollback session, protocol, transports +├── render/ three.js scene, procedural rigs, poses, VFX, camera +├── ui/ HUD, menus, styles +└── client/ input mapping, fixed-timestep loop, bootstrap +server/ WebSocket relay + lobby +``` + +Dependencies flow one way, and a lint rule enforces it: **the engine may not +import from `render`, `ui`, `net`, `client`, or `three`.** That boundary is +what keeps the simulation deterministic and portable. + +### Determinism + +`step(state, inputs, config)` is pure. Same state plus same inputs always +produces the same next state, on every machine. + +That is harder than it sounds in JavaScript, and it drove three decisions: + +- **`Math.sin`, `Math.cos` and `Math.atan2` are banned in the engine.** IEEE-754 + exactly specifies `+ - * /` and `sqrt`, but the transcendental functions are + implementation-defined and drift between engines and CPUs. One ULP of drift in + a facing angle desyncs a match within seconds. `engine/math/trig.ts` provides + polynomial replacements using only exactly-specified operations. +- **`Math.random` is banned.** A seeded xorshift128 lives in the state and is + snapshot and restored with everything else. +- **No clocks.** Nothing in the simulation may read the time. + +`checksum()` hashes floats by their exact bit pattern, so a one-ULP divergence +is caught on the frame it happens rather than after it grows visible. + +### Rollback netcode + +Your input applies the frame you press it. Your opponent's has not arrived yet, +so it is predicted — repeat their last input, which is right most of the time +because inputs are held across frames. When the real input contradicts the +prediction, the session rewinds, corrects, and re-simulates forward at speed. + +The test suite proves the property that matters: a peer running with six frames +of delay lands on a **byte-identical** state to a peer with none. + +The server is a relay, not an authority. It pairs two players, tells each which +side they are on, and forwards inputs. It never simulates a match, so it can +never be the thing that desyncs one. + +--- + +## Development + +```bash +npm run type-check # tsc --noEmit, strict +npm run lint # eslint, including the layering rule +npm test # vitest — 91 tests +npm run build # production bundle +``` + +Tests are co-located (`Module.ts` → `Module.test.ts`). The suite covers +deterministic trig and PRNG, motion recognition and its leniency, capsule +collision, the damage pipeline, drive and burnout, the full round and Erasure +flow, simulation purity and rollback replay, and two-peer netcode convergence. + +### Tuning the game + +Almost all balance lives in data, not logic: + +| Change | File | +|---|---| +| Frame data, hitboxes, damage | `engine/data/universal.ts`, `engine/data/specials/*` | +| Character stats | `engine/data/characters.ts` | +| Physics, meters, timings | `engine/data/constants.ts` | +| Finisher names and the callout | `engine/data/finishers.ts` | +| Fighter appearance | `render/design/index.ts` | + +--- + +## License + +MIT diff --git a/ascendants/index.html b/ascendants/index.html new file mode 100644 index 0000000..8caa43b --- /dev/null +++ b/ascendants/index.html @@ -0,0 +1,19 @@ + + + + + + ASCENDANTS + + + + + + + + +
+ + + + diff --git a/ascendants/package-lock.json b/ascendants/package-lock.json new file mode 100644 index 0000000..0c7d3d6 --- /dev/null +++ b/ascendants/package-lock.json @@ -0,0 +1,4801 @@ +{ + "name": "ascendant", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ascendant", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "three": "^0.185.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/three": "^0.185.0", + "@types/ws": "^8.5.13", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^8.57.1", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vitest": "^2.1.8" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "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.npmjs.org/@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/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/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.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "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.npmjs.org/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.npmjs.org/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": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/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/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@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/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.67.0.tgz", + "integrity": "sha512-Un7Heoyj65NREbKAyIrFxeM143NZpExWmy1Nep4DLeQOeLlTeumPjoNKnBrU5D5moWXbPJgRa5Uwcdu0faVNGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/type-utils": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "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.67.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.67.0.tgz", + "integrity": "sha512-fUBfTuuEulWqX6V8+O3PtScV01tzYYRUDTAirHFKoRAt7nOzoGiPt0M/bB47wWNy0coOOcgEwAMUtBpykMxl6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "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.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.67.0.tgz", + "integrity": "sha512-cvE8c7ulYeXN9fYuszhCeCsbzyVEXuhrRCybnBre7TUmqb5nRmBfQAwCj0O3WJFDeyAZt4VYv51vMCC9LHSdYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.67.0", + "@typescript-eslint/types": "^8.67.0", + "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.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.67.0.tgz", + "integrity": "sha512-EgvsleTwS4E+WzzSvem8fAUubLwatMNF1B5hHSLQxcvs7q2dtRhGyujHwLJSYlG41niJ7GP24Aha2+0mb1b2kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.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/tsconfig-utils": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.67.0.tgz", + "integrity": "sha512-vV+LUSv5njUWsknE71fqKTlXUva+R76SaeORd6Zojcunk/6DvKFXONU3BrAs2H49mbygUXt6gbYunzwqNwlhdg==", + "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.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.67.0.tgz", + "integrity": "sha512-aVWDXbRmdXO9siTfX4ditQI1T9+zVcNazT48EJCD0v40/9RIFoUgZ05CmGEq9H2gixRpjUn/iplwvlcvutJW/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.0", + "@typescript-eslint/utils": "8.67.0", + "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.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.67.0.tgz", + "integrity": "sha512-sBtgslww8nsMYUjhdPBiSyUqSzT8uR6g93A2QXnQC8+cGdjz0CyaOdqHDRJb1AtORbZCNUJBBeFA/tNR2uQmww==", + "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.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.67.0.tgz", + "integrity": "sha512-EKQBCE9yNlRJYm7jdTW5AhDacDUmSwQb0FAJAmK2EKYrNXIsa2vxcSZx6PvJ/dEdI6lS+Y9W+EXckLj0iPFGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.67.0", + "@typescript-eslint/tsconfig-utils": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/visitor-keys": "8.67.0", + "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.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.67.0.tgz", + "integrity": "sha512-U9D1FdwEWBwok3hxxSdhclMb0twvt9QnjIQ0VfQ1AiX2epnpSgv2ubVDsayOFyY8K6FX+AQ7E0FKWVG3iKsj1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.67.0", + "@typescript-eslint/types": "8.67.0", + "@typescript-eslint/typescript-estree": "8.67.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/visitor-keys": { + "version": "8.67.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.67.0.tgz", + "integrity": "sha512-fkv8dHRDqfGtTHuJeebdrQ7cX6Ad4WAS00rgHh9UGvMycF1mjBfsxry1XsLIFhWZ6Judlh6UdzK+TYlbpCXgnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.67.0", + "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.npmjs.org/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/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/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.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/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/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/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": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/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/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/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": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/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/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/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/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/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.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "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.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/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.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "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.17", + "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.npmjs.org/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.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/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.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "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/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/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/tsx": { + "version": "4.23.12", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz", + "integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/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/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/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.npmjs.org/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.npmjs.org/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": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "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/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-node/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/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/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "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.npmjs.org/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/ascendants/package.json b/ascendants/package.json new file mode 100644 index 0000000..501da0d --- /dev/null +++ b/ascendants/package.json @@ -0,0 +1,34 @@ +{ + "name": "ascendants", + "version": "0.1.0", + "private": true, + "description": "ASCENDANTS — 3D arena fighter with deterministic rollback netcode", + "type": "module", + "license": "MIT", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "server": "tsx server/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "type-check": "tsc --noEmit", + "lint": "eslint src server --ext .ts" + }, + "dependencies": { + "three": "^0.185.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/three": "^0.185.0", + "@types/ws": "^8.5.13", + "@typescript-eslint/eslint-plugin": "^8.18.0", + "@typescript-eslint/parser": "^8.18.0", + "eslint": "^8.57.1", + "tsx": "^4.19.2", + "typescript": "^5.7.2", + "vite": "^6.0.3", + "vitest": "^2.1.8" + } +} diff --git a/ascendants/server/index.ts b/ascendants/server/index.ts new file mode 100644 index 0000000..adec723 --- /dev/null +++ b/ascendants/server/index.ts @@ -0,0 +1,126 @@ +import { WebSocketServer, type WebSocket } from 'ws'; +import { decode, encode, isClientMessage, PROTOCOL_VERSION, type PlayerIndex, type ServerMessage } from '../src/net/protocol'; +import { Lobby } from './lobby'; + +/** + * Match relay. + * + * Forwards input and checksum traffic between the two clients in a room and + * does nothing else. It deliberately holds no game state — see lobby.ts for + * why that is the right shape for a rollback title. + */ + +const PORT = Number(process.env.PORT ?? 8787); +const STALE_ROOM_MS = 5 * 60 * 1000; +const PRUNE_INTERVAL_MS = 60 * 1000; + +const lobby = new Lobby(); +const server = new WebSocketServer({ port: PORT }); + +type Session = { roomId: string; index: PlayerIndex } | null; + +function send(socket: WebSocket, message: ServerMessage): void { + if (socket.readyState === socket.OPEN) { + socket.send(encode(message)); + } +} + +server.on('connection', (socket: WebSocket) => { + let session: Session = null; + + socket.on('message', (raw: unknown) => { + const message = decode(String(raw)); + if (!message || !isClientMessage(message)) { + return; + } + + if (message.type === 'hello') { + if (message.version !== PROTOCOL_VERSION) { + send(socket, { type: 'error', reason: `protocol ${PROTOCOL_VERSION} required` }); + socket.close(); + return; + } + const seat = lobby.join(message.roomId, socket, message.characterId); + if (!seat) { + send(socket, { type: 'error', reason: 'room full' }); + socket.close(); + return; + } + session = { roomId: message.roomId, index: seat.index }; + send(socket, { type: 'assign', playerIndex: seat.index, roomId: message.roomId }); + + // Both seats filled: hand out the shared seed and start the match. + const room = lobby.get(message.roomId); + if (room && room.seats.length === 2 && !room.started) { + room.started = true; + const characterIds: [string, string] = [ + room.seats[0]?.characterId ?? 'vanta', + room.seats[1]?.characterId ?? 'korvath', + ]; + for (const occupant of room.seats) { + send(occupant.socket, { + type: 'start', + seed: room.seed, + characterIds, + stageId: room.stageId, + }); + } + } + return; + } + + if (!session) { + return; + } + + if (message.type === 'ping') { + send(socket, { type: 'pong', sent: message.sent }); + return; + } + + // Everything else is relayed verbatim to the opponent. + if (message.type === 'input' || message.type === 'checksum' || message.type === 'bye') { + for (const peer of lobby.others(session.roomId, session.index)) { + send(peer.socket, message); + } + } + }); + + socket.on('close', () => { + if (!session) { + return; + } + for (const peer of lobby.others(session.roomId, session.index)) { + send(peer.socket, { type: 'bye', reason: 'opponent disconnected' }); + } + lobby.leave(session.roomId, session.index); + }); +}); + +const pruneTimer = setInterval(() => { + lobby.pruneStale(STALE_ROOM_MS); +}, PRUNE_INTERVAL_MS); + +// HEALTH / PRESSURE: a heartbeat the host can scrape without a metrics stack. +const heartbeat = setInterval(() => { + process.stdout.write( + `${JSON.stringify({ + timestamp: new Date().toISOString(), + service: 'ascendants-relay', + level: 'info', + event: 'heartbeat', + data: { rooms: lobby.roomCount, players: lobby.playerCount }, + })}\n`, + ); +}, 30000); + +function shutdown(): void { + clearInterval(pruneTimer); + clearInterval(heartbeat); + server.close(); +} + +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +process.stdout.write(`ascendants relay listening on :${PORT}\n`); diff --git a/ascendants/server/lobby.ts b/ascendants/server/lobby.ts new file mode 100644 index 0000000..a251282 --- /dev/null +++ b/ascendants/server/lobby.ts @@ -0,0 +1,119 @@ +import type { WebSocket } from 'ws'; +import type { PlayerIndex } from '../src/net/protocol'; + +/** + * Room management. + * + * The server is a relay, not an authority: it never simulates the match. Both + * clients run the identical deterministic simulation and only exchange inputs, + * so the server's entire job is to pair two people, tell them which side they + * are on, and forward bytes. That keeps hosting costs near zero and means the + * server can never be the thing that desyncs a match. + */ + +export type Seat = { + socket: WebSocket; + index: PlayerIndex; + characterId: string; + alive: boolean; +}; + +export type Room = { + id: string; + seats: Seat[]; + seed: number; + stageId: string; + started: boolean; + createdAt: number; +}; + +export class Lobby { + private readonly rooms = new Map(); + + /** Seat a player, creating the room if needed. Null means the room is full. */ + join(roomId: string, socket: WebSocket, characterId: string): Seat | null { + let room = this.rooms.get(roomId); + if (!room) { + room = { + id: roomId, + seats: [], + // Seeded from the room so both clients agree without extra messages. + seed: hashString(roomId), + stageId: 'crater', + started: false, + createdAt: Date.now(), + }; + this.rooms.set(roomId, room); + } + if (room.seats.length >= 2) { + return null; + } + const seat: Seat = { + socket, + index: room.seats.length === 0 ? 0 : 1, + characterId, + alive: true, + }; + room.seats.push(seat); + return seat; + } + + get(roomId: string): Room | undefined { + return this.rooms.get(roomId); + } + + /** Everyone in the room except the given seat — the relay target. */ + others(roomId: string, index: PlayerIndex): Seat[] { + const room = this.rooms.get(roomId); + return room ? room.seats.filter((s) => s.index !== index && s.alive) : []; + } + + leave(roomId: string, index: PlayerIndex): void { + const room = this.rooms.get(roomId); + if (!room) { + return; + } + for (const seat of room.seats) { + if (seat.index === index) { + seat.alive = false; + } + } + if (room.seats.every((s) => !s.alive)) { + this.rooms.delete(roomId); + } + } + + get roomCount(): number { + return this.rooms.size; + } + + get playerCount(): number { + let total = 0; + for (const room of this.rooms.values()) { + total += room.seats.filter((s) => s.alive).length; + } + return total; + } + + /** Drop rooms nobody ever finished joining, so abandoned ids cannot pile up. */ + pruneStale(maxAgeMs: number): number { + const cutoff = Date.now() - maxAgeMs; + let removed = 0; + for (const [id, room] of this.rooms) { + if (!room.started && room.createdAt < cutoff && room.seats.length < 2) { + this.rooms.delete(id); + removed++; + } + } + return removed; + } +} + +/** Deterministic 32-bit hash so both clients derive the same match seed. */ +export function hashString(value: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < value.length; i++) { + hash = Math.imul(hash ^ value.charCodeAt(i), 0x01000193) >>> 0; + } + return hash; +} diff --git a/ascendants/src/client/input.ts b/ascendants/src/client/input.ts new file mode 100644 index 0000000..521d8ab --- /dev/null +++ b/ascendants/src/client/input.ts @@ -0,0 +1,169 @@ +import { Button, type PlayerInput } from '../engine/types/input'; + +/** + * Turning hardware into a frame of input. + * + * The simulation wants directions in TARGET-RELATIVE numpad notation — 6 is + * always toward the opponent. The player, however, is thinking in screen space: + * they press right because the opponent is on the right. Translating here, at + * the edge of the system, is what keeps every motion input working unchanged + * when the fighters cross over, and keeps the engine ignorant of the camera. + */ + +export type Binding = { + readonly left: string[]; + readonly right: string[]; + readonly up: string[]; + readonly down: string[]; + readonly light: string[]; + readonly medium: string[]; + readonly heavy: string[]; + readonly ki: string[]; + readonly guard: string[]; + readonly drive: string[]; + readonly dash: string[]; + readonly charge: string[]; + readonly vanish: string[]; +}; + +export const PLAYER_ONE_BINDING: Binding = { + left: ['KeyA'], + right: ['KeyD'], + up: ['KeyW'], + down: ['KeyS'], + light: ['KeyJ'], + medium: ['KeyK'], + heavy: ['KeyL'], + ki: ['KeyU'], + guard: ['Space'], + drive: ['KeyI'], + dash: ['ShiftLeft'], + charge: ['KeyC'], + vanish: ['KeyV'], +}; + +export const PLAYER_TWO_BINDING: Binding = { + left: ['ArrowLeft'], + right: ['ArrowRight'], + up: ['ArrowUp'], + down: ['ArrowDown'], + light: ['Numpad1'], + medium: ['Numpad2'], + heavy: ['Numpad3'], + ki: ['Numpad4'], + guard: ['Numpad0'], + drive: ['Numpad5'], + dash: ['ShiftRight'], + charge: ['Numpad7'], + vanish: ['Numpad8'], +}; + +/** + * Combine a horizontal and vertical axis into numpad notation. + * @param toward +1 when screen-right means "toward the opponent", -1 otherwise + */ +export function toNumpad(screenX: number, screenY: number, toward: number): number { + const forward = Math.sign(screenX) * toward; + const vertical = Math.sign(screenY); + if (vertical > 0) { + return forward > 0 ? 9 : forward < 0 ? 7 : 8; + } + if (vertical < 0) { + return forward > 0 ? 3 : forward < 0 ? 1 : 2; + } + return forward > 0 ? 6 : forward < 0 ? 4 : 5; +} + +export class KeyboardReader { + private readonly held = new Set(); + private readonly onDown = (event: KeyboardEvent): void => { + // Stop the browser scrolling the page out from under the match. + if (event.code.startsWith('Arrow') || event.code === 'Space') { + event.preventDefault(); + } + this.held.add(event.code); + }; + private readonly onUp = (event: KeyboardEvent): void => { + this.held.delete(event.code); + }; + private readonly onBlur = (): void => { + // Losing focus with a key down would otherwise leave it stuck forever. + this.held.clear(); + }; + + attach(target: Window = window): void { + target.addEventListener('keydown', this.onDown); + target.addEventListener('keyup', this.onUp); + target.addEventListener('blur', this.onBlur); + } + + detach(target: Window = window): void { + target.removeEventListener('keydown', this.onDown); + target.removeEventListener('keyup', this.onUp); + target.removeEventListener('blur', this.onBlur); + } + + isDown(codes: readonly string[]): boolean { + return codes.some((code) => this.held.has(code)); + } + + read(binding: Binding, toward: number): PlayerInput { + const screenX = (this.isDown(binding.right) ? 1 : 0) - (this.isDown(binding.left) ? 1 : 0); + const screenY = (this.isDown(binding.up) ? 1 : 0) - (this.isDown(binding.down) ? 1 : 0); + + let buttons = 0; + if (this.isDown(binding.light)) buttons |= Button.Light; + if (this.isDown(binding.medium)) buttons |= Button.Medium; + if (this.isDown(binding.heavy)) buttons |= Button.Heavy; + if (this.isDown(binding.ki)) buttons |= Button.Ki; + if (this.isDown(binding.guard)) buttons |= Button.Guard; + if (this.isDown(binding.drive)) buttons |= Button.Drive; + if (this.isDown(binding.dash)) buttons |= Button.Dash; + if (this.isDown(binding.charge)) buttons |= Button.Charge; + if (this.isDown(binding.vanish)) buttons |= Button.Vanish; + + return { dir: toNumpad(screenX, screenY, toward), buttons }; + } +} + +const STICK_DEADZONE = 0.35; + +/** + * Read a connected gamepad, mapped face-button-per-attack in the arcade + * convention. Returns null when no pad is present so the caller can fall back + * to the keyboard without a mode switch. + */ +export function readGamepad(index: number, toward: number): PlayerInput | null { + const pads = typeof navigator !== 'undefined' ? navigator.getGamepads?.() : null; + const pad = pads?.[index]; + if (!pad) { + return null; + } + + const axisX = pad.axes[0] ?? 0; + const axisY = pad.axes[1] ?? 0; + const dpadRight = pad.buttons[15]?.pressed ? 1 : 0; + const dpadLeft = pad.buttons[14]?.pressed ? 1 : 0; + const dpadUp = pad.buttons[12]?.pressed ? 1 : 0; + const dpadDown = pad.buttons[13]?.pressed ? 1 : 0; + + const screenX = + dpadRight - dpadLeft || (Math.abs(axisX) > STICK_DEADZONE ? Math.sign(axisX) : 0); + // Gamepad Y is inverted relative to screen up. + const screenY = + dpadUp - dpadDown || (Math.abs(axisY) > STICK_DEADZONE ? -Math.sign(axisY) : 0); + + const pressed = (i: number): boolean => pad.buttons[i]?.pressed === true; + let buttons = 0; + if (pressed(2)) buttons |= Button.Light; + if (pressed(3)) buttons |= Button.Medium; + if (pressed(1)) buttons |= Button.Heavy; + if (pressed(0)) buttons |= Button.Ki; + if (pressed(6)) buttons |= Button.Guard; + if (pressed(4)) buttons |= Button.Drive; + if (pressed(5)) buttons |= Button.Dash; + if (pressed(7)) buttons |= Button.Charge; + if (pressed(10)) buttons |= Button.Vanish; + + return { dir: toNumpad(screenX, screenY, toward), buttons }; +} diff --git a/ascendants/src/client/loop.ts b/ascendants/src/client/loop.ts new file mode 100644 index 0000000..fc184bd --- /dev/null +++ b/ascendants/src/client/loop.ts @@ -0,0 +1,105 @@ +import { TICK_MS } from '../engine/data/constants'; + +/** + * Fixed-timestep game loop. + * + * The simulation must advance in whole 60Hz frames regardless of the display's + * refresh rate — frame data is meaningless otherwise, and rollback requires + * both peers to count frames the same way. Rendering runs at whatever rate the + * browser offers; only the simulation is pinned. + * + * The accumulator is capped so a backgrounded tab that wakes with a two-second + * debt does not try to simulate 120 frames in one go and lock the page. Time + * beyond the cap is discarded, which shows up as a brief skip rather than a + * freeze. + */ + +const MAX_STEPS_PER_FRAME = 5; +const MAX_ACCUMULATED_MS = TICK_MS * 8; + +export type LoopTelemetry = { + /** HEALTH: rendered frames per second, smoothed. */ + fps: number; + /** PRESSURE: simulation steps taken in the last rendered frame. */ + stepsLastFrame: number; + /** EFFICIENCY: simulation time discarded because the tab fell behind. */ + droppedMs: number; + totalSteps: number; +}; + +export type LoopCallbacks = { + /** Advance one simulation frame. Return false to stall (e.g. awaiting a peer). */ + tick: () => boolean; + render: (deltaSeconds: number) => void; +}; + +export class GameLoop { + private running = false; + private handle = 0; + private lastTime = 0; + private accumulator = 0; + + readonly telemetry: LoopTelemetry = { + fps: 0, + stepsLastFrame: 0, + droppedMs: 0, + totalSteps: 0, + }; + + constructor(private readonly callbacks: LoopCallbacks) {} + + start(): void { + if (this.running) { + return; + } + this.running = true; + this.lastTime = performance.now(); + this.accumulator = 0; + this.handle = requestAnimationFrame(this.frame); + } + + stop(): void { + this.running = false; + cancelAnimationFrame(this.handle); + } + + get isRunning(): boolean { + return this.running; + } + + private readonly frame = (now: number): void => { + if (!this.running) { + return; + } + this.handle = requestAnimationFrame(this.frame); + + const elapsed = now - this.lastTime; + this.lastTime = now; + + if (elapsed > 0) { + // Smoothed so a single long frame does not make the readout jump. + this.telemetry.fps += (1000 / elapsed - this.telemetry.fps) * 0.1; + } + + this.accumulator += elapsed; + if (this.accumulator > MAX_ACCUMULATED_MS) { + this.telemetry.droppedMs += this.accumulator - MAX_ACCUMULATED_MS; + this.accumulator = MAX_ACCUMULATED_MS; + } + + let steps = 0; + while (this.accumulator >= TICK_MS && steps < MAX_STEPS_PER_FRAME) { + if (!this.callbacks.tick()) { + // Stalled waiting on something external; keep the debt for next frame + // rather than silently dropping frames the opponent will send. + break; + } + this.accumulator -= TICK_MS; + steps++; + } + + this.telemetry.stepsLastFrame = steps; + this.telemetry.totalSteps += steps; + this.callbacks.render(elapsed / 1000); + }; +} diff --git a/ascendants/src/client/main.ts b/ascendants/src/client/main.ts new file mode 100644 index 0000000..4c83f24 --- /dev/null +++ b/ascendants/src/client/main.ts @@ -0,0 +1,155 @@ +import { ROUND_TIME_FRAMES, ROUNDS_TO_WIN } from '../engine/data/constants'; +import type { MatchConfig } from '../engine/types/match'; +import { NEUTRAL_INPUT, type PlayerInput } from '../engine/types/input'; +import { MatchView } from '../render/view'; +import { Hud } from '../ui/hud'; +import { Menu, type MatchSetup } from '../ui/menu'; +import { WebSocketTransport } from '../net/transport'; +import { KeyboardReader, PLAYER_ONE_BINDING, PLAYER_TWO_BINDING, readGamepad } from './input'; +import { GameLoop } from './loop'; +import { LocalMatch, OnlineMatch, type MatchRunner } from './match'; + +/** + * Application bootstrap. + * + * Wires the menu to a match runner, a view and a loop, then gets out of the + * way. Everything meaningful lives in the modules this file composes — if this + * file starts growing logic, that logic belongs somewhere else. + */ + +/** Overridable at build time so a deployed client can point at a real relay. */ +const RELAY_URL = import.meta.env.VITE_RELAY_URL ?? 'ws://localhost:8787'; + +function element(selector: string): T { + const found = document.querySelector(selector); + if (!found) { + throw new Error(`Missing required element: ${selector}`); + } + return found; +} + +function configFor(setup: MatchSetup, seed: number): MatchConfig { + return { + roundsToWin: ROUNDS_TO_WIN, + roundTimeFrames: ROUND_TIME_FRAMES, + stageId: 'crater', + seed, + characterIds: setup.characterIds, + }; +} + +/** + * Online play cannot start until the server has paired both players and told + * each which side they are on, so the transport is opened and awaited here. + */ +async function startOnline( + setup: MatchSetup & { mode: { kind: 'online'; roomId: string } }, + menu: Menu, +): Promise<{ runner: OnlineMatch; characterIds: [string, string]; localIndex: 0 | 1 }> { + menu.status('CONNECTING…'); + const transport = new WebSocketTransport(RELAY_URL); + await transport.connect(); + + menu.status('WAITING FOR OPPONENT…'); + return new Promise((resolve, reject) => { + let assignedIndex: 0 | 1 = 0; + + transport.onMessage((message) => { + if (message.type === 'assign') { + assignedIndex = message.playerIndex; + return; + } + if (message.type === 'start') { + const runner = new OnlineMatch( + configFor({ ...setup, characterIds: message.characterIds }, message.seed), + assignedIndex, + transport, + setup.mode.roomId, + setup.characterIds[0], + ); + resolve({ runner, characterIds: message.characterIds, localIndex: assignedIndex }); + return; + } + if (message.type === 'error') { + reject(new Error(message.reason)); + } + }); + + transport.send({ + type: 'hello', + version: 1, + roomId: setup.mode.roomId, + characterId: setup.characterIds[0], + }); + }); +} + +async function main(): Promise { + const canvas = element('#stage'); + const hudRoot = element('#hud'); + const menuRoot = element('#menu'); + + const keyboard = new KeyboardReader(); + keyboard.attach(); + + const menu = new Menu(menuRoot); + const setup = await menu.run(); + + let runner: MatchRunner; + let characterIds: [string, string] = setup.characterIds; + let localIndex: 0 | 1 = setup.localIndex; + let online: OnlineMatch | null = null; + + if (setup.mode.kind === 'online') { + const result = await startOnline( + setup as MatchSetup & { mode: { kind: 'online'; roomId: string } }, + menu, + ); + runner = result.runner; + online = result.runner; + characterIds = result.characterIds; + localIndex = result.localIndex; + } else { + runner = new LocalMatch(configFor(setup, 0x5eed), setup.mode, 0); + } + + menu.hide(); + + const view = new MatchView(canvas, characterIds); + const hud = new Hud(hudRoot); + window.addEventListener('resize', () => view.resize()); + + const readLocal = (): PlayerInput => { + const toward = view.towardSign(runner.state, localIndex); + return readGamepad(0, toward) ?? keyboard.read(PLAYER_ONE_BINDING, toward); + }; + + const readSecond = (): PlayerInput => { + if (setup.mode.kind !== 'versus') { + return NEUTRAL_INPUT; + } + const toward = view.towardSign(runner.state, localIndex === 0 ? 1 : 0); + return readGamepad(1, toward) ?? keyboard.read(PLAYER_TWO_BINDING, toward); + }; + + const loop = new GameLoop({ + tick: () => runner.advance(readLocal(), readSecond()), + render: (deltaSeconds) => { + const state = runner.state; + view.render(state, runner.drainEffects(), deltaSeconds); + hud.update(state, { + netLine: online + ? `${online.rttMs < 0 ? '--' : online.rttMs}ms · rollbacks ${online.telemetry.rollbacks} · ` + + `desyncs ${online.telemetry.desyncs}${online.isDisconnected ? ` · ${online.reason}` : ''}` + : `${loop.telemetry.fps.toFixed(0)} fps`, + }); + }, + }); + + loop.start(); +} + +void main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + document.body.innerHTML = `

ASCENDANTS

${message}

`; +}); diff --git a/ascendants/src/client/match.ts b/ascendants/src/client/match.ts new file mode 100644 index 0000000..5134e00 --- /dev/null +++ b/ascendants/src/client/match.ts @@ -0,0 +1,203 @@ +import { cpuInput, type Difficulty } from '../engine/ai/cpu'; +import { createMatch } from '../engine/sim/init'; +import { step } from '../engine/sim/step'; +import { NEUTRAL_INPUT, packInput, unpackInput, type FrameInputs, type PlayerInput } from '../engine/types/input'; +import type { EffectEvent, MatchConfig, MatchState } from '../engine/types/match'; +import { RollbackSession } from '../net/rollback'; +import { PROTOCOL_VERSION } from '../net/protocol'; +import type { Transport } from '../net/transport'; + +/** + * Match runners. + * + * Offline and online play differ only in where the second player's input comes + * from, so they share one interface and the game loop never branches on mode. + * Offline deliberately skips the rollback session entirely — with both inputs + * known there is nothing to predict, and paying for snapshots would be waste. + */ + +export interface MatchRunner { + readonly state: MatchState; + readonly frame: number; + /** Effects produced since the last call; draining keeps the buffer bounded. */ + drainEffects(): EffectEvent[]; + advance(localInput: PlayerInput, secondInput?: PlayerInput): boolean; + dispose(): void; +} + +const MAX_BUFFERED_EFFECTS = 256; + +abstract class BaseRunner { + protected effects: EffectEvent[] = []; + + protected collect(state: MatchState): void { + if (state.effects.length === 0) { + return; + } + this.effects.push(...state.effects); + if (this.effects.length > MAX_BUFFERED_EFFECTS) { + this.effects.splice(0, this.effects.length - MAX_BUFFERED_EFFECTS); + } + } + + drainEffects(): EffectEvent[] { + const drained = this.effects; + this.effects = []; + return drained; + } +} + +export type LocalMode = { kind: 'cpu'; difficulty: Difficulty } | { kind: 'versus' }; + +export class LocalMatch extends BaseRunner implements MatchRunner { + state: MatchState; + private currentFrame = 0; + + constructor( + private readonly config: MatchConfig, + private readonly mode: LocalMode, + private readonly localIndex: 0 | 1 = 0, + ) { + super(); + this.state = createMatch(config); + } + + get frame(): number { + return this.currentFrame; + } + + advance(localInput: PlayerInput, secondInput: PlayerInput = NEUTRAL_INPUT): boolean { + const remoteIndex = this.localIndex === 0 ? 1 : 0; + const remote = + this.mode.kind === 'cpu' + ? cpuInput(this.state, remoteIndex, this.mode.difficulty) + : secondInput; + + const inputs: FrameInputs = + this.localIndex === 0 ? [localInput, remote] : [remote, localInput]; + + this.state = step(this.state, inputs, this.config); + this.collect(this.state); + this.currentFrame += 1; + return true; + } + + dispose(): void { + this.effects = []; + } +} + +const INPUT_REDUNDANCY = 6; + +export class OnlineMatch extends BaseRunner implements MatchRunner { + private readonly session: RollbackSession; + private disconnected = false; + private disconnectReason = ''; + + constructor( + config: MatchConfig, + private readonly localIndex: 0 | 1, + private readonly transport: Transport, + roomId: string, + characterId: string, + ) { + super(); + this.session = new RollbackSession(config, localIndex); + + this.transport.onMessage((message) => { + if (message.type === 'input') { + // Messages carry a window of frames; the session ignores anything it + // has already confirmed, so replays are free. + const oldest = message.frame - message.packed.length + 1; + message.packed.forEach((packed, offset) => { + this.session.addRemoteInput(oldest + offset, unpackInput(packed)); + }); + return; + } + if (message.type === 'checksum') { + this.session.verifyChecksum(message.frame, message.value); + return; + } + if (message.type === 'bye' || message.type === 'error') { + this.disconnected = true; + this.disconnectReason = message.reason; + } + }); + + this.transport.onClose((reason) => { + this.disconnected = true; + this.disconnectReason = reason; + }); + + this.transport.send({ + type: 'hello', + version: PROTOCOL_VERSION, + roomId, + characterId, + }); + } + + get state(): MatchState { + return this.session.current; + } + + get frame(): number { + return this.session.frame; + } + + get isDisconnected(): boolean { + return this.disconnected; + } + + get reason(): string { + return this.disconnectReason; + } + + get telemetry(): RollbackSession['telemetry'] { + return this.session.telemetry; + } + + get rttMs(): number { + return this.transport.rttMs; + } + + /** Returns false when stalled waiting on the opponent. */ + advance(localInput: PlayerInput): boolean { + if (this.disconnected) { + return false; + } + const frame = this.session.frame; + this.session.addLocalInput(frame, localInput); + + if (!this.session.advance()) { + return false; + } + this.collect(this.session.current); + + const bundle = this.session.recentLocalInputs(INPUT_REDUNDANCY); + this.transport.send({ + type: 'input', + frame: bundle.frame, + packed: bundle.packed.map(packInput), + }); + + // Periodic hash exchange: a desync is caught in under a second rather than + // being noticed when the two screens visibly disagree. + if (frame % 30 === 0) { + const value = this.session.checksumAt(frame); + if (value !== undefined) { + this.transport.send({ type: 'checksum', frame, value }); + } + } + return true; + } + + dispose(): void { + this.effects = []; + this.transport.close('match ended'); + } + + get localPlayerIndex(): 0 | 1 { + return this.localIndex; + } +} diff --git a/ascendants/src/engine/ai/cpu.ts b/ascendants/src/engine/ai/cpu.ts new file mode 100644 index 0000000..b7e7aae --- /dev/null +++ b/ascendants/src/engine/ai/cpu.ts @@ -0,0 +1,147 @@ +import { FATAL_HEALTH_RATIO, KI_VANISH_COST, THROW_RANGE } from '../data/constants'; +import { getMove } from '../data/moves'; +import { getCharacter } from '../data/characters'; +import { nextFloat, seedRng } from '../math/rng'; +import { distanceXZ } from '../math/vec3'; +import { Button, NEUTRAL_INPUT, type PlayerInput } from '../types/input'; +import type { Fighter } from '../types/fighter'; +import type { MatchState } from '../types/match'; + +/** + * The CPU opponent. + * + * Deterministic by construction: it reads only the current state and draws + * randomness from a generator seeded by the frame number. That means the same + * frame always yields the same decision, so a rolled-back frame replays + * identically and the AI cannot desync a match. + * + * It plays by ranges rather than by scripts — what it does is chosen by how far + * away you are and what you are currently doing, which is roughly how a human + * decides too. + */ + +export type Difficulty = 'rookie' | 'fighter' | 'herald'; + +type Profile = { + /** Chance per frame of committing to an action at all. */ + readonly aggression: number; + /** Chance of blocking a threat it has recognised. */ + readonly defense: number; + /** Frames of deliberate delay before reacting, simulating human latency. */ + readonly reaction: number; + readonly usesVanish: boolean; + readonly usesSuper: boolean; +}; + +const PROFILES: Record = { + rookie: { aggression: 0.05, defense: 0.25, reaction: 16, usesVanish: false, usesSuper: false }, + fighter: { aggression: 0.11, defense: 0.6, reaction: 8, usesVanish: true, usesSuper: true }, + herald: { aggression: 0.2, defense: 0.9, reaction: 3, usesVanish: true, usesSuper: true }, +}; + +const CLOSE_RANGE = 2.0; +const MID_RANGE = 5.5; + +function rngFor(frame: number, index: number): ReturnType { + return seedRng(Math.imul(frame + 1, 2654435761) ^ Math.imul(index + 7, 40503)); +} + +/** Is the opponent committed to something the CPU could react to? */ +function opponentThreatening(opponent: Fighter): boolean { + if (opponent.state !== 'attack' || opponent.moveId === null) { + return false; + } + const move = getMove(opponent.moveId); + return opponent.moveFrame <= move.startup + move.active; +} + +function pick(dir: number, buttons: number): PlayerInput { + return { dir, buttons }; +} + +export function cpuInput( + state: MatchState, + index: 0 | 1, + difficulty: Difficulty = 'fighter', +): PlayerInput { + const self = state.fighters[index]; + const opponent = state.fighters[index === 0 ? 1 : 0]; + const profile = PROFILES[difficulty]; + + if (state.phase !== 'fight' && state.phase !== 'finisher') { + return NEUTRAL_INPUT; + } + + const rng = rngFor(state.frame, index); + const roll = nextFloat(rng); + const distance = distanceXZ(self.pos, opponent.pos); + + // Escape a combo rather than sit in it, when the resource allows. + if (profile.usesVanish && (self.state === 'hitstun' || self.state === 'launched')) { + if (self.ki >= KI_VANISH_COST && self.vanishCooldown === 0 && roll < 0.4) { + return pick(5, Button.Vanish); + } + return NEUTRAL_INPUT; + } + + // Reaction delay: below the profile's threshold the CPU simply has not + // noticed yet, which is what stops higher difficulties feeling precognitive. + const noticed = state.frame % profile.reaction === 0; + + if (opponentThreatening(opponent) && distance < MID_RANGE && noticed) { + if (roll < profile.defense) { + // Occasionally parry instead of blocking, for the Drive reward. + return roll < profile.defense * 0.25 ? pick(5, Button.Drive) : pick(4, Button.Guard); + } + } + + // Comeback tools take priority once they are available. + const desperate = self.health <= self.maxHealth * FATAL_HEALTH_RATIO; + if (desperate && !self.fatalUsed && distance < CLOSE_RANGE && roll < 0.25) { + return pick(6, Button.Drive | Button.Ki); + } + + if (profile.usesSuper && self.superMeter >= 3000 && distance < MID_RANGE && roll < 0.1) { + return superInput(self, state.frame); + } + + if (roll > profile.aggression) { + // Idle behaviour: hold ground, or charge ki when there is space to do it. + if (distance > MID_RANGE && nextFloat(rng) < 0.5) { + return pick(5, Button.Charge); + } + return distance > CLOSE_RANGE ? pick(6, 0) : pick(5, 0); + } + + if (distance <= THROW_RANGE && nextFloat(rng) < 0.15) { + return pick(5, Button.Light | Button.Medium); + } + if (distance <= CLOSE_RANGE) { + const choice = nextFloat(rng); + if (choice < 0.4) { + return pick(5, Button.Light); + } + if (choice < 0.7) { + return pick(5, Button.Medium); + } + return pick(2, Button.Heavy); + } + if (distance <= MID_RANGE) { + return nextFloat(rng) < 0.5 ? pick(6, Button.Dash) : pick(6, Button.Medium); + } + return nextFloat(rng) < 0.6 ? pick(5, Button.Ki) : pick(6, 0); +} + +/** + * Supers need their motion spread across several frames, so the CPU walks the + * double quarter-circle deterministically off the frame counter. + */ +function superInput(self: Fighter, frame: number): PlayerInput { + const sequence = [2, 3, 6, 2, 3, 6]; + const position = frame % (sequence.length + 1); + if (position === sequence.length) { + const character = getCharacter(self.characterId); + return pick(6, getMove(character.superMove).input.button); + } + return pick(sequence[position] ?? 5, 0); +} diff --git a/ascendants/src/engine/combat/apply.ts b/ascendants/src/engine/combat/apply.ts new file mode 100644 index 0000000..271fcbd --- /dev/null +++ b/ascendants/src/engine/combat/apply.ts @@ -0,0 +1,174 @@ +import { ARENA_RADIUS, JUGGLE_LIMIT, SUPER_GAIN_ON_TAKE, SUPER_GAIN_PER_DAMAGE, SUPER_MAX, TRANSFORM_DAMAGE_MULT } from '../data/constants'; +import type { CharacterStats } from '../data/characters'; +import { scale } from '../math/vec3'; +import type { Fighter } from '../types/fighter'; +import type { HitProps, Move } from '../types/move'; +import type { EffectEvent } from '../types/match'; +import { applyChip, applyDamage, computeDamage } from './damage'; +import { drainDrive, gainDrive } from './drive'; +import { gainKi } from './ki'; +import { forwardVector } from './hitbox'; + +/** Everything the resolver needs that is not on the fighters themselves. */ +export type CombatContext = { + readonly stats: readonly [CharacterStats, CharacterStats]; + readonly effects: EffectEvent[]; +}; + +const COUNTER_DAMAGE_MULT = 1.25; +const COUNTER_HITSTUN_BONUS = 6; +const ATTACKER_DRIVE_ON_HIT = 120; +const WALL_BOUNCE_MARGIN = 3; + +function pushApart(attacker: Fighter, defender: Fighter, amount: number): void { + const dir = forwardVector(attacker.facing); + const impulse = scale(dir, amount); + defender.vel = { + x: defender.vel.x + impulse.x, + y: defender.vel.y, + z: defender.vel.z + impulse.z, + }; +} + +function nearWall(fighter: Fighter): boolean { + const r = Math.sqrt(fighter.pos.x * fighter.pos.x + fighter.pos.z * fighter.pos.z); + return r > ARENA_RADIUS - WALL_BOUNCE_MARGIN; +} + +function addSuper(fighter: Fighter, amount: number): void { + fighter.superMeter = Math.min(SUPER_MAX, fighter.superMeter + amount); +} + +export function applyHit( + attacker: Fighter, + defender: Fighter, + move: Move, + props: HitProps, + ctx: CombatContext, + counter: boolean, +): void { + const attackerStats = ctx.stats[attacker.index]; + const defenderStats = ctx.stats[defender.index]; + const ignoreScaling = move.kind === 'super' || move.kind === 'fatal'; + + const base = props.damage * (counter ? COUNTER_DAMAGE_MULT : 1); + const dealt = applyDamage( + defender, + computeDamage({ + base, + hitIndex: defender.comboHits, + attackerStats, + defenderStats, + attackerTransformed: attacker.transformed, + transformMult: TRANSFORM_DAMAGE_MULT, + ignoreScaling, + }), + ); + + defender.comboHits += 1; + defender.comboDamage += dealt; + + // Juggles decay: once the limit is passed the victim simply falls out, + // which is what stops a good launcher from becoming an infinite. + const juggleExhausted = defender.juggle >= JUGGLE_LIMIT; + const hitstun = juggleExhausted + ? Math.floor(props.hitstun * 0.5) + : props.hitstun + (counter ? COUNTER_HITSTUN_BONUS : 0); + + defender.stun = hitstun; + defender.hitstop = props.hitstop; + attacker.hitstop = props.hitstop; + defender.moveId = null; + defender.moveFrame = 0; + defender.chain.length = 0; + defender.armor = 0; + + const launching = props.launch > 0 && !juggleExhausted; + if (launching) { + defender.vel = { x: defender.vel.x, y: props.launch / defenderStats.weight, z: defender.vel.z }; + defender.grounded = false; + defender.flying = false; + } + + if (!defender.grounded) { + defender.juggle += props.juggleCost; + } + + const knockdown = props.knockdown || juggleExhausted; + defender.state = launching || !defender.grounded ? 'launched' : knockdown ? 'knockdown' : 'hitstun'; + defender.stateFrame = 0; + + // Wall bounce only pays off with the opponent cornered — the reward for + // winning the positioning battle rather than a property of the button. + const bounce = props.wallBounce && nearWall(defender); + pushApart(attacker, defender, (props.pushback * (bounce ? -0.6 : 1)) / defenderStats.weight); + if (bounce) { + defender.stun += 14; + ctx.effects.push({ kind: 'launch', pos: defender.pos, power: 1, owner: attacker.index }); + } + + gainKi(attacker, props.kiGainOnHit); + gainKi(defender, props.kiGainOnHit * 0.5); + gainDrive(attacker, ATTACKER_DRIVE_ON_HIT); + addSuper(attacker, dealt * SUPER_GAIN_PER_DAMAGE); + addSuper(defender, dealt * SUPER_GAIN_ON_TAKE); + + ctx.effects.push({ + kind: counter ? 'launch' : 'impact', + pos: defender.pos, + power: Math.min(2, dealt / 80), + owner: attacker.index, + }); +} + +export function applyBlock( + attacker: Fighter, + defender: Fighter, + props: HitProps, + ctx: CombatContext, + burnoutBonus: number, +): void { + applyChip(defender, props.chipDamage); + defender.stun = props.blockstun + burnoutBonus; + defender.state = 'blockstun'; + defender.stateFrame = 0; + defender.hitstop = Math.floor(props.hitstop * 0.6); + attacker.hitstop = Math.floor(props.hitstop * 0.6); + + drainDrive(defender, props.driveDamageOnBlock); + gainKi(defender, props.kiGainOnBlock); + gainKi(attacker, props.kiGainOnBlock); + pushApart(attacker, defender, props.pushback * 1.15); + + ctx.effects.push({ kind: 'block', pos: defender.pos, power: 1, owner: defender.index }); +} + +/** A successful Drive Parry: no damage, no stun, and a full bar of Drive back. */ +export function applyParry( + attacker: Fighter, + defender: Fighter, + props: HitProps, + ctx: CombatContext, + driveGain: number, +): void { + gainDrive(defender, driveGain); + defender.hitstop = props.hitstop; + attacker.hitstop = props.hitstop + 4; + defender.stun = 0; + ctx.effects.push({ kind: 'parry', pos: defender.pos, power: 1.5, owner: defender.index }); +} + +/** Armor absorbs the hit: damage lands at a discount, the flow does not break. */ +export function applyArmor( + attacker: Fighter, + defender: Fighter, + props: HitProps, + ctx: CombatContext, +): void { + defender.armor -= 1; + applyDamage(defender, Math.round(props.damage * 0.35)); + defender.hitstop = props.hitstop; + attacker.hitstop = props.hitstop; + gainKi(defender, 4); + ctx.effects.push({ kind: 'block', pos: defender.pos, power: 1.4, owner: defender.index }); +} diff --git a/ascendants/src/engine/combat/damage.test.ts b/ascendants/src/engine/combat/damage.test.ts new file mode 100644 index 0000000..0d9b3b5 --- /dev/null +++ b/ascendants/src/engine/combat/damage.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { MIN_SCALING, SCALING_TABLE } from '../data/constants'; +import { getCharacter } from '../data/characters'; +import { createFighter } from '../sim/init'; +import { applyChip, applyDamage, comboScaling, computeDamage } from './damage'; + +const stats = getCharacter('vanta').stats; +const tank = getCharacter('korvath').stats; + +function ctx(overrides: Partial[0]> = {}) { + return computeDamage({ + base: 100, + hitIndex: 0, + attackerStats: stats, + defenderStats: stats, + attackerTransformed: false, + transformMult: 1.25, + ignoreScaling: false, + ...overrides, + }); +} + +describe('damage pipeline', () => { + it('applies no scaling to the first two hits', () => { + expect(comboScaling(0)).toBe(SCALING_TABLE[0]); + expect(comboScaling(1)).toBe(SCALING_TABLE[1]); + }); + + it('floors scaling for combos past the table', () => { + expect(comboScaling(50)).toBe(MIN_SCALING); + }); + + it('scales later hits down monotonically', () => { + let previous = Infinity; + for (let i = 0; i < 12; i++) { + const value = comboScaling(i); + expect(value).toBeLessThanOrEqual(previous); + previous = value; + } + }); + + it('reduces damage as a combo lengthens', () => { + expect(ctx({ hitIndex: 5 })).toBeLessThan(ctx({ hitIndex: 0 })); + }); + + it('increases damage while transformed', () => { + expect(ctx({ attackerTransformed: true })).toBeGreaterThan(ctx()); + }); + + it('reduces damage against a tankier defender', () => { + expect(ctx({ defenderStats: tank })).toBeLessThan(ctx()); + }); + + it('keeps supers meaningful deep into a combo', () => { + const late = ctx({ hitIndex: 9 }); + const lateSuper = ctx({ hitIndex: 9, ignoreScaling: true }); + expect(lateSuper).toBeGreaterThan(late); + }); + + it('always returns a whole number of at least one', () => { + const value = ctx({ base: 0.1, hitIndex: 9 }); + expect(Number.isInteger(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(1); + }); + + it('never drives health below zero', () => { + const fighter = createFighter(0, 'vanta'); + fighter.health = 30; + expect(applyDamage(fighter, 500)).toBe(30); + expect(fighter.health).toBe(0); + }); + + it('leaves chip damage one point short of a kill', () => { + const fighter = createFighter(0, 'vanta'); + fighter.health = 5; + applyChip(fighter, 100); + expect(fighter.health).toBe(1); + }); + + it('lets chip damage kill a fighter in burnout', () => { + const fighter = createFighter(0, 'vanta'); + fighter.health = 5; + fighter.burnout = 60; + applyChip(fighter, 100); + expect(fighter.health).toBe(0); + }); +}); diff --git a/ascendants/src/engine/combat/damage.ts b/ascendants/src/engine/combat/damage.ts new file mode 100644 index 0000000..9127d34 --- /dev/null +++ b/ascendants/src/engine/combat/damage.ts @@ -0,0 +1,63 @@ +import { MIN_SCALING, SCALING_TABLE } from '../data/constants'; +import type { CharacterStats } from '../data/characters'; +import type { Fighter } from '../types/fighter'; + +/** + * Damage pipeline. + * + * Order matters and is fixed: base damage, then combo scaling, then the + * attacker's power, then the defender's toughness, then transformation. Any + * other order lets two multipliers compound into numbers the balance table + * never predicted. + */ + +/** Scaling applied to the Nth hit of a combo (0-indexed). */ +export function comboScaling(hitIndex: number): number { + if (hitIndex < 0) { + return 1; + } + const value = SCALING_TABLE[hitIndex]; + return value ?? MIN_SCALING; +} + +export type DamageContext = { + readonly base: number; + readonly hitIndex: number; + readonly attackerStats: CharacterStats; + readonly defenderStats: CharacterStats; + readonly attackerTransformed: boolean; + readonly transformMult: number; + /** Supers and Fatal Blows ignore combo scaling below a floor. */ + readonly ignoreScaling: boolean; +}; + +export function computeDamage(ctx: DamageContext): number { + const scaling = ctx.ignoreScaling ? Math.max(comboScaling(ctx.hitIndex), 0.5) : comboScaling(ctx.hitIndex); + const power = ctx.attackerStats.damageMult * (ctx.attackerTransformed ? ctx.transformMult : 1); + const raw = ctx.base * scaling * power * ctx.defenderStats.defenseMult; + // Round to whole points so health is always an integer and two peers can + // never disagree by a fraction after a long combo. + return Math.max(1, Math.round(raw)); +} + +/** Chip damage cannot kill unless the defender is in Burnout (SF6 rule). */ +export function applyChip(defender: Fighter, chip: number): number { + if (chip <= 0) { + return 0; + } + const canKill = defender.burnout > 0; + const floor = canKill ? 0 : 1; + const applied = Math.min(chip, Math.max(0, defender.health - floor)); + defender.health -= applied; + return applied; +} + +/** Apply damage and clamp at zero. Returns the amount actually dealt. */ +export function applyDamage(defender: Fighter, amount: number): number { + const applied = Math.min(amount, defender.health); + defender.health -= applied; + if (defender.health < 0) { + defender.health = 0; + } + return applied; +} diff --git a/ascendants/src/engine/combat/drive.test.ts b/ascendants/src/engine/combat/drive.test.ts new file mode 100644 index 0000000..56ffb8e --- /dev/null +++ b/ascendants/src/engine/combat/drive.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { BURNOUT_FRAMES, DRIVE_MAX } from '../data/constants'; +import { getCharacter } from '../data/characters'; +import { createFighter } from '../sim/init'; +import { canSpendDrive, drainDrive, gainDrive, spendDrive, tickDrive } from './drive'; +import { gainKi, spendKi } from './ki'; + +const stats = getCharacter('vanta').stats; + +describe('drive gauge', () => { + it('starts full', () => { + expect(createFighter(0, 'vanta').drive).toBe(DRIVE_MAX); + }); + + it('spends when affordable and refuses when not', () => { + const fighter = createFighter(0, 'vanta'); + fighter.drive = 1500; + expect(spendDrive(fighter, 1000)).toBe(true); + expect(fighter.drive).toBe(500); + expect(spendDrive(fighter, 1000)).toBe(false); + expect(fighter.drive).toBe(500); + }); + + it('enters burnout when the gauge is emptied', () => { + const fighter = createFighter(0, 'vanta'); + fighter.drive = 1000; + spendDrive(fighter, 1000); + expect(fighter.burnout).toBe(BURNOUT_FRAMES); + expect(fighter.drive).toBe(0); + }); + + it('enters burnout when chipped to empty on block', () => { + const fighter = createFighter(0, 'vanta'); + fighter.drive = 100; + drainDrive(fighter, 200); + expect(fighter.burnout).toBeGreaterThan(0); + }); + + it('locks out every drive option during burnout', () => { + const fighter = createFighter(0, 'vanta'); + fighter.drive = 1000; + spendDrive(fighter, 1000); + fighter.drive = DRIVE_MAX; + expect(canSpendDrive(fighter, 1000)).toBe(false); + }); + + it('recovers to a full gauge when burnout expires', () => { + const fighter = createFighter(0, 'vanta'); + fighter.drive = 1000; + spendDrive(fighter, 1000); + for (let i = 0; i < BURNOUT_FRAMES + 1; i++) { + tickDrive(fighter, stats); + } + expect(fighter.burnout).toBe(0); + expect(fighter.drive).toBe(DRIVE_MAX); + }); + + it('never regenerates past the cap', () => { + const fighter = createFighter(0, 'vanta'); + gainDrive(fighter, 99999); + expect(fighter.drive).toBe(DRIVE_MAX); + }); +}); + +describe('ki gauge', () => { + it('refuses a spend it cannot afford', () => { + const fighter = createFighter(0, 'vanta'); + fighter.ki = 10; + expect(spendKi(fighter, 25)).toBe(false); + expect(fighter.ki).toBe(10); + }); + + it('caps at the maximum', () => { + const fighter = createFighter(0, 'vanta'); + gainKi(fighter, 9999); + expect(fighter.ki).toBe(100); + }); +}); diff --git a/ascendants/src/engine/combat/drive.ts b/ascendants/src/engine/combat/drive.ts new file mode 100644 index 0000000..9757e8e --- /dev/null +++ b/ascendants/src/engine/combat/drive.ts @@ -0,0 +1,74 @@ +import { + BURNOUT_FRAMES, + DRIVE_MAX, + DRIVE_REGEN, + DRIVE_REGEN_BURNOUT, +} from '../data/constants'; +import type { CharacterStats } from '../data/characters'; +import type { Fighter } from '../types/fighter'; + +/** + * The Drive gauge (Street Fighter 6's system, adopted wholesale). + * + * Drive is the risk resource: it pays for Impact, Parry, Rush and Overdrive, + * and blocking burns it. Running it to zero triggers Burnout, where the + * fighter loses every drive option, takes chip damage that can kill, and + * suffers longer blockstun. That is the pressure valve the whole neutral game + * is built around, so it is modelled exactly rather than approximated. + */ + +export function canSpendDrive(fighter: Fighter, amount: number): boolean { + return fighter.burnout <= 0 && fighter.drive >= amount; +} + +export function spendDrive(fighter: Fighter, amount: number): boolean { + if (!canSpendDrive(fighter, amount)) { + return false; + } + fighter.drive -= amount; + if (fighter.drive <= 0) { + enterBurnout(fighter); + } + return true; +} + +/** Drive lost passively — chip on block, parry upkeep. Can force Burnout. */ +export function drainDrive(fighter: Fighter, amount: number): void { + if (fighter.burnout > 0) { + return; + } + fighter.drive -= amount; + if (fighter.drive <= 0) { + enterBurnout(fighter); + } +} + +export function gainDrive(fighter: Fighter, amount: number): void { + if (fighter.burnout > 0) { + return; + } + fighter.drive = Math.min(DRIVE_MAX, fighter.drive + amount); +} + +export function enterBurnout(fighter: Fighter): void { + fighter.drive = 0; + fighter.burnout = BURNOUT_FRAMES; +} + +/** + * Advance drive recovery by one frame. In Burnout the gauge refills on a timer + * and the fighter only escapes once it is full again — recovery is faster than + * normal regeneration precisely so Burnout is a bad minute, not a lost round. + */ +export function tickDrive(fighter: Fighter, stats: CharacterStats): void { + if (fighter.burnout > 0) { + fighter.burnout -= 1; + fighter.drive = Math.min(DRIVE_MAX, fighter.drive + DRIVE_REGEN_BURNOUT); + if (fighter.burnout <= 0) { + fighter.burnout = 0; + fighter.drive = DRIVE_MAX; + } + return; + } + fighter.drive = Math.min(DRIVE_MAX, fighter.drive + DRIVE_REGEN * stats.driveRegenMult); +} diff --git a/ascendants/src/engine/combat/hitbox.test.ts b/ascendants/src/engine/combat/hitbox.test.ts new file mode 100644 index 0000000..b71683f --- /dev/null +++ b/ascendants/src/engine/combat/hitbox.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { HALF_PI } from '../math/trig'; +import { vec3 } from '../math/vec3'; +import { createFighter } from '../sim/init'; +import { bodyCapsule, capsulesOverlap, forwardVector, hitboxCapsule, segmentDistanceSq, toWorld } from './hitbox'; + +const jab = { offset: vec3(0, 1.25, 0.55), radius: 0.42, length: 0.7, startFrame: 5, endFrame: 6 }; + +describe('capsule geometry', () => { + it('measures distance between parallel segments', () => { + const d = segmentDistanceSq(vec3(0, 0, 0), vec3(1, 0, 0), vec3(0, 2, 0), vec3(1, 2, 0)); + expect(d).toBeCloseTo(4, 9); + }); + + it('returns zero for crossing segments', () => { + const d = segmentDistanceSq(vec3(-1, 0, 0), vec3(1, 0, 0), vec3(0, -1, 0), vec3(0, 1, 0)); + expect(d).toBeCloseTo(0, 9); + }); + + it('handles degenerate points', () => { + const d = segmentDistanceSq(vec3(0, 0, 0), vec3(0, 0, 0), vec3(3, 4, 0), vec3(3, 4, 0)); + expect(d).toBeCloseTo(25, 9); + }); + + it('rotates a local offset a quarter turn', () => { + const world = toWorld(vec3(0, 0, 1), HALF_PI); + expect(world.x).toBeCloseTo(1, 6); + expect(world.z).toBeCloseTo(0, 6); + }); + + it('points the forward vector along +z at zero yaw', () => { + const f = forwardVector(0); + expect(f.z).toBeCloseTo(1, 9); + expect(f.x).toBeCloseTo(0, 9); + }); + + it('connects a jab against an adjacent opponent', () => { + const attacker = createFighter(0, 'vanta'); + const defender = createFighter(1, 'vanta'); + attacker.pos = vec3(0, 0, 0); + attacker.facing = 0; + defender.pos = vec3(0, 0, 1.4); + expect(capsulesOverlap(hitboxCapsule(attacker, jab), bodyCapsule(defender))).toBe(true); + }); + + it('whiffs the same jab at range', () => { + const attacker = createFighter(0, 'vanta'); + const defender = createFighter(1, 'vanta'); + attacker.pos = vec3(0, 0, 0); + attacker.facing = 0; + defender.pos = vec3(0, 0, 4); + expect(capsulesOverlap(hitboxCapsule(attacker, jab), bodyCapsule(defender))).toBe(false); + }); + + it('whiffs against an opponent standing behind the attacker', () => { + const attacker = createFighter(0, 'vanta'); + const defender = createFighter(1, 'vanta'); + attacker.pos = vec3(0, 0, 0); + attacker.facing = 0; + defender.pos = vec3(0, 0, -1.4); + expect(capsulesOverlap(hitboxCapsule(attacker, jab), bodyCapsule(defender))).toBe(false); + }); + + it('whiffs against an opponent far overhead', () => { + const attacker = createFighter(0, 'vanta'); + const defender = createFighter(1, 'vanta'); + attacker.pos = vec3(0, 0, 0); + attacker.facing = 0; + defender.pos = vec3(0, 6, 1.2); + expect(capsulesOverlap(hitboxCapsule(attacker, jab), bodyCapsule(defender))).toBe(false); + }); +}); diff --git a/ascendants/src/engine/combat/hitbox.ts b/ascendants/src/engine/combat/hitbox.ts new file mode 100644 index 0000000..bc30bcc --- /dev/null +++ b/ascendants/src/engine/combat/hitbox.ts @@ -0,0 +1,105 @@ +import { cos, sin } from '../math/trig'; +import { add, scale, sub, vec3, type Vec3 } from '../math/vec3'; +import { FIGHTER_HEIGHT, FIGHTER_RADIUS, type Fighter } from '../types/fighter'; +import type { Hitbox } from '../types/move'; + +/** + * Capsule collision. + * + * Every collider in the game — a limb's hitbox, a body's hurtbox, a beam — is a + * capsule, so there is exactly one intersection test to get right and it is + * cheap: the distance between two segments compared against summed radii. + */ + +export type Capsule = { a: Vec3; b: Vec3; radius: number }; + +/** Rotate a fighter-local offset into world space (yaw only; +z is forward). */ +export function toWorld(local: Vec3, facing: number): Vec3 { + const s = sin(facing); + const c = cos(facing); + return vec3(local.x * c + local.z * s, local.y, -local.x * s + local.z * c); +} + +export function forwardVector(facing: number): Vec3 { + return vec3(sin(facing), 0, cos(facing)); +} + +/** Place a move's local hitbox into world space for the given fighter. */ +export function hitboxCapsule(fighter: Fighter, boxDef: Hitbox): Capsule { + const a = add(fighter.pos, toWorld(boxDef.offset, fighter.facing)); + const b = add(a, scale(forwardVector(fighter.facing), boxDef.length)); + return { a, b, radius: boxDef.radius }; +} + +/** A fighter's hurtbox: an upright capsule spanning their body. */ +export function bodyCapsule(fighter: Fighter): Capsule { + const foot = vec3(fighter.pos.x, fighter.pos.y + FIGHTER_RADIUS, fighter.pos.z); + const head = vec3(fighter.pos.x, fighter.pos.y + FIGHTER_HEIGHT - FIGHTER_RADIUS, fighter.pos.z); + return { a: foot, b: head, radius: FIGHTER_RADIUS }; +} + +export function sphereCapsule(center: Vec3, radius: number): Capsule { + return { a: center, b: center, radius }; +} + +function clamp01(v: number): number { + return v < 0 ? 0 : v > 1 ? 1 : v; +} + +/** + * Squared distance between two segments (Ericson, Real-Time Collision + * Detection). Kept squared so the common case never pays for a sqrt. + */ +export function segmentDistanceSq(p1: Vec3, q1: Vec3, p2: Vec3, q2: Vec3): number { + const d1 = sub(q1, p1); + const d2 = sub(q2, p2); + const r = sub(p1, p2); + const a = d1.x * d1.x + d1.y * d1.y + d1.z * d1.z; + const e = d2.x * d2.x + d2.y * d2.y + d2.z * d2.z; + const f = d2.x * r.x + d2.y * r.y + d2.z * r.z; + const EPS = 1e-9; + + let s: number; + let t: number; + + if (a <= EPS && e <= EPS) { + return r.x * r.x + r.y * r.y + r.z * r.z; + } + if (a <= EPS) { + s = 0; + t = clamp01(f / e); + } else { + const c = d1.x * r.x + d1.y * r.y + d1.z * r.z; + if (e <= EPS) { + t = 0; + s = clamp01(-c / a); + } else { + const b = d1.x * d2.x + d1.y * d2.y + d1.z * d2.z; + const denom = a * e - b * b; + s = denom !== 0 ? clamp01((b * f - c * e) / denom) : 0; + t = (b * s + f) / e; + if (t < 0) { + t = 0; + s = clamp01(-c / a); + } else if (t > 1) { + t = 1; + s = clamp01((b - c) / a); + } + } + } + + const c1 = add(p1, scale(d1, s)); + const c2 = add(p2, scale(d2, t)); + const diff = sub(c1, c2); + return diff.x * diff.x + diff.y * diff.y + diff.z * diff.z; +} + +export function capsulesOverlap(x: Capsule, y: Capsule): boolean { + const reach = x.radius + y.radius; + return segmentDistanceSq(x.a, x.b, y.a, y.b) <= reach * reach; +} + +/** Midpoint of a capsule — used as the spawn point for impact effects. */ +export function capsuleCenter(c: Capsule): Vec3 { + return vec3((c.a.x + c.b.x) / 2, (c.a.y + c.b.y) / 2, (c.a.z + c.b.z) / 2); +} diff --git a/ascendants/src/engine/combat/ki.ts b/ascendants/src/engine/combat/ki.ts new file mode 100644 index 0000000..7f2ec0f --- /dev/null +++ b/ascendants/src/engine/combat/ki.ts @@ -0,0 +1,42 @@ +import { KI_CHARGE_RATE, KI_MAX, KI_REGEN_PASSIVE } from '../data/constants'; +import type { CharacterStats } from '../data/characters'; +import type { Fighter } from '../types/fighter'; + +/** + * The Ki gauge (the Dragon Ball half of the system). + * + * Where Drive regenerates on its own and punishes overuse, Ki must be actively + * earned: standing still to charge is the only fast source, and standing still + * is exactly when you are most vulnerable. That tension is the reason the + * charge stance exists at all. + */ + +export function canSpendKi(fighter: Fighter, amount: number): boolean { + return fighter.ki >= amount; +} + +export function spendKi(fighter: Fighter, amount: number): boolean { + if (!canSpendKi(fighter, amount)) { + return false; + } + fighter.ki -= amount; + return true; +} + +export function gainKi(fighter: Fighter, amount: number): void { + fighter.ki = Math.min(KI_MAX, fighter.ki + amount); +} + +/** Trickle regeneration applied on every frame the fighter is not charging. */ +export function tickKi(fighter: Fighter, stats: CharacterStats): void { + gainKi(fighter, KI_REGEN_PASSIVE * stats.kiRegenMult); +} + +/** The charge stance: an order of magnitude faster, and completely defenceless. */ +export function tickCharge(fighter: Fighter, stats: CharacterStats): void { + gainKi(fighter, KI_CHARGE_RATE * stats.kiRegenMult); +} + +export function kiRatio(fighter: Fighter): number { + return fighter.ki / KI_MAX; +} diff --git a/ascendants/src/engine/combat/projectiles.ts b/ascendants/src/engine/combat/projectiles.ts new file mode 100644 index 0000000..8a8acba --- /dev/null +++ b/ascendants/src/engine/combat/projectiles.ts @@ -0,0 +1,205 @@ +import { ARENA_RADIUS, BURNOUT_BLOCKSTUN_BONUS, CLASH_FRAMES, CLASH_PUSH, DRIVE_PARRY_GAIN, DRIVE_PARRY_STARTUP } from '../data/constants'; +import { atan2, cos, sin, wrapAngle } from '../math/trig'; +import { add, normalize, scale, sub, vec3, type Vec3 } from '../math/vec3'; +import type { Fighter } from '../types/fighter'; +import type { MatchState, Projectile } from '../types/match'; +import type { ProjectileSpec } from '../types/move'; +import { applyBlock, applyParry, type CombatContext } from './apply'; +import { applyDamage } from './damage'; +import { bodyCapsule, capsulesOverlap, forwardVector, sphereCapsule, toWorld } from './hitbox'; +import { isInvulnerable } from './resolve'; + +/** + * Projectiles and beam clashes. + * + * Beams are projectiles with a high clash power rather than a separate entity + * type. That means the beam struggle — two supers meeting mid-screen and the + * players fighting over it — falls out of ordinary projectile collision instead + * of needing a bespoke system. + */ + +export function spawnProjectile( + state: MatchState, + owner: Fighter, + spec: ProjectileSpec, +): Projectile { + const origin = add(owner.pos, toWorld(spec.spawnOffset, owner.facing)); + const projectile: Projectile = { + id: state.nextProjectileId++, + owner: owner.index, + kind: spec.kind, + pos: origin, + vel: scale(forwardVector(owner.facing), spec.speed), + radius: spec.radius, + life: spec.lifetime, + homing: spec.homing, + clashPower: spec.clashPower, + pierce: spec.pierce, + hit: spec.hit, + clashWith: -1, + clashFrames: 0, + }; + state.projectiles.push(projectile); + return projectile; +} + +function steerToward(projectile: Projectile, target: Vec3, rate: number): void { + const speed = Math.sqrt( + projectile.vel.x * projectile.vel.x + projectile.vel.z * projectile.vel.z, + ); + if (speed === 0 || rate === 0) { + return; + } + const current = atan2(projectile.vel.x, projectile.vel.z); + const desired = atan2(target.x - projectile.pos.x, target.z - projectile.pos.z); + // Fold into [-PI, PI] so the projectile always turns the short way. Trig here + // must come from the deterministic module, never from Math. + const delta = wrapAngle(desired - current); + const step = delta > rate ? rate : delta < -rate ? -rate : delta; + const heading = current + step; + projectile.vel = vec3(sin(heading) * speed, projectile.vel.y, cos(heading) * speed); +} + +function outOfBounds(p: Projectile): boolean { + const r = Math.sqrt(p.pos.x * p.pos.x + p.pos.z * p.pos.z); + return r > ARENA_RADIUS + 4 || p.pos.y < -2 || p.pos.y > 40; +} + +/** Two opposing projectiles meeting head-on enter a contested clash. */ +function detectClashes(state: MatchState, ctx: CombatContext): void { + const list = state.projectiles; + for (let i = 0; i < list.length; i++) { + const a = list[i]; + if (!a || a.clashWith >= 0) { + continue; + } + for (let j = i + 1; j < list.length; j++) { + const b = list[j]; + if (!b || b.clashWith >= 0 || b.owner === a.owner) { + continue; + } + if (!capsulesOverlap(sphereCapsule(a.pos, a.radius), sphereCapsule(b.pos, b.radius))) { + continue; + } + a.clashWith = b.id; + b.clashWith = a.id; + ctx.effects.push({ kind: 'clash', pos: a.pos, power: 1, owner: a.owner }); + } + } +} + +/** + * Advance a live clash. Both projectiles hold position while the stronger one + * inches forward; when the contest times out the weaker is destroyed and the + * winner continues at reduced power. + */ +function tickClashes(state: MatchState, ctx: CombatContext): void { + const byId = new Map(state.projectiles.map((p) => [p.id, p])); + const destroyed = new Set(); + + for (const p of state.projectiles) { + if (p.clashWith < 0 || destroyed.has(p.id)) { + continue; + } + const other = byId.get(p.clashWith); + if (!other) { + p.clashWith = -1; + continue; + } + if (other.id < p.id) { + continue; + } + + p.clashFrames++; + other.clashFrames++; + const advantage = p.clashPower - other.clashPower; + const drift = scale(normalize(sub(other.pos, p.pos)), CLASH_PUSH * Math.sign(advantage)); + p.pos = add(p.pos, drift); + other.pos = add(other.pos, drift); + ctx.effects.push({ kind: 'clash', pos: p.pos, power: Math.abs(advantage) / 8, owner: p.owner }); + + if (p.clashFrames >= CLASH_FRAMES) { + if (advantage > 0) { + destroyed.add(other.id); + p.clashWith = -1; + p.clashPower *= 0.6; + } else if (advantage < 0) { + destroyed.add(p.id); + other.clashWith = -1; + other.clashPower *= 0.6; + } else { + destroyed.add(p.id); + destroyed.add(other.id); + } + } + } + + if (destroyed.size > 0) { + state.projectiles = state.projectiles.filter((p) => !destroyed.has(p.id)); + } +} + +function hitFighter( + state: MatchState, + projectile: Projectile, + defender: Fighter, + ctx: CombatContext, +): boolean { + if (isInvulnerable(defender, 'projectile')) { + return false; + } + const owner = state.fighters[projectile.owner]; + if (defender.state === 'parry' && defender.parryHold >= DRIVE_PARRY_STARTUP) { + applyParry(owner, defender, projectile.hit, ctx, DRIVE_PARRY_GAIN); + return true; + } + if (defender.state === 'guard' || defender.state === 'blockstun') { + applyBlock(owner, defender, projectile.hit, ctx, defender.burnout > 0 ? BURNOUT_BLOCKSTUN_BONUS : 0); + return true; + } + + applyDamage(defender, projectile.hit.damage); + defender.comboHits += 1; + defender.stun = projectile.hit.hitstun; + defender.hitstop = projectile.hit.hitstop; + defender.state = defender.grounded ? 'hitstun' : 'launched'; + defender.stateFrame = 0; + defender.moveId = null; + const push = scale(normalize(projectile.vel), projectile.hit.pushback); + defender.vel = { x: defender.vel.x + push.x, y: defender.vel.y, z: defender.vel.z + push.z }; + ctx.effects.push({ kind: 'impact', pos: defender.pos, power: 1.2, owner: projectile.owner }); + return true; +} + +export function tickProjectiles(state: MatchState, ctx: CombatContext): void { + detectClashes(state, ctx); + tickClashes(state, ctx); + + const survivors: Projectile[] = []; + for (const p of state.projectiles) { + if (p.clashWith < 0) { + const target = state.fighters[p.owner === 0 ? 1 : 0]; + if (p.homing > 0) { + steerToward(p, target.pos, p.homing); + } + p.pos = add(p.pos, p.vel); + p.life -= 1; + } + + if (p.life <= 0 || outOfBounds(p)) { + continue; + } + + const defender = state.fighters[p.owner === 0 ? 1 : 0]; + const connected = + defender.state !== 'defeated' && + capsulesOverlap(sphereCapsule(p.pos, p.radius), bodyCapsule(defender)) && + hitFighter(state, p, defender, ctx); + + if (connected && !p.pierce) { + continue; + } + survivors.push(p); + } + state.projectiles = survivors; +} diff --git a/ascendants/src/engine/combat/resolve.ts b/ascendants/src/engine/combat/resolve.ts new file mode 100644 index 0000000..7741e3c --- /dev/null +++ b/ascendants/src/engine/combat/resolve.ts @@ -0,0 +1,154 @@ +import { BURNOUT_BLOCKSTUN_BONUS, DRIVE_PARRY_GAIN, DRIVE_PARRY_STARTUP } from '../data/constants'; +import { getMove } from '../data/moves'; +import { dot, sub } from '../math/vec3'; +import type { Fighter } from '../types/fighter'; +import type { MatchState } from '../types/match'; +import type { Move } from '../types/move'; +import { applyArmor, applyBlock, applyHit, applyParry, type CombatContext } from './apply'; +import { bodyCapsule, capsulesOverlap, forwardVector, hitboxCapsule } from './hitbox'; + +/** + * Melee resolution: which active hitbox touched whom, and what that means. + * + * The outcome ladder is ordered by priority and is the same for every attack in + * the game — invulnerability, then armor, then parry, then block, then the + * counter-hit check. Keeping one ladder means a new move can never invent its + * own defensive exception. + */ + +export type HitOutcome = 'whiff' | 'hit' | 'counter' | 'block' | 'parry' | 'armor'; + +function isInvulnerable(fighter: Fighter, against: 'strike' | 'projectile'): boolean { + if (!fighter.moveId) { + return false; + } + const invuln = getMove(fighter.moveId).invuln; + if (!invuln || fighter.moveFrame < invuln.start || fighter.moveFrame > invuln.end) { + return false; + } + return invuln.kind === 'full' || invuln.kind === against; +} + +/** You cannot block what you cannot see — crossups and teleports beat guard. */ +function facingAttacker(defender: Fighter, attacker: Fighter): boolean { + const toAttacker = sub(attacker.pos, defender.pos); + return dot(forwardVector(defender.facing), toAttacker) > 0; +} + +function canBlock(defender: Fighter, attacker: Fighter): boolean { + const guarding = defender.state === 'guard' || defender.state === 'blockstun'; + return guarding && facingAttacker(defender, attacker); +} + +function isInStartup(fighter: Fighter): boolean { + if (fighter.state !== 'attack' || !fighter.moveId) { + return false; + } + return fighter.moveFrame <= getMove(fighter.moveId).startup; +} + +export function decideOutcome(attacker: Fighter, defender: Fighter, move: Move): HitOutcome { + if (isInvulnerable(defender, 'strike')) { + return 'whiff'; + } + if (move.hit.guardBreak) { + return isInStartup(defender) ? 'counter' : 'hit'; + } + if (defender.armor > 0) { + return 'armor'; + } + if (defender.state === 'parry' && defender.parryHold >= DRIVE_PARRY_STARTUP) { + return 'parry'; + } + if (canBlock(defender, attacker)) { + return 'block'; + } + return isInStartup(defender) ? 'counter' : 'hit'; +} + +/** + * Encode "this hitbox already touched this target" so multi-hit moves land once + * per active window rather than once per frame. + */ +function connectionKey(defenderIndex: number, hitboxIndex: number): number { + return defenderIndex * 100 + hitboxIndex; +} + +export function resolveMelee(state: MatchState, ctx: CombatContext): void { + for (const attacker of state.fighters) { + if (attacker.state !== 'attack' || attacker.moveId === null || attacker.hitstop > 0) { + continue; + } + const defender = state.fighters[attacker.index === 0 ? 1 : 0]; + // A dazed opponent is awaiting an Erasure, not a normal punish. + if (defender.state === 'defeated' || defender.state === 'dazed' || defender.state === 'erased') { + continue; + } + const move = getMove(attacker.moveId); + const attackerCapsuleSource = attacker; + + for (let i = 0; i < move.hitboxes.length; i++) { + const hb = move.hitboxes[i]; + if (!hb || attacker.moveFrame < hb.startFrame || attacker.moveFrame > hb.endFrame) { + continue; + } + const key = connectionKey(defender.index, i); + if (attacker.connected.includes(key)) { + continue; + } + // Throws pass harmlessly under an airborne opponent. + if (move.kind === 'throw' && !defender.grounded) { + continue; + } + if (!capsulesOverlap(hitboxCapsule(attackerCapsuleSource, hb), bodyCapsule(defender))) { + continue; + } + + const outcome = decideOutcome(attacker, defender, move); + if (outcome === 'whiff') { + // Deliberately not recorded: the hitbox stays live and may connect on a + // later frame once the defender's invulnerability lapses. + continue; + } + + attacker.connected.push(key); + applyOutcome(attacker, defender, move, outcome, ctx); + } + } +} + +export function applyOutcome( + attacker: Fighter, + defender: Fighter, + move: Move, + outcome: HitOutcome, + ctx: CombatContext, +): void { + switch (outcome) { + case 'armor': + applyArmor(attacker, defender, move.hit, ctx); + return; + case 'parry': + applyParry(attacker, defender, move.hit, ctx, DRIVE_PARRY_GAIN); + return; + case 'block': + applyBlock( + attacker, + defender, + move.hit, + ctx, + defender.burnout > 0 ? BURNOUT_BLOCKSTUN_BONUS : 0, + ); + return; + case 'hit': + applyHit(attacker, defender, move, move.hit, ctx, false); + return; + case 'counter': + applyHit(attacker, defender, move, move.hit, ctx, true); + return; + default: + return; + } +} + +export { isInvulnerable }; diff --git a/ascendants/src/engine/data/builders.ts b/ascendants/src/engine/data/builders.ts new file mode 100644 index 0000000..92cc2a3 --- /dev/null +++ b/ascendants/src/engine/data/builders.ts @@ -0,0 +1,132 @@ +import type { Hitbox, HitProps, InvulnWindow, Move, MoveCost, MoveInput, MoveKind, ProjectileSpec } from '../types/move'; +import { vec3, type Vec3 } from '../math/vec3'; + +/** + * Terse constructors for the move tables. + * + * WHY: frame data is read far more often than it is written — during balance + * passes an engineer scans dozens of moves at once. Defaults here mean each + * move entry states only what makes it different, so the tables stay legible. + */ + +const DEFAULT_HIT: HitProps = { + damage: 0, + chipDamage: 0, + hitstun: 16, + blockstun: 12, + hitstop: 6, + pushback: 0.09, + launch: 0, + juggleCost: 1, + knockdown: false, + wallBounce: false, + guardBreak: false, + kiGainOnHit: 3, + kiGainOnBlock: 1, + driveDamageOnBlock: 120, +}; + +export function hit(overrides: Partial & { damage: number }): HitProps { + return { ...DEFAULT_HIT, ...overrides }; +} + +/** Capsule hitbox in fighter-local space; +z is forward. */ +export function box( + forward: number, + height: number, + radius: number, + length: number, + startFrame: number, + endFrame: number, +): Hitbox { + return { offset: vec3(0, height, forward), radius, length, startFrame, endFrame }; +} + +export function cost(ki = 0, drive = 0, superMeter = 0): MoveCost { + return { ki, drive, superMeter }; +} + +export const NO_COST: MoveCost = { ki: 0, drive: 0, superMeter: 0 }; + +export function input(motion: readonly number[], button: number, holdDir?: number): MoveInput { + return holdDir === undefined ? { motion, button } : { motion, button, holdDir }; +} + +export function blast(overrides: Partial & { hit: HitProps }): ProjectileSpec { + return { + kind: 'blast', + speed: 0.42, + radius: 0.45, + lifetime: 90, + spawnOffset: vec3(0, 1.1, 0.9), + homing: 0, + clashPower: 1, + pierce: false, + ...overrides, + }; +} + +export function beam(overrides: Partial & { hit: HitProps }): ProjectileSpec { + return { + kind: 'beam', + speed: 0.85, + radius: 0.8, + lifetime: 70, + spawnOffset: vec3(0, 1.15, 1.1), + homing: 0, + clashPower: 4, + pierce: true, + ...overrides, + }; +} + +export type MoveSpec = { + id: string; + name: string; + kind: MoveKind; + input: MoveInput; + startup: number; + active: number; + recovery: number; + hit: HitProps; + hitboxes?: readonly Hitbox[]; + cost?: MoveCost; + cancels?: readonly string[]; + groundOk?: boolean; + airOk?: boolean; + travel?: { forward: number; up: number }; + projectile?: ProjectileSpec; + invuln?: InvulnWindow; + finisher?: boolean; +}; + +/** + * Assemble a Move. Optional fields are attached only when present so the + * result satisfies exactOptionalPropertyTypes and never carries `undefined` + * into a state snapshot. + */ +export function move(spec: MoveSpec): Move { + return { + id: spec.id, + name: spec.name, + kind: spec.kind, + input: spec.input, + startup: spec.startup, + active: spec.active, + recovery: spec.recovery, + hitboxes: spec.hitboxes ?? [], + hit: spec.hit, + cost: spec.cost ?? NO_COST, + cancels: spec.cancels ?? [], + groundOk: spec.groundOk ?? true, + airOk: spec.airOk ?? false, + ...(spec.travel ? { travel: spec.travel } : {}), + ...(spec.projectile ? { projectile: spec.projectile } : {}), + ...(spec.invuln ? { invuln: spec.invuln } : {}), + ...(spec.finisher ? { finisher: spec.finisher } : {}), + }; +} + +export function localOffset(forward: number, height: number): Vec3 { + return vec3(0, height, forward); +} diff --git a/ascendants/src/engine/data/characters.ts b/ascendants/src/engine/data/characters.ts new file mode 100644 index 0000000..707b3d6 --- /dev/null +++ b/ascendants/src/engine/data/characters.ts @@ -0,0 +1,184 @@ +/** + * The roster. Stats are multipliers against the universal baseline so the + * balance conversation stays relative — "Korvath takes 12% less damage" reads + * faster than an absolute defense number, and a baseline tuning change + * propagates to every character automatically. + */ + +export type CharacterStats = { + readonly maxHealth: number; + readonly walkMult: number; + readonly dashMult: number; + readonly damageMult: number; + /** Incoming damage multiplier; below 1 is tankier. */ + readonly defenseMult: number; + readonly kiRegenMult: number; + readonly driveRegenMult: number; + readonly jumpMult: number; + /** Resistance to pushback and juggle height; heavier fighters move less. */ + readonly weight: number; +}; + +/** Objective-case pronoun, used to address a fighter in announcer lines. */ +export type Pronoun = 'her' | 'him' | 'them'; + +export type Character = { + readonly id: string; + readonly name: string; + readonly title: string; + readonly lore: string; + readonly pronoun: Pronoun; + readonly accent: string; + readonly secondary: string; + readonly stats: CharacterStats; + /** Ids of this character's three specials, in menu order. */ + readonly specials: readonly [string, string, string]; + readonly superMove: string; +}; + +const BASE: CharacterStats = { + maxHealth: 1000, + walkMult: 1, + dashMult: 1, + damageMult: 1, + defenseMult: 1, + kiRegenMult: 1, + driveRegenMult: 1, + jumpMult: 1, + weight: 1, +}; + +export const ROSTER: readonly Character[] = [ + { + id: 'vanta', + name: 'VANTA', + title: 'The Even Hand', + lore: 'First to walk out of the crater still standing. Teaches what she survived.', + pronoun: 'her', + accent: '#8B5CF6', + secondary: '#2E1065', + stats: { ...BASE }, + specials: ['vanta.riftPalm', 'vanta.ascensionKnee', 'vanta.phaseStep'], + superMove: 'vanta.eclipse', + }, + { + id: 'korvath', + name: 'KORVATH', + title: 'Sunken Colossus', + lore: 'Took a direct hit from the Herald and got up. Slower every year, harder every year.', + pronoun: 'him', + accent: '#C9A84C', + secondary: '#3B2F0B', + stats: { + ...BASE, + maxHealth: 1180, + walkMult: 0.82, + dashMult: 0.85, + damageMult: 1.14, + defenseMult: 0.88, + kiRegenMult: 0.9, + jumpMult: 0.9, + weight: 1.35, + }, + specials: ['korvath.quake', 'korvath.grasp', 'korvath.bulwark'], + superMove: 'korvath.gravityWell', + }, + { + id: 'sei', + name: 'SEI', + title: 'Nine Steps', + lore: 'Never blocks. Insists blocking is just being somewhere you should not be.', + pronoun: 'her', + accent: '#22D3EE', + secondary: '#083344', + stats: { + ...BASE, + maxHealth: 880, + walkMult: 1.28, + dashMult: 1.32, + damageMult: 0.86, + defenseMult: 1.12, + kiRegenMult: 1.1, + driveRegenMult: 1.15, + jumpMult: 1.15, + weight: 0.78, + }, + specials: ['sei.nineStep', 'sei.razorRush', 'sei.updraft'], + superMove: 'sei.thousandCuts', + }, + { + id: 'marrow', + name: 'MARROW', + title: 'The Long Dark', + lore: 'Measures every fight in metres. Has never willingly been inside two of them.', + pronoun: 'them', + accent: '#34D399', + secondary: '#052E22', + stats: { + ...BASE, + maxHealth: 920, + walkMult: 0.94, + dashMult: 0.96, + damageMult: 0.92, + defenseMult: 1.08, + kiRegenMult: 1.45, + jumpMult: 0.98, + weight: 0.92, + }, + specials: ['marrow.lance', 'marrow.tether', 'marrow.bulwarkWall'], + superMove: 'marrow.eventHorizon', + }, + { + id: 'talon', + name: 'TALON', + title: 'Counterweight', + lore: 'Fights entirely in replies. Has not thrown a first punch since the sky fell.', + pronoun: 'him', + accent: '#F472B6', + secondary: '#4C0519', + stats: { + ...BASE, + maxHealth: 960, + walkMult: 1.06, + damageMult: 0.95, + defenseMult: 0.97, + driveRegenMult: 1.3, + weight: 0.95, + }, + specials: ['talon.counter', 'talon.hookKick', 'talon.airDive'], + superMove: 'talon.retribution', + }, + { + id: 'helios', + name: 'HELIOS', + title: 'Last Light', + lore: 'Burns through his own reserves to end fights early, because he cannot survive long ones.', + pronoun: 'him', + accent: '#F97316', + secondary: '#431407', + stats: { + ...BASE, + maxHealth: 840, + walkMult: 1.02, + damageMult: 1.28, + defenseMult: 1.18, + kiRegenMult: 1.2, + driveRegenMult: 0.9, + weight: 0.9, + }, + specials: ['helios.solarFang', 'helios.corona', 'helios.flare'], + superMove: 'helios.supernova', + }, +]; + +const BY_ID = new Map(ROSTER.map((c) => [c.id, c])); + +export function getCharacter(id: string): Character { + const found = BY_ID.get(id); + if (!found) { + throw new Error(`Unknown character: ${id}`); + } + return found; +} + +export const DEFAULT_CHARACTER_ID = 'vanta'; diff --git a/ascendants/src/engine/data/constants.ts b/ascendants/src/engine/data/constants.ts new file mode 100644 index 0000000..dc638d2 --- /dev/null +++ b/ascendants/src/engine/data/constants.ts @@ -0,0 +1,115 @@ +/** + * Every tunable number in the simulation, in one place. + * + * Units: distance is world units (a fighter is 1.8 tall), time is frames at a + * fixed 60Hz, and all velocities are per-frame. Frame counts — not seconds — + * are authoritative because a fighting game's feel lives in exact frame + * advantage, and because rollback re-simulates whole frames. + */ + +export const TICK_RATE = 60; +export const TICK_MS = 1000 / 60; + +/** Arena is a disc with an invisible cylinder wall and a flight ceiling. */ +export const ARENA_RADIUS = 26; +export const ARENA_CEILING = 34; +export const FLOOR_Y = 0; + +export const WALK_SPEED = 0.075; +export const DASH_SPEED = 0.225; +export const DASH_FRAMES = 16; +export const BACKDASH_SPEED = 0.26; +export const BACKDASH_FRAMES = 18; +export const BACKDASH_INVULN = 7; +export const AIR_DRIFT = 0.011; +export const JUMP_VELOCITY = 0.42; +export const GRAVITY = 0.028; +export const MAX_FALL_SPEED = 0.85; +export const LANDING_RECOVERY = 4; + +export const FLY_SPEED = 0.155; +export const FLY_VERTICAL_SPEED = 0.12; +export const FLY_MIN_HEIGHT = 1.2; + +/** Ki — the Dragon Ball resource: blasts, flight, vanish, transformation. */ +export const KI_MAX = 100; +export const KI_REGEN_PASSIVE = 0.055; +export const KI_CHARGE_RATE = 0.55; +export const KI_FLY_UPKEEP = 0.06; +export const KI_VANISH_COST = 25; +export const VANISH_COOLDOWN = 90; +export const VANISH_FRAMES = 14; +export const VANISH_DISTANCE = 2.2; + +/** Drive — the Street Fighter 6 resource: impact, parry, rush, overdrive. */ +export const DRIVE_MAX = 6000; +export const DRIVE_BAR = 1000; +export const DRIVE_REGEN = 3.2; +export const DRIVE_REGEN_BURNOUT = 6.4; +export const DRIVE_PARRY_DRAIN = 8.5; +export const DRIVE_PARRY_GAIN = 1000; +export const DRIVE_PARRY_STARTUP = 1; +export const DRIVE_RUSH_COST = 1000; +export const DRIVE_RUSH_CANCEL_COST = 3000; +export const DRIVE_RUSH_FRAMES = 20; +export const DRIVE_RUSH_SPEED = 0.3; +export const BURNOUT_FRAMES = 300; +export const BURNOUT_BLOCKSTUN_BONUS = 4; + +export const SUPER_MAX = 3000; +export const SUPER_GAIN_PER_DAMAGE = 1.6; +export const SUPER_GAIN_ON_TAKE = 0.8; + +/** + * Damage scaling by combo length. Index 0 is the first hit; combos past the + * table floor at MIN_SCALING so long juggles stay expressive but never lethal + * on their own. + */ +export const SCALING_TABLE = [1, 1, 0.85, 0.75, 0.65, 0.55, 0.45, 0.35, 0.25, 0.15] as const; +export const MIN_SCALING = 0.1; + +/** Juggle points spent per air hit; at the limit the victim falls out. */ +export const JUGGLE_LIMIT = 8; +export const JUGGLE_GRAVITY_STEP = 0.0035; + +export const HITSTOP_SCALE = 1; +export const PUSHBACK_DECAY = 0.82; + +/** MK-style Fatal Blow: one attempt per match, only while badly hurt. */ +export const FATAL_HEALTH_RATIO = 0.3; +export const FATAL_FREEZE_FRAMES = 90; + +export const TRANSFORM_FRAMES = 54; +export const TRANSFORM_KI_REQUIRED = 100; +export const TRANSFORM_KI_DRAIN = 0.05; +export const TRANSFORM_DAMAGE_MULT = 1.25; +export const TRANSFORM_SPEED_MULT = 1.18; +export const TRANSFORM_INVULN_FRAMES = 30; + +export const ROUND_TIME_FRAMES = 99 * 60; +export const INTRO_FRAMES = 100; +export const ROUND_END_FRAMES = 170; +export const MATCH_END_FRAMES = 300; +export const ROUNDS_TO_WIN = 2; +export const START_DISTANCE = 5.5; + +export const WAKEUP_FRAMES = 22; +export const KNOCKDOWN_FRAMES = 26; +export const THROW_RANGE = 1.4; + +/** Beam clash: contested for this long before the higher power wins. */ +export const CLASH_FRAMES = 48; +export const CLASH_PUSH = 0.06; + +/** + * The Erasure window — this game's answer to Mortal Kombat's finisher. + * + * A match-ending blow does not kill outright. It leaves the loser standing, + * helpless and swaying, while the announcer calls PUT HER/HIM/THEM UNDER. The winner has + * this long to land a character-specific Erasure; let the window lapse and the + * round simply ends. Mercy is the default, not the reward. + */ +export const FINISHER_WINDOW_FRAMES = 200; +export const FINISHER_RANGE = 3.2; +export const ERASURE_FRAMES = 190; +export const DAZE_SWAY_RATE = 0.06; diff --git a/ascendants/src/engine/data/finishers.ts b/ascendants/src/engine/data/finishers.ts new file mode 100644 index 0000000..cfce52e --- /dev/null +++ b/ascendants/src/engine/data/finishers.ts @@ -0,0 +1,98 @@ +import { Button } from '../types/input'; +import { getCharacter } from './characters'; + +/** + * Erasures — one per fighter, all mechanically identical, all flavoured + * differently. Mechanical parity matters: a finisher that varied in difficulty + * by character would quietly punish players for their roster choice at the one + * moment the game is asking them to celebrate. + */ + +export type Erasure = { + readonly id: string; + readonly characterId: string; + /** Shown in place of the health bars while the cinematic plays. */ + readonly name: string; + /** One line of flavour under the title card. */ + readonly caption: string; + readonly motion: readonly number[]; + readonly button: number; +}; + +/** + * The callout, addressed to the winner about the fighter left standing. + * + * The pronoun comes from the loser's character sheet rather than a global + * string, so Marrow — who uses they — reads correctly instead of being forced + * into a binary the roster does not have. + */ +export function finisherCallout(loserCharacterId: string): string { + return `PUT ${getCharacter(loserCharacterId).pronoun.toUpperCase()} UNDER.`; +} + +/** Neutral form, for menus and copy that has no fighter in scope. */ +export const FINISHER_CALLOUT_GENERIC = 'PUT THEM UNDER.'; +export const FINISHER_SUBTITLE = 'THE SKY IS STILL OWED'; + +const QCB = [2, 1, 4]; + +export const ERASURES: readonly Erasure[] = [ + { + id: 'erasure.vanta', + characterId: 'vanta', + name: 'EVENT SILENCE', + caption: 'She closes the space where you were standing.', + motion: QCB, + button: Button.Ki, + }, + { + id: 'erasure.korvath', + characterId: 'korvath', + name: 'BURIAL RITE', + caption: 'The ground opens on request. It does not give anything back.', + motion: QCB, + button: Button.Heavy, + }, + { + id: 'erasure.sei', + characterId: 'sei', + name: 'THE TENTH STEP', + caption: 'Nine steps get her there. The tenth is for you.', + motion: QCB, + button: Button.Medium, + }, + { + id: 'erasure.marrow', + characterId: 'marrow', + name: 'LONG DARK', + caption: 'From further away than you ever got to stand.', + motion: QCB, + button: Button.Ki, + }, + { + id: 'erasure.talon', + characterId: 'talon', + name: 'FINAL REPLY', + caption: 'He never throws the first punch. Only the last one.', + motion: QCB, + button: Button.Guard, + }, + { + id: 'erasure.helios', + characterId: 'helios', + name: 'TOTAL ECLIPSE', + caption: 'He spends everything he has left. It is more than enough.', + motion: QCB, + button: Button.Heavy, + }, +]; + +const BY_CHARACTER = new Map(ERASURES.map((e) => [e.characterId, e])); + +export function erasureFor(characterId: string): Erasure | undefined { + return BY_CHARACTER.get(characterId); +} + +export function erasureById(id: string): Erasure | undefined { + return ERASURES.find((e) => e.id === id); +} diff --git a/ascendants/src/engine/data/moves.ts b/ascendants/src/engine/data/moves.ts new file mode 100644 index 0000000..85fc7cd --- /dev/null +++ b/ascendants/src/engine/data/moves.ts @@ -0,0 +1,81 @@ +import type { Move } from '../types/move'; +import { UNIVERSAL_MOVES } from './universal'; +import { VANTA_MOVES } from './specials/vanta'; +import { KORVATH_MOVES } from './specials/korvath'; +import { SEI_MOVES } from './specials/sei'; +import { MARROW_MOVES } from './specials/marrow'; +import { TALON_MOVES } from './specials/talon'; +import { HELIOS_MOVES } from './specials/helios'; + +/** + * The move registry. + * + * Character ownership is derived from the id prefix (`vanta.eclipse` belongs to + * `vanta`, `u.*` belongs to everyone) rather than stored separately, so adding + * a character cannot desynchronise two lists. + */ + +const ALL: readonly Move[] = [ + ...UNIVERSAL_MOVES, + ...VANTA_MOVES, + ...KORVATH_MOVES, + ...SEI_MOVES, + ...MARROW_MOVES, + ...TALON_MOVES, + ...HELIOS_MOVES, +]; + +const BY_ID = new Map(ALL.map((m) => [m.id, m])); + +export const ALL_MOVES = ALL; + +export function getMove(id: string): Move { + const found = BY_ID.get(id); + if (!found) { + throw new Error(`Unknown move: ${id}`); + } + return found; +} + +export function tryGetMove(id: string): Move | undefined { + return BY_ID.get(id); +} + +export function ownerOf(moveId: string): string { + const dot = moveId.indexOf('.'); + return dot < 0 ? 'u' : moveId.slice(0, dot); +} + +/** Universal moves plus the ones belonging to `characterId`. */ +export function movesFor(characterId: string): readonly Move[] { + return ALL.filter((m) => { + const owner = ownerOf(m.id); + return owner === 'u' || owner === characterId; + }); +} + +/** + * Resolve a cancel list, expanding the `@special` / `@super` wildcards against + * the acting character's own kit. + */ +export function resolveCancels(move: Move, characterId: string): readonly string[] { + const out: string[] = []; + for (const entry of move.cancels) { + if (entry === '@special') { + for (const m of movesFor(characterId)) { + if (m.kind === 'special' || m.kind === 'blast') { + out.push(m.id); + } + } + } else if (entry === '@super') { + for (const m of movesFor(characterId)) { + if (m.kind === 'super') { + out.push(m.id); + } + } + } else { + out.push(entry); + } + } + return out; +} diff --git a/ascendants/src/engine/data/specials/helios.ts b/ascendants/src/engine/data/specials/helios.ts new file mode 100644 index 0000000..3b7acac --- /dev/null +++ b/ascendants/src/engine/data/specials/helios.ts @@ -0,0 +1,96 @@ +import { Button } from '../../types/input'; +import type { Move } from '../../types/move'; +import { blast, box, cost, hit, input, move } from '../builders'; + +const QCF = [2, 3, 6]; +const QCB = [2, 1, 4]; +const DP = [6, 2, 3]; +const SUPER_MOTION = [2, 3, 6, 2, 3, 6]; + +/** HELIOS — the highest damage in the game on the shortest health bar. */ +export const HELIOS_MOVES: readonly Move[] = [ + move({ + id: 'helios.solarFang', + name: 'Solar Fang', + kind: 'special', + input: input(QCF, Button.Heavy), + startup: 16, + active: 4, + recovery: 26, + hit: hit({ + damage: 130, + chipDamage: 22, + hitstun: 32, + blockstun: 20, + hitstop: 13, + launch: 0.2, + pushback: 0.24, + driveDamageOnBlock: 300, + }), + hitboxes: [box(1.0, 1.3, 0.55, 1.3, 16, 19)], + cancels: ['@super'], + travel: { forward: 0.24, up: 0 }, + }), + move({ + id: 'helios.corona', + name: 'Corona Rise', + kind: 'special', + input: input(DP, Button.Heavy), + startup: 7, + active: 6, + recovery: 32, + hit: hit({ + damage: 110, + hitstun: 36, + blockstun: 16, + hitstop: 12, + launch: 0.45, + juggleCost: 2, + pushback: 0.14, + }), + hitboxes: [box(0.6, 1.55, 0.58, 1.35, 7, 12)], + invuln: { start: 1, end: 8, kind: 'full' }, + travel: { forward: 0.1, up: 0.38 }, + }), + move({ + id: 'helios.flare', + name: 'Flare', + kind: 'special', + input: input(QCB, Button.Ki), + startup: 12, + active: 1, + recovery: 20, + hit: hit({ damage: 0 }), + cost: cost(18), + airOk: true, + projectile: blast({ + speed: 0.55, + radius: 0.55, + lifetime: 70, + clashPower: 3, + hit: hit({ damage: 75, chipDamage: 15, hitstun: 24, blockstun: 16, hitstop: 9, pushback: 0.18 }), + }), + }), + move({ + id: 'helios.supernova', + name: 'Supernova', + kind: 'super', + input: input(SUPER_MOTION, Button.Heavy), + startup: 9, + active: 6, + recovery: 50, + hit: hit({ + damage: 360, + chipDamage: 60, + hitstun: 56, + hitstop: 24, + knockdown: true, + guardBreak: true, + pushback: 0.45, + juggleCost: 0, + }), + hitboxes: [box(1.2, 1.2, 1.6, 2.0, 9, 14)], + cost: cost(0, 0, 3000), + invuln: { start: 1, end: 8, kind: 'full' }, + }), +]; diff --git a/ascendants/src/engine/data/specials/korvath.ts b/ascendants/src/engine/data/specials/korvath.ts new file mode 100644 index 0000000..940c0cb --- /dev/null +++ b/ascendants/src/engine/data/specials/korvath.ts @@ -0,0 +1,89 @@ +import { Button } from '../../types/input'; +import type { Move } from '../../types/move'; +import { box, cost, hit, input, move } from '../builders'; + +const QCF = [2, 3, 6]; +const QCB = [2, 1, 4]; +const DOWN_DOWN = [2, 2]; +const SUPER_MOTION = [2, 3, 6, 2, 3, 6]; + +/** KORVATH — armor, command grabs, and no way to run away from him. */ +export const KORVATH_MOVES: readonly Move[] = [ + move({ + id: 'korvath.quake', + name: 'Quake Step', + kind: 'special', + input: input(QCF, Button.Heavy), + startup: 22, + active: 5, + recovery: 30, + // A ground-only shockwave: jumping beats it cleanly, which is the price of + // a move that covers half the arena floor. + hit: hit({ + damage: 110, + chipDamage: 18, + hitstun: 34, + blockstun: 20, + hitstop: 12, + knockdown: true, + pushback: 0.3, + driveDamageOnBlock: 320, + }), + hitboxes: [box(2.4, 0.3, 0.7, 4.2, 22, 26)], + }), + move({ + id: 'korvath.grasp', + name: 'Tectonic Grasp', + kind: 'throw', + input: input(QCB, Button.Light), + startup: 8, + active: 2, + recovery: 34, + hit: hit({ + damage: 150, + hitstun: 46, + hitstop: 18, + knockdown: true, + guardBreak: true, + pushback: 0.2, + kiGainOnHit: 10, + }), + hitboxes: [box(0.7, 1.1, 0.7, 0.8, 8, 9)], + }), + move({ + id: 'korvath.bulwark', + name: 'Bulwark', + kind: 'special', + input: input(DOWN_DOWN, Button.Guard), + startup: 4, + active: 30, + recovery: 14, + hit: hit({ damage: 0 }), + cost: cost(0, 500), + // Two hits of armor with no offense of its own: it buys forward movement + // through a fireball, which is exactly what this archetype lacks. + invuln: { start: 1, end: 34, kind: 'armor', armorHits: 2 }, + travel: { forward: 0.18, up: 0 }, + }), + move({ + id: 'korvath.gravityWell', + name: 'Gravity Well', + kind: 'super', + input: input(SUPER_MOTION, Button.Heavy), + startup: 10, + active: 6, + recovery: 46, + hit: hit({ + damage: 320, + hitstun: 54, + hitstop: 22, + knockdown: true, + guardBreak: true, + pushback: 0.1, + juggleCost: 0, + }), + hitboxes: [box(1.4, 1.0, 1.9, 2.4, 10, 15)], + cost: cost(0, 0, 3000), + invuln: { start: 1, end: 9, kind: 'full' }, + }), +]; diff --git a/ascendants/src/engine/data/specials/marrow.ts b/ascendants/src/engine/data/specials/marrow.ts new file mode 100644 index 0000000..e7fc190 --- /dev/null +++ b/ascendants/src/engine/data/specials/marrow.ts @@ -0,0 +1,99 @@ +import { Button } from '../../types/input'; +import type { Move } from '../../types/move'; +import { beam, blast, cost, hit, input, move } from '../builders'; + +const QCF = [2, 3, 6]; +const QCB = [2, 1, 4]; +const DOWN_DOWN = [2, 2]; +const SUPER_MOTION = [2, 3, 6, 2, 3, 6]; + +/** MARROW — controls space with layered projectiles; loses badly up close. */ +export const MARROW_MOVES: readonly Move[] = [ + move({ + id: 'marrow.lance', + name: 'Marrow Lance', + kind: 'special', + input: input(QCF, Button.Ki), + startup: 16, + active: 1, + recovery: 26, + hit: hit({ damage: 0 }), + cost: cost(25), + airOk: true, + projectile: beam({ + speed: 0.95, + radius: 0.6, + lifetime: 60, + clashPower: 6, + hit: hit({ damage: 95, chipDamage: 18, hitstun: 28, blockstun: 18, hitstop: 10, pushback: 0.2 }), + }), + }), + move({ + id: 'marrow.tether', + name: 'Tether Orb', + kind: 'special', + input: input(QCB, Button.Ki), + startup: 14, + active: 1, + recovery: 22, + hit: hit({ damage: 0 }), + cost: cost(15), + airOk: true, + // Slow and homing: it does little on its own, but it takes away the + // opponent's option to simply stand still and block. + projectile: blast({ + speed: 0.22, + radius: 0.42, + lifetime: 150, + homing: 0.045, + clashPower: 1, + hit: hit({ damage: 55, chipDamage: 10, hitstun: 24, blockstun: 15, hitstop: 7, pushback: 0.1 }), + }), + }), + move({ + id: 'marrow.bulwarkWall', + name: 'Standing Wall', + kind: 'special', + input: input(DOWN_DOWN, Button.Ki), + startup: 12, + active: 1, + recovery: 20, + hit: hit({ damage: 0 }), + cost: cost(20), + // A stationary, high-clash barrier that eats incoming fireballs outright. + projectile: blast({ + speed: 0, + radius: 0.9, + lifetime: 180, + clashPower: 9, + hit: hit({ damage: 30, chipDamage: 6, hitstun: 18, blockstun: 12, hitstop: 5, pushback: 0.06 }), + }), + }), + move({ + id: 'marrow.eventHorizon', + name: 'Event Horizon', + kind: 'super', + input: input(SUPER_MOTION, Button.Ki), + startup: 11, + active: 1, + recovery: 48, + hit: hit({ damage: 0 }), + cost: cost(0, 0, 3000), + invuln: { start: 1, end: 10, kind: 'full' }, + projectile: beam({ + speed: 1.05, + radius: 1.35, + lifetime: 90, + clashPower: 20, + hit: hit({ + damage: 330, + chipDamage: 55, + hitstun: 50, + blockstun: 28, + hitstop: 18, + knockdown: true, + pushback: 0.4, + }), + }), + }), +]; diff --git a/ascendants/src/engine/data/specials/sei.ts b/ascendants/src/engine/data/specials/sei.ts new file mode 100644 index 0000000..dae1691 --- /dev/null +++ b/ascendants/src/engine/data/specials/sei.ts @@ -0,0 +1,92 @@ +import { Button } from '../../types/input'; +import type { Move } from '../../types/move'; +import { box, cost, hit, input, move } from '../builders'; + +const QCF = [2, 3, 6]; +const QCB = [2, 1, 4]; +const DP = [6, 2, 3]; +const SUPER_MOTION = [2, 3, 6, 2, 3, 6]; + +/** SEI — fastest kit in the game, and the lowest damage per touch. */ +export const SEI_MOVES: readonly Move[] = [ + move({ + id: 'sei.nineStep', + name: 'Nine Step', + kind: 'special', + input: input(QCB, Button.Light), + startup: 10, + active: 3, + recovery: 16, + hit: hit({ damage: 70, hitstun: 24, blockstun: 15, hitstop: 7, pushback: 0.08 }), + hitboxes: [box(0.7, 1.2, 0.48, 0.9, 10, 12)], + cost: cost(6), + cancels: ['@super'], + travel: { forward: 1.15, up: 0 }, + }), + move({ + id: 'sei.razorRush', + name: 'Razor Rush', + kind: 'special', + input: input(QCF, Button.Medium), + startup: 12, + active: 15, + recovery: 22, + // Three separate active windows, so each connects independently and the + // string can be blocked partway through rather than being all-or-nothing. + hit: hit({ damage: 30, hitstun: 18, blockstun: 13, hitstop: 5, juggleCost: 1, pushback: 0.04 }), + hitboxes: [ + box(0.7, 1.2, 0.44, 0.9, 12, 14), + box(0.85, 1.25, 0.44, 0.95, 18, 20), + box(1.0, 1.3, 0.46, 1.0, 24, 26), + ], + cancels: ['@super'], + travel: { forward: 0.22, up: 0 }, + }), + move({ + id: 'sei.updraft', + name: 'Updraft', + kind: 'special', + input: input(DP, Button.Medium), + startup: 5, + active: 5, + recovery: 26, + hit: hit({ + damage: 80, + hitstun: 32, + blockstun: 14, + hitstop: 10, + launch: 0.4, + juggleCost: 2, + pushback: 0.1, + }), + hitboxes: [box(0.55, 1.5, 0.5, 1.2, 5, 9)], + invuln: { start: 1, end: 6, kind: 'full' }, + travel: { forward: 0.1, up: 0.36 }, + }), + move({ + id: 'sei.thousandCuts', + name: 'Thousand Cuts', + kind: 'super', + input: input(SUPER_MOTION, Button.Medium), + startup: 7, + active: 20, + recovery: 40, + hit: hit({ + damage: 60, + hitstun: 20, + hitstop: 8, + juggleCost: 0, + pushback: 0.03, + kiGainOnHit: 0, + }), + hitboxes: [ + box(0.8, 1.2, 0.5, 1.1, 7, 9), + box(0.9, 1.25, 0.5, 1.1, 12, 14), + box(1.0, 1.3, 0.5, 1.1, 17, 19), + box(1.1, 1.35, 0.55, 1.2, 22, 26), + ], + cost: cost(0, 0, 3000), + invuln: { start: 1, end: 6, kind: 'full' }, + travel: { forward: 0.3, up: 0 }, + }), +]; diff --git a/ascendants/src/engine/data/specials/talon.ts b/ascendants/src/engine/data/specials/talon.ts new file mode 100644 index 0000000..aef955a --- /dev/null +++ b/ascendants/src/engine/data/specials/talon.ts @@ -0,0 +1,98 @@ +import { Button } from '../../types/input'; +import type { Move } from '../../types/move'; +import { box, cost, hit, input, move } from '../builders'; + +const QCF = [2, 3, 6]; +const QCB = [2, 1, 4]; +const SUPER_MOTION = [2, 3, 6, 2, 3, 6]; + +/** TALON — wins by answering. Almost every tool is conditional on the opponent. */ +export const TALON_MOVES: readonly Move[] = [ + move({ + id: 'talon.counter', + name: 'Counterweight', + kind: 'special', + input: input(QCB, Button.Guard), + startup: 3, + active: 4, + recovery: 26, + // Strike-invulnerable through the read window, then swings. Whiffing it + // against a patient opponent is a full punish, which is the whole cost. + hit: hit({ + damage: 120, + hitstun: 36, + blockstun: 18, + hitstop: 14, + knockdown: true, + pushback: 0.22, + }), + hitboxes: [box(0.75, 1.2, 0.5, 1.0, 3, 6)], + cost: cost(0, 500), + invuln: { start: 1, end: 14, kind: 'strike' }, + }), + move({ + id: 'talon.hookKick', + name: 'Hook Kick', + kind: 'special', + input: input(QCF, Button.Heavy), + startup: 14, + active: 4, + recovery: 22, + hit: hit({ + damage: 100, + chipDamage: 14, + hitstun: 30, + blockstun: 18, + hitstop: 11, + wallBounce: true, + pushback: 0.26, + }), + hitboxes: [box(0.95, 1.35, 0.5, 1.15, 14, 17)], + cancels: ['@super'], + travel: { forward: 0.2, up: 0 }, + }), + move({ + id: 'talon.airDive', + name: 'Falling Talon', + kind: 'special', + input: input(QCF, Button.Medium), + startup: 10, + active: 6, + recovery: 18, + hit: hit({ + damage: 85, + hitstun: 30, + blockstun: 16, + hitstop: 10, + knockdown: true, + juggleCost: 2, + pushback: 0.14, + }), + hitboxes: [box(0.6, 0.6, 0.5, 1.1, 10, 15)], + groundOk: false, + airOk: true, + travel: { forward: 0.42, up: -0.22 }, + }), + move({ + id: 'talon.retribution', + name: 'Retribution', + kind: 'super', + input: input(SUPER_MOTION, Button.Guard), + startup: 8, + active: 5, + recovery: 44, + hit: hit({ + damage: 310, + hitstun: 52, + hitstop: 20, + knockdown: true, + guardBreak: true, + pushback: 0.3, + juggleCost: 0, + }), + hitboxes: [box(1.0, 1.2, 0.6, 1.4, 8, 12)], + cost: cost(0, 0, 3000), + invuln: { start: 1, end: 10, kind: 'full' }, + travel: { forward: 0.28, up: 0 }, + }), +]; diff --git a/ascendants/src/engine/data/specials/vanta.ts b/ascendants/src/engine/data/specials/vanta.ts new file mode 100644 index 0000000..8263709 --- /dev/null +++ b/ascendants/src/engine/data/specials/vanta.ts @@ -0,0 +1,95 @@ +import { Button } from '../../types/input'; +import type { Move } from '../../types/move'; +import { beam, blast, box, cost, hit, input, move } from '../builders'; + +const QCF = [2, 3, 6]; +const QCB = [2, 1, 4]; +const DP = [6, 2, 3]; +const SUPER_MOTION = [2, 3, 6, 2, 3, 6]; + +/** VANTA — the reference kit. Every archetype is measured against this one. */ +export const VANTA_MOVES: readonly Move[] = [ + move({ + id: 'vanta.riftPalm', + name: 'Rift Palm', + kind: 'special', + input: input(QCF, Button.Ki), + startup: 14, + active: 1, + recovery: 24, + hit: hit({ damage: 0 }), + cost: cost(12), + airOk: true, + projectile: blast({ + speed: 0.5, + radius: 0.5, + hit: hit({ damage: 65, chipDamage: 12, hitstun: 22, blockstun: 16, hitstop: 8, pushback: 0.15 }), + clashPower: 2, + }), + }), + move({ + id: 'vanta.ascensionKnee', + name: 'Ascension Knee', + kind: 'special', + input: input(DP, Button.Heavy), + startup: 6, + active: 6, + recovery: 28, + // Invulnerable on the way up: the universal answer to jump-ins, and + // deliberately unsafe on block so it cannot be thrown out for free. + hit: hit({ + damage: 95, + hitstun: 34, + blockstun: 16, + hitstop: 11, + launch: 0.42, + juggleCost: 2, + pushback: 0.12, + }), + hitboxes: [box(0.6, 1.5, 0.55, 1.3, 6, 11)], + invuln: { start: 1, end: 7, kind: 'full' }, + travel: { forward: 0.12, up: 0.34 }, + }), + move({ + id: 'vanta.phaseStep', + name: 'Phase Step', + kind: 'special', + input: input(QCB, Button.Light), + startup: 4, + active: 1, + recovery: 12, + hit: hit({ damage: 0 }), + cost: cost(8), + airOk: true, + // Passes through fireballs but not fists — a positioning tool, not an escape. + invuln: { start: 1, end: 12, kind: 'projectile' }, + travel: { forward: 0.62, up: 0 }, + }), + move({ + id: 'vanta.eclipse', + name: 'Eclipse Cannon', + kind: 'super', + input: input(SUPER_MOTION, Button.Ki), + startup: 9, + active: 1, + recovery: 44, + hit: hit({ damage: 0 }), + cost: cost(0, 0, 3000), + invuln: { start: 1, end: 8, kind: 'full' }, + projectile: beam({ + speed: 0.9, + radius: 1.0, + lifetime: 80, + clashPower: 12, + hit: hit({ + damage: 280, + chipDamage: 45, + hitstun: 46, + blockstun: 26, + hitstop: 16, + knockdown: true, + pushback: 0.35, + }), + }), + }), +]; diff --git a/ascendants/src/engine/data/universal.ts b/ascendants/src/engine/data/universal.ts new file mode 100644 index 0000000..66b188e --- /dev/null +++ b/ascendants/src/engine/data/universal.ts @@ -0,0 +1,247 @@ +import { Button } from '../types/input'; +import type { Move } from '../types/move'; +import { blast, box, cost, hit, input, move } from './builders'; + +/** + * Moves every fighter owns. Characters differentiate through specials and + * supers, not through their normals — the shared normals are what makes the + * game teachable, exactly as in Street Fighter. + * + * `@special` and `@super` in a cancel list are wildcards meaning "any move of + * that kind belonging to this character", which keeps per-character tables + * from having to restate the universal cancel routes. + */ + +const CHAIN_FROM_LIGHT = ['u.M', 'u.H', 'u.2M', 'u.blast', '@special', '@super'] as const; +const CHAIN_FROM_MEDIUM = ['u.H', 'u.2H', 'u.blast', '@special', '@super'] as const; +const CANCEL_TO_SPECIAL = ['@special', '@super'] as const; + +export const UNIVERSAL_MOVES: readonly Move[] = [ + move({ + id: 'u.L', + name: 'Jab', + kind: 'normal', + input: input([], Button.Light), + startup: 5, + active: 2, + recovery: 9, + // +3 on hit, 0 on block: a safe check that still converts into a chain. + hit: hit({ damage: 30, hitstun: 14, blockstun: 11, hitstop: 5, pushback: 0.06 }), + hitboxes: [box(0.55, 1.25, 0.42, 0.7, 5, 6)], + cancels: CHAIN_FROM_LIGHT, + }), + move({ + id: 'u.M', + name: 'Straight', + kind: 'normal', + input: input([], Button.Medium), + startup: 8, + active: 3, + recovery: 14, + hit: hit({ damage: 55, hitstun: 19, blockstun: 15, hitstop: 7, pushback: 0.1 }), + hitboxes: [box(0.75, 1.3, 0.45, 0.85, 8, 10)], + cancels: CHAIN_FROM_MEDIUM, + }), + move({ + id: 'u.H', + name: 'Rising Smash', + kind: 'normal', + input: input([], Button.Heavy), + startup: 12, + active: 4, + recovery: 20, + hit: hit({ + damage: 85, + hitstun: 26, + blockstun: 20, + hitstop: 10, + pushback: 0.16, + launch: 0.26, + driveDamageOnBlock: 240, + }), + hitboxes: [box(0.85, 1.45, 0.5, 0.95, 12, 15)], + cancels: CANCEL_TO_SPECIAL, + }), + move({ + id: 'u.2M', + name: 'Low Sweep Kick', + kind: 'command', + input: input([], Button.Medium, 2), + startup: 7, + active: 3, + recovery: 13, + hit: hit({ damage: 48, hitstun: 18, blockstun: 14, hitstop: 6, pushback: 0.08 }), + hitboxes: [box(0.7, 0.45, 0.4, 0.8, 7, 9)], + cancels: CHAIN_FROM_MEDIUM, + }), + move({ + id: 'u.2H', + name: 'Ankle Breaker', + kind: 'command', + input: input([], Button.Heavy, 2), + startup: 11, + active: 4, + recovery: 22, + hit: hit({ damage: 70, hitstun: 30, blockstun: 16, hitstop: 9, knockdown: true, pushback: 0.14 }), + hitboxes: [box(0.9, 0.4, 0.45, 1.0, 11, 14)], + cancels: CANCEL_TO_SPECIAL, + }), + move({ + id: 'u.6H', + name: 'Skyfall Axe', + kind: 'command', + input: input([], Button.Heavy, 6), + startup: 20, + active: 4, + recovery: 18, + // Slow overhead: the answer to a crouching opponent, punishable if read. + hit: hit({ damage: 75, hitstun: 28, blockstun: 18, hitstop: 9, launch: 0.2, pushback: 0.12 }), + hitboxes: [box(0.8, 1.6, 0.48, 1.1, 20, 23)], + cancels: CANCEL_TO_SPECIAL, + travel: { forward: 0.08, up: 0 }, + }), + move({ + id: 'u.airL', + name: 'Air Jab', + kind: 'normal', + input: input([], Button.Light), + startup: 5, + active: 3, + recovery: 10, + hit: hit({ damage: 32, hitstun: 16, blockstun: 12, hitstop: 5, juggleCost: 1 }), + hitboxes: [box(0.6, 1.0, 0.44, 0.75, 5, 7)], + cancels: ['u.airH', '@special'], + groundOk: false, + airOk: true, + }), + move({ + id: 'u.airH', + name: 'Meteor Heel', + kind: 'normal', + input: input([], Button.Heavy), + startup: 10, + active: 4, + recovery: 16, + hit: hit({ + damage: 80, + hitstun: 30, + blockstun: 18, + hitstop: 10, + knockdown: true, + juggleCost: 2, + pushback: 0.1, + }), + hitboxes: [box(0.7, 0.7, 0.5, 1.0, 10, 13)], + cancels: CANCEL_TO_SPECIAL, + groundOk: false, + airOk: true, + }), + move({ + id: 'u.throw', + name: 'Seize', + kind: 'throw', + input: input([], Button.Light | Button.Medium), + startup: 5, + active: 2, + recovery: 22, + hit: hit({ + damage: 110, + hitstun: 40, + hitstop: 14, + knockdown: true, + guardBreak: true, + pushback: 0.3, + kiGainOnHit: 8, + }), + hitboxes: [box(0.5, 1.1, 0.5, 0.6, 5, 6)], + }), + move({ + id: 'u.blast', + name: 'Ki Blast', + kind: 'blast', + input: input([], Button.Ki), + startup: 11, + active: 1, + recovery: 16, + hit: hit({ damage: 0 }), + cost: cost(5), + cancels: ['@special', '@super'], + airOk: true, + projectile: blast({ + hit: hit({ damage: 40, chipDamage: 8, hitstun: 18, blockstun: 14, hitstop: 6, pushback: 0.12 }), + }), + }), + move({ + id: 'u.driveImpact', + name: 'Drive Impact', + kind: 'driveImpact', + input: input([], Button.Guard | Button.Heavy), + startup: 26, + active: 3, + recovery: 18, + // Absorbs one hit during startup and wall-splats on connect — the SF6 + // pressure-reset button, and the reason cornering an opponent is dangerous. + hit: hit({ + damage: 90, + chipDamage: 20, + hitstun: 36, + blockstun: 22, + hitstop: 14, + launch: 0.1, + wallBounce: true, + pushback: 0.24, + driveDamageOnBlock: 500, + }), + hitboxes: [box(0.9, 1.2, 0.55, 1.1, 26, 28)], + cost: cost(0, 1000), + invuln: { start: 1, end: 25, kind: 'armor', armorHits: 1 }, + }), + move({ + id: 'u.fatal', + name: 'Fatal Blow', + kind: 'fatal', + input: input([], Button.Drive | Button.Ki), + startup: 12, + active: 4, + recovery: 34, + // Mortal Kombat's comeback valve: fully invulnerable on the way in, one + // attempt per match, and only unlocked once the user is nearly dead. + hit: hit({ + damage: 300, + hitstun: 60, + hitstop: 24, + knockdown: true, + guardBreak: true, + pushback: 0.4, + juggleCost: 0, + kiGainOnHit: 0, + }), + hitboxes: [box(1.0, 1.2, 0.65, 1.3, 12, 15)], + invuln: { start: 1, end: 11, kind: 'full' }, + travel: { forward: 0.35, up: 0 }, + finisher: true, + }), + move({ + id: 'u.transform', + name: 'Ascend', + kind: 'transform', + input: input([2, 2], Button.Charge), + startup: 6, + active: 1, + recovery: 47, + // The burst itself knocks nearby opponents away, so ascending under + // pressure is a real option rather than a free punish for the attacker. + hit: hit({ + damage: 20, + hitstun: 24, + blockstun: 14, + hitstop: 8, + knockdown: true, + pushback: 0.5, + guardBreak: true, + }), + hitboxes: [box(0, 1.0, 2.2, 0.1, 6, 6)], + airOk: true, + invuln: { start: 1, end: 30, kind: 'full' }, + }), +]; diff --git a/ascendants/src/engine/fighter/actions.ts b/ascendants/src/engine/fighter/actions.ts new file mode 100644 index 0000000..0bd4d1a --- /dev/null +++ b/ascendants/src/engine/fighter/actions.ts @@ -0,0 +1,162 @@ +import { FATAL_HEALTH_RATIO, TRANSFORM_KI_REQUIRED } from '../data/constants'; +import type { CharacterStats } from '../data/characters'; +import { getMove, movesFor, resolveCancels } from '../data/moves'; +import { pressedWithin, recentDirs } from '../input/buffer'; +import { MOTION_WINDOW, matchesHold, matchesMotion } from '../input/motion'; +import { canSpendDrive, spendDrive } from '../combat/drive'; +import { canSpendKi, spendKi } from '../combat/ki'; +import { scale, add, vec3 } from '../math/vec3'; +import { forwardVector } from '../combat/hitbox'; +import type { Fighter } from '../types/fighter'; +import type { Move, MoveKind } from '../types/move'; +import { totalFrames } from '../types/move'; +import type { PlayerInput } from '../types/input'; + +/** + * Move selection: turning a frame of input into an action. + * + * Candidates are ranked by how specific their input is — a six-direction super + * motion beats a quarter-circle, which beats a bare button press. Without that + * ordering the first matching entry in the table would win, and every super + * would come out as a jab. + */ + +const INPUT_BUFFER_FRAMES = 4; + +const PRIORITY: Record = { + fatal: 100, + super: 90, + transform: 85, + driveImpact: 80, + throw: 70, + overdrive: 65, + special: 60, + beam: 55, + blast: 50, + command: 40, + normal: 30, +}; + +function specificity(move: Move): number { + const holdBonus = move.input.holdDir ? 5 : 0; + return move.input.motion.length * 100 + PRIORITY[move.kind] + holdBonus; +} + +function affordable(fighter: Fighter, move: Move): boolean { + return ( + canSpendKi(fighter, move.cost.ki) && + (move.cost.drive === 0 || canSpendDrive(fighter, move.cost.drive)) && + fighter.superMeter >= move.cost.superMeter + ); +} + +/** Conditions beyond resources: Fatal Blows and transformations are gated. */ +function unlocked(fighter: Fighter, move: Move): boolean { + if (move.kind === 'fatal') { + return !fighter.fatalUsed && fighter.health <= fighter.maxHealth * FATAL_HEALTH_RATIO; + } + if (move.kind === 'transform') { + return !fighter.transformed && fighter.ki >= TRANSFORM_KI_REQUIRED; + } + return true; +} + +function stanceAllows(fighter: Fighter, move: Move): boolean { + return fighter.grounded ? move.groundOk : move.airOk; +} + +/** + * Cancel rules: a move already in progress can only be replaced if it has + * connected, the new move is in its cancel list, and it has not already been + * used in this chain (which is what stops a light attack looping into itself). + */ +function cancelAllowed(fighter: Fighter, next: Move): boolean { + if (fighter.state !== 'attack' || fighter.moveId === null) { + return true; + } + const current = getMove(fighter.moveId); + if (fighter.connected.length === 0) { + return false; + } + if (fighter.moveFrame > totalFrames(current)) { + return true; + } + if (fighter.chain.includes(next.id)) { + return false; + } + return resolveCancels(current, fighter.characterId).includes(next.id); +} + +function inputMatches(fighter: Fighter, move: Move, current: PlayerInput): boolean { + const required = move.input.button; + if ((current.buttons & required) !== required) { + return false; + } + if (!pressedWithin(fighter.inputHistory, required, INPUT_BUFFER_FRAMES)) { + return false; + } + if (move.input.holdDir !== undefined && !matchesHold(current.dir, move.input.holdDir)) { + return false; + } + return matchesMotion(recentDirs(fighter.inputHistory, MOTION_WINDOW), move.input.motion); +} + +/** The highest-specificity legal move for this frame, or null. */ +export function selectMove(fighter: Fighter, current: PlayerInput): Move | null { + let best: Move | null = null; + let bestScore = -1; + + for (const move of movesFor(fighter.characterId)) { + if (!stanceAllows(fighter, move) || !unlocked(fighter, move) || !affordable(fighter, move)) { + continue; + } + if (!cancelAllowed(fighter, move) || !inputMatches(fighter, move, current)) { + continue; + } + const score = specificity(move); + if (score > bestScore) { + best = move; + bestScore = score; + } + } + return best; +} + +/** Commit to a move: pay its costs, reset its counters, apply its travel. */ +export function startMove(fighter: Fighter, move: Move, stats: CharacterStats): void { + spendKi(fighter, move.cost.ki); + if (move.cost.drive > 0) { + spendDrive(fighter, move.cost.drive); + } + fighter.superMeter -= move.cost.superMeter; + + if (fighter.state === 'attack' && fighter.moveId !== null) { + fighter.chain.push(fighter.moveId); + } else { + fighter.chain.length = 0; + } + + fighter.state = 'attack'; + fighter.stateFrame = 0; + fighter.moveId = move.id; + fighter.moveFrame = 0; + fighter.connected.length = 0; + fighter.parryHold = 0; + + if (move.kind === 'fatal') { + fighter.fatalUsed = true; + } + if (move.invuln?.kind === 'armor') { + fighter.armor = move.invuln.armorHits ?? 1; + } + + if (move.travel) { + const forward = scale(forwardVector(fighter.facing), move.travel.forward * stats.dashMult); + fighter.pos = add(fighter.pos, forward); + if (move.travel.up !== 0) { + fighter.vel = vec3(fighter.vel.x, move.travel.up, fighter.vel.z); + fighter.grounded = false; + fighter.flying = false; + } + } +} diff --git a/ascendants/src/engine/fighter/locomotion.ts b/ascendants/src/engine/fighter/locomotion.ts new file mode 100644 index 0000000..a7deff2 --- /dev/null +++ b/ascendants/src/engine/fighter/locomotion.ts @@ -0,0 +1,200 @@ +import { + BACKDASH_SPEED, + DASH_SPEED, + FLY_MIN_HEIGHT, + FLY_SPEED, + FLY_VERTICAL_SPEED, + JUMP_VELOCITY, + KI_FLY_UPKEEP, + KI_VANISH_COST, + DRIVE_PARRY_DRAIN, + VANISH_COOLDOWN, + VANISH_DISTANCE, + VANISH_FRAMES, + WALK_SPEED, + TRANSFORM_SPEED_MULT, +} from '../data/constants'; +import type { CharacterStats } from '../data/characters'; +import { drainDrive } from '../combat/drive'; +import { spendKi } from '../combat/ki'; +import { forwardVector } from '../combat/hitbox'; +import { add, scale, vec3 } from '../math/vec3'; +import { Button, dirForward, dirVertical, isDown, type PlayerInput } from '../types/input'; +import { isReeling, type Fighter } from '../types/fighter'; +import type { EffectEvent } from '../types/match'; +import { translateLocal, translateVertical, faceOpponent, clampToArena } from './movement'; + +/** + * Player-driven movement. + * + * The fight happens on the plane between the two fighters: 6 advances, 4 + * retreats, 8 rises, 2 drops. Free lateral movement is deliberately not on the + * stick — it is a committed sidestep off the dash button instead. Keeping the + * stick two-dimensional is what lets quarter-circle motions stay readable in a + * 3D space, and it is the same compromise the arena fighters this game descends + * from all landed on. + */ + +const SIDESTEP_SPEED = 0.2; + +export function isFreeToMove(fighter: Fighter): boolean { + return ( + fighter.state === 'idle' || + fighter.state === 'walk' || + fighter.state === 'air' || + fighter.state === 'fly' || + fighter.state === 'guard' || + fighter.state === 'charge' || + fighter.state === 'parry' + ); +} + +function speedMult(fighter: Fighter, stats: CharacterStats): number { + return stats.walkMult * (fighter.transformed ? TRANSFORM_SPEED_MULT : 1); +} + +/** + * The Vanish: spend ki to blink behind the attacker mid-combo. + * + * This is the pressure release valve the Dragon Ball half of the game needs — + * without it, one launcher would mean watching an unbroken juggle. Its cost and + * cooldown are what stop it from erasing offence entirely. + */ +export function tryVanish( + fighter: Fighter, + opponent: Fighter, + input: PlayerInput, + effects: EffectEvent[], +): boolean { + if (!isReeling(fighter) || !isDown(input, Button.Vanish)) { + return false; + } + if (fighter.vanishCooldown > 0 || !spendKi(fighter, KI_VANISH_COST)) { + return false; + } + + const behind = add(opponent.pos, scale(forwardVector(opponent.facing), -VANISH_DISTANCE)); + fighter.pos = vec3(behind.x, Math.max(opponent.pos.y, behind.y), behind.z); + fighter.vel = vec3(0, 0, 0); + fighter.state = 'vanish'; + fighter.stateFrame = 0; + fighter.stun = 0; + fighter.juggle = 0; + fighter.comboHits = 0; + fighter.moveId = null; + fighter.vanishCooldown = VANISH_COOLDOWN + VANISH_FRAMES; + faceOpponent(fighter, opponent.pos.x, opponent.pos.z, true); + clampToArena(fighter); + effects.push({ kind: 'vanish', pos: fighter.pos, power: 1, owner: fighter.index }); + return true; +} + +function startDash(fighter: Fighter, forward: number, strafe: number): void { + fighter.state = forward < 0 ? 'backdash' : 'dash'; + fighter.stateFrame = 0; + fighter.dashForward = forward; + fighter.dashStrafe = strafe; +} + +/** Dashes are committed: the direction is locked in when the dash begins. */ +export function tickDash(fighter: Fighter, stats: CharacterStats): void { + const speed = fighter.dashForward < 0 ? BACKDASH_SPEED : DASH_SPEED; + translateLocal( + fighter, + fighter.dashForward * speed * stats.dashMult, + fighter.dashStrafe * SIDESTEP_SPEED * stats.dashMult, + ); +} + +export function tickLocomotion( + fighter: Fighter, + input: PlayerInput, + stats: CharacterStats, +): void { + if (fighter.state === 'dash' || fighter.state === 'backdash') { + tickDash(fighter, stats); + return; + } + if (!isFreeToMove(fighter)) { + return; + } + + const forward = dirForward(input.dir); + const vertical = dirVertical(input.dir); + + // Stance buttons are exclusive and checked in priority order: you cannot + // parry while charging, and you cannot charge while guarding. + if (isDown(input, Button.Drive) && fighter.burnout <= 0) { + fighter.state = 'parry'; + fighter.parryHold += 1; + drainDrive(fighter, DRIVE_PARRY_DRAIN); + return; + } + fighter.parryHold = 0; + + if (isDown(input, Button.Guard)) { + fighter.state = 'guard'; + return; + } + if (isDown(input, Button.Charge)) { + fighter.state = 'charge'; + return; + } + + if (isDown(input, Button.Dash)) { + startDash(fighter, forward, forward === 0 ? (vertical >= 0 ? 1 : -1) : 0); + return; + } + + if (fighter.flying) { + tickFlight(fighter, forward, vertical, stats); + return; + } + + if (fighter.grounded) { + if (vertical > 0) { + fighter.vel = vec3(fighter.vel.x, JUMP_VELOCITY * stats.jumpMult, fighter.vel.z); + fighter.grounded = false; + fighter.state = 'air'; + fighter.stateFrame = 0; + return; + } + if (forward !== 0) { + fighter.state = 'walk'; + translateLocal(fighter, forward * WALK_SPEED * speedMult(fighter, stats), 0); + } else { + fighter.state = 'idle'; + } + return; + } + + // Airborne: pressing up again converts the jump into sustained flight. + if (vertical > 0 && fighter.pos.y > FLY_MIN_HEIGHT && fighter.ki > KI_FLY_UPKEEP) { + fighter.flying = true; + fighter.state = 'fly'; + return; + } + fighter.state = 'air'; + translateLocal(fighter, forward * WALK_SPEED * 0.6 * speedMult(fighter, stats), 0); +} + +function tickFlight( + fighter: Fighter, + forward: number, + vertical: number, + stats: CharacterStats, +): void { + if (!spendKi(fighter, KI_FLY_UPKEEP)) { + fighter.flying = false; + fighter.state = 'air'; + return; + } + fighter.state = 'fly'; + translateLocal(fighter, forward * FLY_SPEED * speedMult(fighter, stats), 0); + if (vertical !== 0) { + translateVertical(fighter, vertical * FLY_VERTICAL_SPEED); + } + if (fighter.pos.y <= FLY_MIN_HEIGHT && vertical < 0) { + fighter.flying = false; + } +} diff --git a/ascendants/src/engine/fighter/movement.ts b/ascendants/src/engine/fighter/movement.ts new file mode 100644 index 0000000..18c14de --- /dev/null +++ b/ascendants/src/engine/fighter/movement.ts @@ -0,0 +1,141 @@ +import { + ARENA_CEILING, + ARENA_RADIUS, + FLOOR_Y, + GRAVITY, + JUGGLE_GRAVITY_STEP, + MAX_FALL_SPEED, + PUSHBACK_DECAY, +} from '../data/constants'; +import { atan2, rotateToward } from '../math/trig'; +import { add, scale, vec3 } from '../math/vec3'; +import { forwardVector } from '../combat/hitbox'; +import { FIGHTER_RADIUS, type Fighter } from '../types/fighter'; + +/** + * Physics and world constraints. + * + * Velocity is reserved for forces the player does not directly steer — gravity, + * launches, pushback. Deliberate locomotion writes to position instead. Keeping + * the two separate means a walk input can never fight a knockback impulse for + * ownership of the same number, which is the usual source of "my character + * slid" bugs. + */ + +const AIR_DRAG = 0.985; +const FLIGHT_VERTICAL_DAMP = 0.86; + +export function tickPhysics(fighter: Fighter): void { + if (fighter.hitstop > 0) { + return; + } + + const { x: vx, z: vz } = fighter.vel; + let vy = fighter.vel.y; + + if (fighter.flying) { + vy *= FLIGHT_VERTICAL_DAMP; + } else if (!fighter.grounded) { + // Each juggle point adds gravity, so a long combo drops the victim out + // naturally instead of needing a hard hit-count cutoff. + vy -= GRAVITY * (1 + fighter.juggle * JUGGLE_GRAVITY_STEP); + if (vy < -MAX_FALL_SPEED) { + vy = -MAX_FALL_SPEED; + } + } + + fighter.pos = add(fighter.pos, vec3(vx, vy, vz)); + + // Horizontal drag only: vertical velocity was already resolved above, by + // flight damping or by gravity. + const drag = fighter.grounded ? PUSHBACK_DECAY : AIR_DRAG; + fighter.vel = vec3(vx * drag, vy, vz * drag); + + landAndBound(fighter); +} + +function landAndBound(fighter: Fighter): void { + if (fighter.pos.y <= FLOOR_Y) { + const wasAirborne = !fighter.grounded; + fighter.pos = vec3(fighter.pos.x, FLOOR_Y, fighter.pos.z); + fighter.vel = vec3(fighter.vel.x, 0, fighter.vel.z); + fighter.grounded = true; + fighter.flying = false; + fighter.juggle = 0; + if (wasAirborne && fighter.state === 'launched') { + fighter.state = 'knockdown'; + fighter.stateFrame = 0; + } + } else { + fighter.grounded = false; + } + + if (fighter.pos.y > ARENA_CEILING) { + fighter.pos = vec3(fighter.pos.x, ARENA_CEILING, fighter.pos.z); + fighter.vel = vec3(fighter.vel.x, Math.min(0, fighter.vel.y), fighter.vel.z); + } + + clampToArena(fighter); +} + +/** The arena is a disc with an invisible cylinder wall; nobody leaves it. */ +export function clampToArena(fighter: Fighter): void { + const limit = ARENA_RADIUS - FIGHTER_RADIUS; + const r = Math.sqrt(fighter.pos.x * fighter.pos.x + fighter.pos.z * fighter.pos.z); + if (r <= limit || r === 0) { + return; + } + const k = limit / r; + fighter.pos = vec3(fighter.pos.x * k, fighter.pos.y, fighter.pos.z * k); + fighter.vel = vec3(fighter.vel.x * 0.2, fighter.vel.y, fighter.vel.z * 0.2); +} + +/** Translate along the fighter's own axes: +forward is toward their facing. */ +export function translateLocal(fighter: Fighter, forward: number, strafe: number): void { + const fwd = forwardVector(fighter.facing); + // Right-hand perpendicular of the forward vector on the XZ plane. + const right = vec3(fwd.z, 0, -fwd.x); + fighter.pos = add(fighter.pos, add(scale(fwd, forward), scale(right, strafe))); + clampToArena(fighter); +} + +export function translateVertical(fighter: Fighter, amount: number): void { + fighter.pos = vec3(fighter.pos.x, fighter.pos.y + amount, fighter.pos.z); + if (fighter.pos.y < FLOOR_Y) { + fighter.pos = vec3(fighter.pos.x, FLOOR_Y, fighter.pos.z); + } +} + +const TURN_RATE = 0.32; + +/** Lock-on: fighters continuously orient toward their opponent. */ +export function faceOpponent(fighter: Fighter, opponentX: number, opponentZ: number, instant = false): void { + const desired = atan2(opponentX - fighter.pos.x, opponentZ - fighter.pos.z); + fighter.facing = instant ? desired : rotateToward(fighter.facing, desired, TURN_RATE); +} + +/** Stop two fighters occupying the same space, pushing each apart evenly. */ +export function separate(a: Fighter, b: Fighter): void { + const dx = b.pos.x - a.pos.x; + const dz = b.pos.z - a.pos.z; + const distSq = dx * dx + dz * dz; + const minDist = FIGHTER_RADIUS * 2; + if (distSq >= minDist * minDist) { + return; + } + const dist = Math.sqrt(distSq); + if (dist === 0) { + // Perfectly co-located: nudge along a fixed axis so the result stays + // deterministic rather than depending on a random direction. + a.pos = vec3(a.pos.x - minDist / 2, a.pos.y, a.pos.z); + b.pos = vec3(b.pos.x + minDist / 2, b.pos.y, b.pos.z); + return; + } + const overlap = (minDist - dist) / 2; + const nx = (dx / dist) * overlap; + const nz = (dz / dist) * overlap; + a.pos = vec3(a.pos.x - nx, a.pos.y, a.pos.z - nz); + b.pos = vec3(b.pos.x + nx, b.pos.y, b.pos.z + nz); + clampToArena(a); + clampToArena(b); +} diff --git a/ascendants/src/engine/fighter/timers.ts b/ascendants/src/engine/fighter/timers.ts new file mode 100644 index 0000000..2c15125 --- /dev/null +++ b/ascendants/src/engine/fighter/timers.ts @@ -0,0 +1,158 @@ +import { + DAZE_SWAY_RATE, + KNOCKDOWN_FRAMES, + LANDING_RECOVERY, + TRANSFORM_FRAMES, + TRANSFORM_KI_DRAIN, + VANISH_FRAMES, + WAKEUP_FRAMES, + DASH_FRAMES, + BACKDASH_FRAMES, + DRIVE_RUSH_FRAMES, +} from '../data/constants'; +import type { CharacterStats } from '../data/characters'; +import { getMove } from '../data/moves'; +import { tickDrive } from '../combat/drive'; +import { tickCharge, tickKi } from '../combat/ki'; +import type { Fighter } from '../types/fighter'; +import { totalFrames } from '../types/move'; + +/** + * Per-frame bookkeeping: timers, resources, and the transitions that fire when + * a timed state runs out. + * + * Hitstop returns early on purpose. During impact freeze nothing about a + * fighter advances — not their move, not their meters, not their stun — which + * is what makes a heavy hit feel heavy instead of just doing more damage. + */ + +export function tickTimers(fighter: Fighter, stats: CharacterStats): void { + if (fighter.hitstop > 0) { + fighter.hitstop -= 1; + return; + } + + fighter.stateFrame += 1; + if (fighter.moveId !== null) { + fighter.moveFrame += 1; + } + if (fighter.stun > 0) { + fighter.stun -= 1; + } + if (fighter.vanishCooldown > 0) { + fighter.vanishCooldown -= 1; + } + + tickDrive(fighter, stats); + if (fighter.state === 'charge') { + tickCharge(fighter, stats); + } else { + tickKi(fighter, stats); + } + + if (fighter.transformed) { + fighter.transformFrames += 1; + fighter.ki -= TRANSFORM_KI_DRAIN; + if (fighter.ki <= 0) { + fighter.ki = 0; + fighter.transformed = false; + } + } + + // A combo is over the moment the victim is free to act again. + if (fighter.stun <= 0 && fighter.state !== 'hitstun' && fighter.state !== 'launched') { + fighter.comboHits = 0; + fighter.comboDamage = 0; + } +} + +/** Return a fighter to a neutral state appropriate to where they are standing. */ +export function toNeutral(fighter: Fighter): void { + fighter.state = fighter.grounded ? 'idle' : 'air'; + fighter.stateFrame = 0; + fighter.moveId = null; + fighter.moveFrame = 0; + fighter.connected.length = 0; + fighter.armor = 0; +} + +/** Resolve any state whose lifetime has elapsed. */ +export function advanceState(fighter: Fighter): void { + if (fighter.hitstop > 0) { + return; + } + + switch (fighter.state) { + case 'attack': { + if (fighter.moveId === null) { + toNeutral(fighter); + return; + } + const move = getMove(fighter.moveId); + if (move.kind === 'transform' && fighter.moveFrame === move.startup) { + fighter.transformed = true; + fighter.transformFrames = 0; + } + if (fighter.moveFrame > totalFrames(move)) { + fighter.chain.length = 0; + toNeutral(fighter); + } + return; + } + case 'hitstun': + case 'blockstun': + if (fighter.stun <= 0) { + toNeutral(fighter); + } + return; + case 'launched': + // Resolved by the ground collision in movement, not by a timer. + return; + case 'knockdown': + if (fighter.stateFrame >= KNOCKDOWN_FRAMES) { + fighter.state = 'wakeup'; + fighter.stateFrame = 0; + } + return; + case 'wakeup': + if (fighter.stateFrame >= WAKEUP_FRAMES) { + toNeutral(fighter); + } + return; + case 'vanish': + if (fighter.stateFrame >= VANISH_FRAMES) { + toNeutral(fighter); + } + return; + case 'transform': + if (fighter.stateFrame >= TRANSFORM_FRAMES) { + fighter.transformed = true; + fighter.transformFrames = 0; + toNeutral(fighter); + } + return; + case 'dash': + if (fighter.stateFrame >= DASH_FRAMES) { + toNeutral(fighter); + } + return; + case 'backdash': + if (fighter.stateFrame >= BACKDASH_FRAMES) { + toNeutral(fighter); + } + return; + case 'land': + if (fighter.stateFrame >= LANDING_RECOVERY) { + toNeutral(fighter); + } + return; + case 'dazed': + // Sways in place until the Erasure window closes; the match layer owns it. + fighter.facing += DAZE_SWAY_RATE * (fighter.stateFrame % 2 === 0 ? 1 : -1); + return; + default: + return; + } +} + +export const DRIVE_RUSH_DURATION = DRIVE_RUSH_FRAMES; diff --git a/ascendants/src/engine/index.ts b/ascendants/src/engine/index.ts new file mode 100644 index 0000000..ddd1ca1 --- /dev/null +++ b/ascendants/src/engine/index.ts @@ -0,0 +1,20 @@ +export * from './types'; +export * from './math'; +export { ALL_MOVES, getMove, tryGetMove, movesFor, resolveCancels, ownerOf } from './data/moves'; +export { ROSTER, getCharacter, DEFAULT_CHARACTER_ID } from './data/characters'; +export type { Character, CharacterStats, Pronoun } from './data/characters'; +export { + ERASURES, + erasureFor, + erasureById, + finisherCallout, + FINISHER_CALLOUT_GENERIC, + FINISHER_SUBTITLE, +} from './data/finishers'; +export type { Erasure } from './data/finishers'; +export * as Constants from './data/constants'; +export { step } from './sim/step'; +export { createMatch, createFighter, defaultConfig, resetRound } from './sim/init'; +export { cloneState } from './sim/clone'; +export { checksum } from './sim/hash'; +export { tickMatchFlow } from './match/rounds'; diff --git a/ascendants/src/engine/input/buffer.ts b/ascendants/src/engine/input/buffer.ts new file mode 100644 index 0000000..feab25c --- /dev/null +++ b/ascendants/src/engine/input/buffer.ts @@ -0,0 +1,53 @@ +import { INPUT_HISTORY } from '../types/fighter'; +import { packInput, unpackInput, type PlayerInput } from '../types/input'; + +/** + * Input history helpers. + * + * The history is a plain array of packed 16-bit inputs capped at + * INPUT_HISTORY, newest last — small enough to copy on every rollback snapshot + * without measurable cost. + */ + +export function pushInput(history: number[], input: PlayerInput): void { + history.push(packInput(input)); + if (history.length > INPUT_HISTORY) { + history.splice(0, history.length - INPUT_HISTORY); + } +} + +/** Directions from the most recent `window` frames, oldest first. */ +export function recentDirs(history: readonly number[], window: number): number[] { + const start = Math.max(0, history.length - window); + const out: number[] = []; + for (let i = start; i < history.length; i++) { + out.push(unpackInput(history[i] ?? 0).dir); + } + return out; +} + +/** + * True if `button` transitioned from released to pressed within the last + * `frames` frames — the classic input buffer that stops a special from being + * eaten because it was entered one frame early. + */ +export function pressedWithin( + history: readonly number[], + button: number, + frames: number, +): boolean { + const end = history.length - 1; + const start = Math.max(1, history.length - frames); + for (let i = end; i >= start; i--) { + const current = unpackInput(history[i] ?? 0).buttons; + const previous = unpackInput(history[i - 1] ?? 0).buttons; + if ((current & button) !== 0 && (previous & button) === 0) { + return true; + } + } + return false; +} + +export function latestInput(history: readonly number[]): PlayerInput { + return unpackInput(history[history.length - 1] ?? 0); +} diff --git a/ascendants/src/engine/input/motion.test.ts b/ascendants/src/engine/input/motion.test.ts new file mode 100644 index 0000000..2d68dae --- /dev/null +++ b/ascendants/src/engine/input/motion.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { isDoubleTap, matchesHold, matchesMotion } from './motion'; + +const QCF = [2, 3, 6]; +const DP = [6, 2, 3]; +const SUPER = [2, 3, 6, 2, 3, 6]; + +describe('motion recognition', () => { + it('accepts a clean quarter-circle forward', () => { + expect(matchesMotion([5, 5, 2, 3, 6], QCF)).toBe(true); + }); + + it('accepts a quarter-circle with the diagonal skipped', () => { + // Players routinely roll straight from down to forward; the game has to + // read that as the same input or specials feel broken. + expect(matchesMotion([5, 2, 6], QCF)).toBe(true); + }); + + it('tolerates neutral frames inside the motion', () => { + expect(matchesMotion([2, 5, 3, 5, 6], QCF)).toBe(true); + }); + + it('rejects the motion entered backwards', () => { + expect(matchesMotion([6, 3, 2], QCF)).toBe(false); + }); + + it('rejects a motion that never reached forward', () => { + expect(matchesMotion([5, 2, 3], QCF)).toBe(false); + }); + + it('distinguishes a dragon punch from a quarter-circle', () => { + expect(matchesMotion([6, 2, 3], DP)).toBe(true); + expect(matchesMotion([2, 3, 6], DP)).toBe(false); + }); + + it('accepts a super motion with both diagonals skipped', () => { + expect(matchesMotion([2, 6, 2, 6], SUPER)).toBe(true); + }); + + it('still requires the first and last direction of a motion', () => { + // Leniency must not go so far that a quarter-circle answers a dragon punch. + expect(matchesMotion([2, 3], QCF)).toBe(false); + expect(matchesMotion([2, 6], DP)).toBe(false); + }); + + it('reads a double quarter-circle super', () => { + expect(matchesMotion([2, 3, 6, 2, 3, 6], SUPER)).toBe(true); + expect(matchesMotion([2, 3, 6], SUPER)).toBe(false); + }); + + it('treats an empty motion as always satisfied', () => { + expect(matchesMotion([], [])).toBe(true); + expect(matchesMotion([5], [])).toBe(true); + }); + + it('accepts diagonals for a cardinal hold requirement', () => { + expect(matchesHold(1, 2)).toBe(true); + expect(matchesHold(3, 2)).toBe(true); + expect(matchesHold(2, 2)).toBe(true); + expect(matchesHold(8, 2)).toBe(false); + }); + + it('treats a zero hold requirement as no requirement', () => { + expect(matchesHold(4, 0)).toBe(true); + }); + + it('detects a double tap only when the direction was released between', () => { + expect(isDoubleTap([6, 5, 6], 6, 10)).toBe(true); + expect(isDoubleTap([6, 6, 6], 6, 10)).toBe(false); + expect(isDoubleTap([6, 5, 4], 6, 10)).toBe(false); + }); +}); diff --git a/ascendants/src/engine/input/motion.ts b/ascendants/src/engine/input/motion.ts new file mode 100644 index 0000000..745fe34 --- /dev/null +++ b/ascendants/src/engine/input/motion.ts @@ -0,0 +1,107 @@ +/** + * Motion input recognition. + * + * Matching is a backwards subsequence search over the recent direction history + * rather than an exact sequence compare, which buys neutral frames mid-motion + * and sloppy timing for free while the bounded window stops a motion from a + * second ago counting now. + * + * Subsequence matching alone is still too strict for real hands: players roll + * straight from down to forward without ever registering the diagonal, and a + * quarter-circle that demands 2-3-6 exactly feels broken. So each motion also + * matches a reduced variant with its interior diagonals dropped. The first and + * last directions are always required — those are what separate a + * quarter-circle from a dragon punch. + */ + +export const MOTION_WINDOW = 15; + +/** Diagonals that a cardinal hold should also accept. */ +const HOLD_EQUIVALENTS: Record = { + 1: [1], + 2: [1, 2, 3], + 3: [3], + 4: [1, 4, 7], + 5: [5], + 6: [3, 6, 9], + 7: [7], + 8: [7, 8, 9], + 9: [9], +}; + +const DIAGONALS = new Set([1, 3, 7, 9]); + +/** + * Memoised per motion. This is pure derivation keyed by the input, so the cache + * cannot introduce state that a rollback would need to rewind. + */ +const variantCache = new Map(); + +function variantsOf(motion: readonly number[]): number[][] { + const key = motion.join(','); + const cached = variantCache.get(key); + if (cached) { + return cached; + } + const reduced = motion.filter( + (dir, i) => i === 0 || i === motion.length - 1 || !DIAGONALS.has(dir), + ); + const variants = + reduced.length === motion.length ? [motion.slice()] : [motion.slice(), reduced]; + variantCache.set(key, variants); + return variants; +} + +/** Strict backwards subsequence match of one exact direction sequence. */ +function matchesExact(dirs: readonly number[], motion: readonly number[]): boolean { + let m = motion.length - 1; + for (let i = dirs.length - 1; i >= 0 && m >= 0; i--) { + if (dirs[i] === motion[m]) { + m--; + } + } + return m < 0; +} + +/** + * @param dirs directions from the recent window, oldest first + * @param motion required numpad sequence, e.g. [2,3,6] + */ +export function matchesMotion(dirs: readonly number[], motion: readonly number[]): boolean { + if (motion.length === 0) { + return true; + } + for (const variant of variantsOf(motion)) { + if (matchesExact(dirs, variant)) { + return true; + } + } + return false; +} + +/** Whether a currently-held direction satisfies a move's `holdDir` requirement. */ +export function matchesHold(currentDir: number, required: number): boolean { + if (required === 0) { + return true; + } + const accepted = HOLD_EQUIVALENTS[required]; + return accepted ? accepted.includes(currentDir) : currentDir === required; +} + +/** + * Detect a double-tap of `dir` (the universal dash input) within `window` + * frames: two separate presses of the direction separated by a release. + */ +export function isDoubleTap(dirs: readonly number[], dir: number, window: number): boolean { + let taps = 0; + let wasHeld = false; + const start = Math.max(0, dirs.length - window); + for (let i = start; i < dirs.length; i++) { + const held = matchesHold(dirs[i] ?? 5, dir); + if (held && !wasHeld) { + taps++; + } + wasHeld = held; + } + return taps >= 2; +} diff --git a/ascendants/src/engine/match/rounds.test.ts b/ascendants/src/engine/match/rounds.test.ts new file mode 100644 index 0000000..d7c8fc8 --- /dev/null +++ b/ascendants/src/engine/match/rounds.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from 'vitest'; +import { FINISHER_WINDOW_FRAMES, INTRO_FRAMES, ROUND_END_FRAMES } from '../data/constants'; +import { finisherCallout } from '../data/finishers'; +import { vec3 } from '../math/vec3'; +import { Button, NEUTRAL_INPUT, type FrameInputs, type PlayerInput } from '../types/input'; +import type { MatchConfig, MatchState } from '../types/match'; +import { createMatch, defaultConfig } from '../sim/init'; +import { step } from '../sim/step'; + +const config: MatchConfig = defaultConfig(); + +function press(dir: number, buttons = 0): PlayerInput { + return { dir, buttons }; +} + +function pair(a: PlayerInput = NEUTRAL_INPUT, b: PlayerInput = NEUTRAL_INPUT): FrameInputs { + return [a, b]; +} + +function run(state: MatchState, frames: number, script: (f: number) => FrameInputs): MatchState { + let current = state; + for (let i = 0; i < frames; i++) { + current = step(current, script(i), config); + } + return current; +} + +function toFightPhase(state: MatchState): MatchState { + return run(state, INTRO_FRAMES + 1, () => pair()); +} + +describe('round flow', () => { + it('leaves the intro and starts the round', () => { + const state = toFightPhase(createMatch(config)); + expect(state.phase).toBe('fight'); + }); + + it('counts the round timer down while fighting', () => { + const state = run(toFightPhase(createMatch(config)), 60, () => pair()); + expect(state.timer).toBeLessThan(config.roundTimeFrames); + }); + + it('awards a round on a knockout without ending the match', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[1].health = 0; + state = run(state, 1, () => pair()); + + expect(state.phase).toBe('roundEnd'); + expect(state.fighters[0].wins).toBe(1); + expect(state.finisher.window).toBe(0); + }); + + it('starts the next round after the round-end pause', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[1].health = 0; + state = run(state, ROUND_END_FRAMES + 2, () => pair()); + + expect(state.round).toBe(2); + expect(state.phase).toBe('intro'); + expect(state.fighters[1].health).toBe(state.fighters[1].maxHealth); + }); + + it('carries wins and a spent Fatal Blow across rounds', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[0].fatalUsed = true; + state.fighters[1].health = 0; + state = run(state, ROUND_END_FRAMES + 2, () => pair()); + + expect(state.fighters[0].wins).toBe(1); + expect(state.fighters[0].fatalUsed).toBe(true); + }); + + it('opens the finisher window on a match-ending blow', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[0].wins = 1; + state.fighters[1].health = 0; + state = run(state, 1, () => pair()); + + expect(state.phase).toBe('finisher'); + expect(state.finisher.performer).toBe(0); + expect(state.finisher.window).toBeGreaterThan(0); + // The loser is left standing, not dropped. + expect(state.fighters[1].state).toBe('dazed'); + expect(state.fighters[1].health).toBe(1); + }); + + it('ends the match normally when the finisher window lapses', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[0].wins = 1; + state.fighters[1].health = 0; + state = run(state, FINISHER_WINDOW_FRAMES + 3, () => pair()); + + expect(state.phase).toBe('matchEnd'); + expect(state.finisher.performed).toBeNull(); + }); + + it('performs an Erasure when the motion lands in range', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[0].wins = 1; + state.fighters[1].health = 0; + state = run(state, 1, () => pair()); + expect(state.phase).toBe('finisher'); + + // Close the distance, then enter Vanta's Erasure: quarter-circle back + Ki. + state.fighters[0].pos = vec3(0, 0, -0.9); + state.fighters[1].pos = vec3(0, 0, 0.9); + const script: PlayerInput[] = [ + press(2), + press(1), + press(4), + press(4, Button.Ki), + ]; + state = run(state, script.length, (f) => pair(script[f] ?? NEUTRAL_INPUT)); + + expect(state.phase).toBe('erasure'); + expect(state.finisher.performed).toBe('erasure.vanta'); + expect(state.fighters[1].state).toBe('erased'); + expect(state.fighters[1].health).toBe(0); + }); + + it('refuses the Erasure from out of range', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[0].wins = 1; + state.fighters[1].health = 0; + state = run(state, 1, () => pair()); + + state.fighters[0].pos = vec3(0, 0, -9); + state.fighters[1].pos = vec3(0, 0, 9); + const script: PlayerInput[] = [press(2), press(1), press(4), press(4, Button.Ki)]; + state = run(state, script.length, (f) => pair(script[f] ?? NEUTRAL_INPUT)); + + expect(state.phase).toBe('finisher'); + expect(state.finisher.performed).toBeNull(); + }); + + it('addresses each fighter with their own pronoun', () => { + expect(finisherCallout('vanta')).toBe('PUT HER UNDER.'); + expect(finisherCallout('korvath')).toBe('PUT HIM UNDER.'); + expect(finisherCallout('marrow')).toBe('PUT THEM UNDER.'); + }); + + it('awards a timeout round to the healthier fighter', () => { + let state = toFightPhase(createMatch(config)); + state.timer = 1; + state.fighters[0].health = 500; + state.fighters[1].health = 200; + state = run(state, 2, () => pair()); + + expect(state.lastRoundWinner).toBe(0); + expect(state.fighters[0].wins).toBe(1); + }); + + it('awards nobody the round on an exactly tied timeout', () => { + let state = toFightPhase(createMatch(config)); + state.timer = 1; + state.fighters[0].health = state.fighters[0].maxHealth; + state.fighters[1].health = state.fighters[1].maxHealth; + state = run(state, 2, () => pair()); + + expect(state.lastRoundWinner).toBe(-1); + expect(state.fighters[0].wins).toBe(0); + expect(state.fighters[1].wins).toBe(0); + }); +}); diff --git a/ascendants/src/engine/match/rounds.ts b/ascendants/src/engine/match/rounds.ts new file mode 100644 index 0000000..b4c8aa9 --- /dev/null +++ b/ascendants/src/engine/match/rounds.ts @@ -0,0 +1,198 @@ +import { + ERASURE_FRAMES, + FINISHER_RANGE, + FINISHER_WINDOW_FRAMES, + INTRO_FRAMES, + MATCH_END_FRAMES, + ROUND_END_FRAMES, +} from '../data/constants'; +import { erasureFor } from '../data/finishers'; +import { recentDirs, pressedWithin } from '../input/buffer'; +import { matchesMotion } from '../input/motion'; +import { MOTION_WINDOW } from '../input/motion'; +import { distanceXZ } from '../math/vec3'; +import type { Fighter } from '../types/fighter'; +import type { MatchConfig, MatchState } from '../types/match'; +import { latestInput } from '../input/buffer'; +import { resetRound } from '../sim/init'; + +/** + * Round flow and the Erasure window. + * + * A match-ending blow does not end the match. It leaves the loser dazed and + * upright while the announcer calls PUT HER/HIM/THEM UNDER, and the winner gets + * a window to land their Erasure. Letting the window lapse ends the match + * normally — the finisher is an option the player takes, never a cutscene the + * game plays at them. + */ + +function loserOf(state: MatchState): number { + const [a, b] = state.fighters; + if (a.health <= 0) { + return 0; + } + return b.health <= 0 ? 1 : -1; +} + +/** On timeout the healthier fighter takes the round; equal health is a draw. */ +function timeoutWinner(state: MatchState): number { + const [a, b] = state.fighters; + const ratioA = a.health / a.maxHealth; + const ratioB = b.health / b.maxHealth; + if (ratioA === ratioB) { + return -1; + } + return ratioA > ratioB ? 0 : 1; +} + +function matchPointFor(state: MatchState, winner: number, config: MatchConfig): boolean { + const fighter = state.fighters[winner === 0 ? 0 : 1]; + return fighter.wins + 1 >= config.roundsToWin; +} + +function beginRoundEnd(state: MatchState, winner: number): void { + state.lastRoundWinner = winner; + if (winner >= 0) { + state.fighters[winner === 0 ? 0 : 1].wins += 1; + } + state.phase = 'roundEnd'; + state.phaseFrame = 0; +} + +/** Open the Erasure window: the loser is left standing rather than dropped. */ +function beginFinisherWindow(state: MatchState, winner: number, loser: number): void { + state.lastRoundWinner = winner; + state.fighters[winner === 0 ? 0 : 1].wins += 1; + + const victim = state.fighters[loser === 0 ? 0 : 1]; + victim.state = 'dazed'; + victim.stateFrame = 0; + victim.stun = 0; + victim.moveId = null; + victim.vel = { x: 0, y: 0, z: 0 }; + victim.health = 1; + + state.phase = 'finisher'; + state.phaseFrame = 0; + state.finisher = { window: FINISHER_WINDOW_FRAMES, performer: winner, performed: null }; + state.effects.push({ kind: 'daze', pos: victim.pos, power: 1, owner: victim.index }); +} + +function tryErasure(state: MatchState, performer: Fighter, victim: Fighter): boolean { + const erasure = erasureFor(performer.characterId); + if (!erasure) { + return false; + } + if (distanceXZ(performer.pos, victim.pos) > FINISHER_RANGE || !performer.grounded) { + return false; + } + const current = latestInput(performer.inputHistory); + if ((current.buttons & erasure.button) !== erasure.button) { + return false; + } + if (!pressedWithin(performer.inputHistory, erasure.button, 4)) { + return false; + } + if (!matchesMotion(recentDirs(performer.inputHistory, MOTION_WINDOW), erasure.motion)) { + return false; + } + + victim.state = 'erased'; + victim.stateFrame = 0; + victim.health = 0; + state.finisher = { window: 0, performer: performer.index, performed: erasure.id }; + state.phase = 'erasure'; + state.phaseFrame = 0; + state.effects.push({ kind: 'erasure', pos: victim.pos, power: 2, owner: performer.index }); + return true; +} + +function matchOver(state: MatchState, config: MatchConfig): boolean { + return state.fighters.some((f) => f.wins >= config.roundsToWin); +} + +/** Advance whichever phase the match is in. Returns true if the round is live. */ +export function tickMatchFlow(state: MatchState, config: MatchConfig): boolean { + state.phaseFrame += 1; + + switch (state.phase) { + case 'intro': + if (state.phaseFrame >= INTRO_FRAMES) { + state.phase = 'fight'; + state.phaseFrame = 0; + state.lastRoundWinner = -1; + } + return false; + + case 'fight': { + state.timer = Math.max(0, state.timer - 1); + const loser = loserOf(state); + if (loser >= 0) { + const winner = loser === 0 ? 1 : 0; + state.fighters[loser === 0 ? 0 : 1].state = 'defeated'; + if (matchPointFor(state, winner, config)) { + state.fighters[loser === 0 ? 0 : 1].state = 'dazed'; + beginFinisherWindow(state, winner, loser); + } else { + beginRoundEnd(state, winner); + } + return false; + } + if (state.timer <= 0) { + const winner = timeoutWinner(state); + if (winner >= 0 && matchPointFor(state, winner, config)) { + beginFinisherWindow(state, winner, winner === 0 ? 1 : 0); + } else { + beginRoundEnd(state, winner); + } + return false; + } + return true; + } + + case 'finisher': { + state.finisher.window -= 1; + const performerIndex = state.finisher.performer; + if (performerIndex >= 0) { + const performer = state.fighters[performerIndex === 0 ? 0 : 1]; + const victim = state.fighters[performerIndex === 0 ? 1 : 0]; + if (tryErasure(state, performer, victim)) { + return false; + } + } + if (state.finisher.window <= 0) { + state.phase = 'matchEnd'; + state.phaseFrame = 0; + } + // The winner may still move during the window, so the round stays live. + return true; + } + + case 'erasure': + if (state.phaseFrame >= ERASURE_FRAMES) { + state.phase = 'matchEnd'; + state.phaseFrame = 0; + } + return false; + + case 'roundEnd': + if (state.phaseFrame >= ROUND_END_FRAMES) { + if (matchOver(state, config)) { + state.phase = 'matchEnd'; + state.phaseFrame = 0; + } else { + state.round += 1; + resetRound(state, config); + } + } + return false; + + case 'matchEnd': + return false; + + default: + return false; + } +} + +export const MATCH_END_LENGTH = MATCH_END_FRAMES; diff --git a/ascendants/src/engine/math/index.ts b/ascendants/src/engine/math/index.ts new file mode 100644 index 0000000..4f278e8 --- /dev/null +++ b/ascendants/src/engine/math/index.ts @@ -0,0 +1,4 @@ +export * from './trig'; +export * from './scalar'; +export * from './vec3'; +export * from './rng'; diff --git a/ascendants/src/engine/math/rng.test.ts b/ascendants/src/engine/math/rng.test.ts new file mode 100644 index 0000000..5838304 --- /dev/null +++ b/ascendants/src/engine/math/rng.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { cloneRng, nextFloat, nextInt, nextUint32, seedRng } from './rng'; + +describe('seeded rng', () => { + it('reproduces the same stream for the same seed', () => { + const a = seedRng(12345); + const b = seedRng(12345); + for (let i = 0; i < 200; i++) { + expect(nextUint32(a)).toBe(nextUint32(b)); + } + }); + + it('diverges immediately for adjacent seeds', () => { + const a = seedRng(1); + const b = seedRng(2); + expect(nextUint32(a)).not.toBe(nextUint32(b)); + }); + + it('produces floats inside [0, 1)', () => { + const state = seedRng(99); + for (let i = 0; i < 1000; i++) { + const value = nextFloat(state); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThan(1); + } + }); + + it('keeps integers inside the requested range', () => { + const state = seedRng(7); + for (let i = 0; i < 1000; i++) { + const value = nextInt(state, 3, 9); + expect(value).toBeGreaterThanOrEqual(3); + expect(value).toBeLessThanOrEqual(9); + } + }); + + it('resumes identically from a cloned state — the rollback case', () => { + const original = seedRng(555); + for (let i = 0; i < 50; i++) { + nextUint32(original); + } + const snapshot = cloneRng(original); + const afterOriginal = [nextUint32(original), nextUint32(original), nextUint32(original)]; + const afterSnapshot = [nextUint32(snapshot), nextUint32(snapshot), nextUint32(snapshot)]; + expect(afterSnapshot).toEqual(afterOriginal); + }); +}); diff --git a/ascendants/src/engine/math/rng.ts b/ascendants/src/engine/math/rng.ts new file mode 100644 index 0000000..872cfa9 --- /dev/null +++ b/ascendants/src/engine/math/rng.ts @@ -0,0 +1,54 @@ +/** + * Seeded xorshift128 PRNG. + * + * WHY: Math.random cannot appear anywhere in the simulation — rollback + * re-simulates the same frame many times and every replay must produce the + * same numbers. This generator's state is four uint32s, so it is trivially + * snapshot-and-restored alongside the rest of the game state, and it uses only + * exactly-specified integer operations. + */ + +export type RngState = { a: number; b: number; c: number; d: number }; + +/** Build a generator state from any integer seed. */ +export function seedRng(seed: number): RngState { + // splitmix-style avalanche so that adjacent seeds diverge immediately. + let h = seed >>> 0; + const next = (): number => { + h = (h + 0x6d2b79f5) >>> 0; + let t = h; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return (t ^ (t >>> 14)) >>> 0; + }; + return { a: next(), b: next(), c: next(), d: next() || 1 }; +} + +export function cloneRng(state: RngState): RngState { + return { a: state.a, b: state.b, c: state.c, d: state.d }; +} + +/** Advance the state in place and return the raw uint32 draw. */ +export function nextUint32(state: RngState): number { + const t = state.d ^ (state.d << 11); + state.d = state.c; + state.c = state.b; + state.b = state.a; + state.a = (state.a ^ (state.a >>> 19) ^ (t ^ (t >>> 8))) >>> 0; + return state.a; +} + +/** Uniform float in [0, 1). */ +export function nextFloat(state: RngState): number { + return nextUint32(state) / 4294967296; +} + +/** Uniform float in [min, max). */ +export function nextRange(state: RngState, min: number, max: number): number { + return min + nextFloat(state) * (max - min); +} + +/** Uniform integer in [min, max] inclusive. */ +export function nextInt(state: RngState, min: number, max: number): number { + return min + Math.floor(nextFloat(state) * (max - min + 1)); +} diff --git a/ascendants/src/engine/math/scalar.ts b/ascendants/src/engine/math/scalar.ts new file mode 100644 index 0000000..bf6df5c --- /dev/null +++ b/ascendants/src/engine/math/scalar.ts @@ -0,0 +1,43 @@ +/** Scalar helpers shared by every simulation subsystem. */ + +export function clamp(value: number, min: number, max: number): number { + return value < min ? min : value > max ? max : value; +} + +export function clamp01(value: number): number { + return clamp(value, 0, 1); +} + +export function lerp(a: number, b: number, t: number): number { + return a + (b - a) * clamp01(t); +} + +export function sign(value: number): number { + return value < 0 ? -1 : value > 0 ? 1 : 0; +} + +/** Step `current` toward `target` by at most `maxDelta`. */ +export function approach(current: number, target: number, maxDelta: number): number { + const diff = target - current; + if (diff > maxDelta) { + return current + maxDelta; + } + if (diff < -maxDelta) { + return current - maxDelta; + } + return target; +} + +/** Map `value` from [inMin, inMax] onto [outMin, outMax], clamped. */ +export function remap( + value: number, + inMin: number, + inMax: number, + outMin: number, + outMax: number, +): number { + if (inMax === inMin) { + return outMin; + } + return lerp(outMin, outMax, (value - inMin) / (inMax - inMin)); +} diff --git a/ascendants/src/engine/math/trig.test.ts b/ascendants/src/engine/math/trig.test.ts new file mode 100644 index 0000000..0fccf1d --- /dev/null +++ b/ascendants/src/engine/math/trig.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { atan2, cos, HALF_PI, PI, rotateToward, sin, TWO_PI, wrapAngle } from './trig'; + +describe('deterministic trig', () => { + it('matches Math.sin closely across a full turn', () => { + for (let i = -720; i <= 720; i += 7) { + const angle = (i / 720) * TWO_PI * 2; + expect(sin(angle)).toBeCloseTo(Math.sin(angle), 9); + } + }); + + it('matches Math.cos closely across a full turn', () => { + for (let i = -360; i <= 360; i += 5) { + const angle = (i / 360) * TWO_PI; + expect(cos(angle)).toBeCloseTo(Math.cos(angle), 9); + } + }); + + it('hits the cardinal values exactly enough to trust', () => { + expect(sin(0)).toBeCloseTo(0, 12); + expect(sin(HALF_PI)).toBeCloseTo(1, 10); + expect(cos(0)).toBeCloseTo(1, 10); + expect(cos(PI)).toBeCloseTo(-1, 10); + }); + + it('resolves atan2 in every quadrant', () => { + const cases: Array<[number, number]> = [ + [1, 1], [1, -1], [-1, -1], [-1, 1], [0, 1], [0, -1], [1, 0], [-1, 0], [3, 4], [-7, 2], + ]; + for (const [y, x] of cases) { + expect(atan2(y, x)).toBeCloseTo(Math.atan2(y, x), 4); + } + }); + + it('returns a stable zero for the degenerate origin', () => { + expect(atan2(0, 0)).toBe(0); + }); + + it('is bit-identical when called repeatedly — the rollback requirement', () => { + const first: number[] = []; + const second: number[] = []; + for (let i = 0; i < 500; i++) { + first.push(sin(i * 0.37), cos(i * 0.37), atan2(i - 250, 17)); + } + for (let i = 0; i < 500; i++) { + second.push(sin(i * 0.37), cos(i * 0.37), atan2(i - 250, 17)); + } + expect(first).toEqual(second); + }); + + it('wraps angles into [-PI, PI]', () => { + expect(wrapAngle(3 * TWO_PI + 0.5)).toBeCloseTo(0.5, 10); + expect(wrapAngle(-3 * TWO_PI - 0.5)).toBeCloseTo(-0.5, 10); + expect(Math.abs(wrapAngle(12.3))).toBeLessThanOrEqual(PI); + }); + + it('rotates the short way around the wrap point', () => { + // From just under +PI to just over -PI is a small step, not a near-full turn. + const from = PI - 0.05; + const to = -PI + 0.05; + const result = rotateToward(from, to, 0.2); + expect(Math.abs(wrapAngle(result - to))).toBeLessThan(1e-9); + }); + + it('clamps rotation to the step size', () => { + expect(rotateToward(0, PI / 2, 0.1)).toBeCloseTo(0.1, 10); + expect(rotateToward(0, -PI / 2, 0.1)).toBeCloseTo(-0.1, 10); + }); + + it('snaps to the target when it is within one step', () => { + expect(rotateToward(1, 1.05, 0.2)).toBeCloseTo(1.05, 10); + }); + + it('breaks the antipodal tie the same way every time', () => { + // Turning to face exactly behind you has no short way round. Either + // direction is correct; being consistent about which is what matters. + const results = Array.from({ length: 5 }, () => rotateToward(0, PI, 0.1)); + expect(new Set(results).size).toBe(1); + expect(Math.abs(results[0] as number)).toBeCloseTo(0.1, 10); + }); +}); diff --git a/ascendants/src/engine/math/trig.ts b/ascendants/src/engine/math/trig.ts new file mode 100644 index 0000000..c673091 --- /dev/null +++ b/ascendants/src/engine/math/trig.ts @@ -0,0 +1,111 @@ +/** + * Deterministic trigonometry. + * + * WHY: rollback netcode re-simulates past frames independently on every peer + * and demands bit-identical results. IEEE-754 exactly specifies + - * / and + * sqrt, but Math.sin/cos/atan2 are implementation-defined and drift between + * JS engines, CPU architectures and browser versions. A single ULP of drift in + * a facing angle compounds into a desync within seconds. Every angle in the + * simulation therefore routes through these polynomial approximations, which + * use only exactly-specified operations (Math.round and Math.abs are also + * exactly specified, so they are safe). + */ + +export const PI = 3.141592653589793; +export const TWO_PI = 6.283185307179586; +export const HALF_PI = 1.5707963267948966; + +// Reciprocal factorials for the odd Taylor series of sin. Degree 15 on the +// reduced domain [-PI/2, PI/2] holds absolute error under 1e-11 — orders of +// magnitude tighter than any gameplay tolerance, and cheap at 7 multiplies. +const R3 = 1 / 6; +const R5 = 1 / 120; +const R7 = 1 / 5040; +const R9 = 1 / 362880; +const R11 = 1 / 39916800; +const R13 = 1 / 6227020800; +const R15 = 1 / 1307674368000; + +/** sin for arguments already reduced to [-PI/2, PI/2]. */ +function sinCore(x: number): number { + const u = x * x; + const series = + 1 - u * (R3 - u * (R5 - u * (R7 - u * (R9 - u * (R11 - u * (R13 - u * R15)))))); + return x * series; +} + +export function sin(x: number): number { + // Fold to [-PI, PI], then mirror the outer quadrants into [-PI/2, PI/2] + // where the series is most accurate. + let a = x - TWO_PI * Math.round(x / TWO_PI); + if (a > HALF_PI) { + a = PI - a; + } else if (a < -HALF_PI) { + a = -PI - a; + } + return sinCore(a); +} + +export function cos(x: number): number { + return sin(x + HALF_PI); +} + +// Minimax coefficients for atan on [0, 1]; max error ~1e-5 rad (0.0006 deg), +// which is imperceptible for facing and aim angles. +const A1 = 0.999866; +const A3 = -0.3302995; +const A5 = 0.180141; +const A7 = -0.085133; +const A9 = 0.0208351; + +/** atan for a ratio in [0, 1]. */ +function atanCore(z: number): number { + const u = z * z; + return z * (A1 + u * (A3 + u * (A5 + u * (A7 + u * A9)))); +} + +export function atan2(y: number, x: number): number { + if (x === 0 && y === 0) { + return 0; + } + const ax = Math.abs(x); + const ay = Math.abs(y); + // Keep the ratio inside [0, 1] so the polynomial stays in its fitted domain. + let r = ax >= ay ? atanCore(ay / ax) : HALF_PI - atanCore(ax / ay); + if (x < 0) { + r = PI - r; + } + return y < 0 ? -r : r; +} + +/** + * Fold any angle into [-PI, PI]. + * + * Exactly +PI maps to -PI, because Math.round breaks a .5 tie upward. The two + * represent the same direction, so the choice is arbitrary — but it is fixed + * and identical on every machine, which is the only property rollback needs. + */ +export function wrapAngle(a: number): number { + return a - TWO_PI * Math.round(a / TWO_PI); +} + +/** Shortest signed rotation from `from` to `to`. */ +export function angleDelta(from: number, to: number): number { + return wrapAngle(to - from); +} + +/** + * Rotate `from` toward `to` by at most `maxStep` radians, taking the short way + * around. For an exactly antipodal target both ways are equally short and the + * turn resolves negative — arbitrary, but deterministic. + */ +export function rotateToward(from: number, to: number, maxStep: number): number { + const delta = angleDelta(from, to); + if (delta > maxStep) { + return wrapAngle(from + maxStep); + } + if (delta < -maxStep) { + return wrapAngle(from - maxStep); + } + return wrapAngle(to); +} diff --git a/ascendants/src/engine/math/vec3.ts b/ascendants/src/engine/math/vec3.ts new file mode 100644 index 0000000..1b4eef3 --- /dev/null +++ b/ascendants/src/engine/math/vec3.ts @@ -0,0 +1,76 @@ +/** Immutable 3D vector helpers. Y is up; the arena floor sits at y = 0. */ + +export type Vec3 = { readonly x: number; readonly y: number; readonly z: number }; + +export const ZERO: Vec3 = { x: 0, y: 0, z: 0 }; + +export function vec3(x: number, y: number, z: number): Vec3 { + return { x, y, z }; +} + +export function add(a: Vec3, b: Vec3): Vec3 { + return { x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }; +} + +export function sub(a: Vec3, b: Vec3): Vec3 { + return { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }; +} + +export function scale(v: Vec3, s: number): Vec3 { + return { x: v.x * s, y: v.y * s, z: v.z * s }; +} + +export function dot(a: Vec3, b: Vec3): number { + return a.x * b.x + a.y * b.y + a.z * b.z; +} + +export function lengthSq(v: Vec3): number { + return v.x * v.x + v.y * v.y + v.z * v.z; +} + +export function length(v: Vec3): number { + // Math.sqrt is correctly rounded per IEEE-754, so it is rollback-safe. + return Math.sqrt(lengthSq(v)); +} + +export function distanceSq(a: Vec3, b: Vec3): number { + const dx = b.x - a.x; + const dy = b.y - a.y; + const dz = b.z - a.z; + return dx * dx + dy * dy + dz * dz; +} + +export function distance(a: Vec3, b: Vec3): number { + return Math.sqrt(distanceSq(a, b)); +} + +/** Horizontal (XZ) distance — the plane that drives spacing and lock-on. */ +export function distanceXZ(a: Vec3, b: Vec3): number { + const dx = b.x - a.x; + const dz = b.z - a.z; + return Math.sqrt(dx * dx + dz * dz); +} + +export function normalize(v: Vec3): Vec3 { + const len = length(v); + return len === 0 ? ZERO : scale(v, 1 / len); +} + +/** Clamp a vector's magnitude without changing its direction. */ +export function clampLength(v: Vec3, max: number): Vec3 { + const len = length(v); + return len <= max || len === 0 ? v : scale(v, max / len); +} + +export function lerpVec3(a: Vec3, b: Vec3, t: number): Vec3 { + const k = t < 0 ? 0 : t > 1 ? 1 : t; + return { + x: a.x + (b.x - a.x) * k, + y: a.y + (b.y - a.y) * k, + z: a.z + (b.z - a.z) * k, + }; +} + +export function withY(v: Vec3, y: number): Vec3 { + return { x: v.x, y, z: v.z }; +} diff --git a/ascendants/src/engine/sim/clone.ts b/ascendants/src/engine/sim/clone.ts new file mode 100644 index 0000000..a173aae --- /dev/null +++ b/ascendants/src/engine/sim/clone.ts @@ -0,0 +1,37 @@ +import type { Fighter } from '../types/fighter'; +import type { MatchState, Projectile } from '../types/match'; + +/** + * Snapshot copying for rollback. + * + * Vec3 values are treated as immutable and shared by reference rather than + * copied: every write site in the simulation replaces the whole vector instead + * of mutating a component, so two snapshots can safely point at the same + * position object. That turns the hottest allocation in the rollback path into + * a pointer copy. + */ + +export function cloneFighter(f: Fighter): Fighter { + return { + ...f, + chain: f.chain.slice(), + connected: f.connected.slice(), + inputHistory: f.inputHistory.slice(), + }; +} + +export function cloneProjectile(p: Projectile): Projectile { + return { ...p }; +} + +export function cloneState(state: MatchState): MatchState { + return { + ...state, + fighters: [cloneFighter(state.fighters[0]), cloneFighter(state.fighters[1])], + projectiles: state.projectiles.map(cloneProjectile), + // Effects are render-only and rebuilt every frame, so a snapshot starts empty. + effects: [], + rng: { ...state.rng }, + finisher: { ...state.finisher }, + }; +} diff --git a/ascendants/src/engine/sim/hash.ts b/ascendants/src/engine/sim/hash.ts new file mode 100644 index 0000000..284c455 --- /dev/null +++ b/ascendants/src/engine/sim/hash.ts @@ -0,0 +1,96 @@ +import type { Fighter } from '../types/fighter'; +import type { MatchState } from '../types/match'; + +/** + * State checksum for desync detection. + * + * Rollback's failure mode is silent: two peers drift apart and keep playing + * different games. Hashing the confirmed state every frame and comparing the + * value with the opponent turns that into a loud, immediate error. + * + * Floats are hashed by their exact bit pattern rather than a rounded value — + * the whole point is to catch a one-ULP divergence on the frame it happens, + * not after it has grown large enough to see. + */ + +const scratch = new Float64Array(1); +const scratchBytes = new Uint32Array(scratch.buffer); + +const FNV_OFFSET = 0x811c9dc5; +const FNV_PRIME = 0x01000193; + +function mix(hash: number, value: number): number { + return Math.imul(hash ^ (value >>> 0), FNV_PRIME) >>> 0; +} + +function mixFloat(hash: number, value: number): number { + scratch[0] = value; + return mix(mix(hash, scratchBytes[0] ?? 0), scratchBytes[1] ?? 0); +} + +function mixString(hash: number, value: string): number { + let h = hash; + for (let i = 0; i < value.length; i++) { + h = mix(h, value.charCodeAt(i)); + } + return h; +} + +function mixFighter(hash: number, f: Fighter): number { + let h = mixString(hash, f.characterId); + h = mixFloat(h, f.pos.x); + h = mixFloat(h, f.pos.y); + h = mixFloat(h, f.pos.z); + h = mixFloat(h, f.vel.x); + h = mixFloat(h, f.vel.y); + h = mixFloat(h, f.vel.z); + h = mixFloat(h, f.facing); + h = mixString(h, f.state); + h = mixString(h, f.moveId ?? '-'); + h = mixFloat(h, f.health); + h = mixFloat(h, f.ki); + h = mixFloat(h, f.drive); + h = mixFloat(h, f.superMeter); + h = mix(h, f.stateFrame); + h = mix(h, f.moveFrame); + h = mix(h, f.stun); + h = mix(h, f.hitstop); + h = mix(h, f.juggle); + h = mix(h, f.comboHits); + h = mix(h, f.burnout); + h = mix(h, f.armor); + h = mix(h, f.wins); + h = mix(h, f.grounded ? 1 : 0); + h = mix(h, f.flying ? 1 : 0); + h = mix(h, f.transformed ? 1 : 0); + h = mix(h, f.fatalUsed ? 1 : 0); + for (const id of f.connected) { + h = mix(h, id); + } + return h; +} + +export function checksum(state: MatchState): number { + let h = FNV_OFFSET; + h = mix(h, state.frame); + h = mixString(h, state.phase); + h = mix(h, state.phaseFrame); + h = mix(h, state.round); + h = mix(h, state.timer); + h = mix(h, state.finisher.window); + h = mix(h, state.finisher.performer + 1); + h = mixString(h, state.finisher.performed ?? '-'); + h = mixFighter(h, state.fighters[0]); + h = mixFighter(h, state.fighters[1]); + for (const p of state.projectiles) { + h = mix(h, p.id); + h = mixFloat(h, p.pos.x); + h = mixFloat(h, p.pos.y); + h = mixFloat(h, p.pos.z); + h = mix(h, p.life); + h = mix(h, p.clashWith + 1); + } + h = mix(h, state.rng.a); + h = mix(h, state.rng.d); + return h >>> 0; +} diff --git a/ascendants/src/engine/sim/init.ts b/ascendants/src/engine/sim/init.ts new file mode 100644 index 0000000..05bde24 --- /dev/null +++ b/ascendants/src/engine/sim/init.ts @@ -0,0 +1,123 @@ +import { + DRIVE_MAX, + INTRO_FRAMES, + ROUND_TIME_FRAMES, + ROUNDS_TO_WIN, + START_DISTANCE, +} from '../data/constants'; +import { getCharacter } from '../data/characters'; +import { seedRng } from '../math/rng'; +import { vec3 } from '../math/vec3'; +import { PI } from '../math/trig'; +import type { Fighter } from '../types/fighter'; +import type { MatchConfig, MatchState } from '../types/match'; + +/** + * State construction. + * + * Everything the simulation touches is created here, in plain data. There is no + * hidden initialisation elsewhere, which is what lets a rollback snapshot be a + * simple structural copy rather than a bespoke serializer per subsystem. + */ + +const STARTING_KI = 25; + +export function createFighter(index: 0 | 1, characterId: string): Fighter { + const character = getCharacter(characterId); + const z = index === 0 ? -START_DISTANCE / 2 : START_DISTANCE / 2; + return { + index, + characterId, + pos: vec3(0, 0, z), + vel: vec3(0, 0, 0), + // Fighter 0 looks along +z, fighter 1 back along -z. + facing: index === 0 ? 0 : PI, + state: 'idle', + stateFrame: 0, + moveId: null, + moveFrame: 0, + chain: [], + connected: [], + health: character.stats.maxHealth, + maxHealth: character.stats.maxHealth, + ki: STARTING_KI, + drive: DRIVE_MAX, + superMeter: 0, + burnout: 0, + stun: 0, + hitstop: 0, + juggle: 0, + comboHits: 0, + comboDamage: 0, + grounded: true, + flying: false, + transformed: false, + transformFrames: 0, + fatalUsed: false, + vanishCooldown: 0, + parryHold: 0, + armor: 0, + wins: 0, + dashForward: 0, + dashStrafe: 0, + inputHistory: [], + }; +} + +export function defaultConfig(overrides: Partial = {}): MatchConfig { + return { + roundsToWin: ROUNDS_TO_WIN, + roundTimeFrames: ROUND_TIME_FRAMES, + stageId: 'crater', + seed: 1, + characterIds: ['vanta', 'korvath'], + ...overrides, + }; +} + +export function createMatch(config: MatchConfig): MatchState { + return { + frame: 0, + phase: 'intro', + phaseFrame: 0, + round: 1, + timer: config.roundTimeFrames, + fighters: [ + createFighter(0, config.characterIds[0]), + createFighter(1, config.characterIds[1]), + ], + projectiles: [], + effects: [], + rng: seedRng(config.seed), + stageId: config.stageId, + nextProjectileId: 1, + lastRoundWinner: -1, + finisher: { window: 0, performer: -1, performed: null }, + }; +} + +/** + * Reset for the next round. + * + * Super meter and the spent Fatal Blow deliberately persist: both are + * match-scoped comeback resources, and resetting them would erase the story a + * long set is telling. + */ +export function resetRound(state: MatchState, config: MatchConfig): void { + for (const index of [0, 1] as const) { + const previous = state.fighters[index]; + const fresh = createFighter(index, previous.characterId); + fresh.wins = previous.wins; + fresh.superMeter = previous.superMeter; + fresh.fatalUsed = previous.fatalUsed; + state.fighters[index] = fresh; + } + state.projectiles = []; + state.effects = []; + state.timer = config.roundTimeFrames; + state.phase = 'intro'; + state.phaseFrame = 0; + state.finisher = { window: 0, performer: -1, performed: null }; +} + +export const INTRO_LENGTH = INTRO_FRAMES; diff --git a/ascendants/src/engine/sim/step.test.ts b/ascendants/src/engine/sim/step.test.ts new file mode 100644 index 0000000..29d0838 --- /dev/null +++ b/ascendants/src/engine/sim/step.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import { INTRO_FRAMES } from '../data/constants'; +import { vec3 } from '../math/vec3'; +import { Button, NEUTRAL_INPUT, type FrameInputs, type PlayerInput } from '../types/input'; +import type { MatchConfig, MatchState } from '../types/match'; +import { cloneState } from './clone'; +import { checksum } from './hash'; +import { createMatch, defaultConfig } from './init'; +import { step } from './step'; + +const config: MatchConfig = defaultConfig(); + +function press(dir: number, buttons: number): PlayerInput { + return { dir, buttons }; +} + +function pair(a: PlayerInput, b: PlayerInput = NEUTRAL_INPUT): FrameInputs { + return [a, b]; +} + +function run( + state: MatchState, + frames: number, + script: (frame: number) => FrameInputs, +): MatchState { + let current = state; + for (let i = 0; i < frames; i++) { + current = step(current, script(i), config); + } + return current; +} + +/** Advance past the intro so fighters can act. */ +function toFightPhase(state: MatchState): MatchState { + return run(state, INTRO_FRAMES + 1, () => pair(NEUTRAL_INPUT)); +} + +/** A varied but reproducible input script that exercises most subsystems. */ +function busyScript(frame: number): FrameInputs { + const p1 = press( + [5, 6, 6, 2, 3, 6, 5, 4][frame % 8] ?? 5, + frame % 11 === 0 ? Button.Light : frame % 17 === 0 ? Button.Ki : frame % 23 === 0 ? Button.Heavy : 0, + ); + const p2 = press( + [5, 4, 5, 6, 8, 5, 2, 6][frame % 8] ?? 5, + frame % 13 === 0 ? Button.Medium : frame % 19 === 0 ? Button.Guard : 0, + ); + return [p1, p2]; +} + +describe('simulation step', () => { + it('is a pure function — the input state is never mutated', () => { + const state = toFightPhase(createMatch(config)); + const before = checksum(state); + step(state, pair(press(6, Button.Heavy)), config); + expect(checksum(state)).toBe(before); + }); + + it('produces identical results for identical inputs', () => { + const a = run(createMatch(config), 900, busyScript); + const b = run(createMatch(config), 900, busyScript); + expect(checksum(a)).toBe(checksum(b)); + }); + + it('diverges when a single frame of input differs', () => { + const baseline = run(createMatch(config), 400, busyScript); + const altered = run(createMatch(config), 400, (frame) => + frame === 150 ? pair(press(6, Button.Heavy), press(4, Button.Guard)) : busyScript(frame), + ); + expect(checksum(altered)).not.toBe(checksum(baseline)); + }); + + it('replays identically from a mid-match snapshot — the rollback guarantee', () => { + // This is the property the whole netcode rests on: rewinding to a saved + // frame and re-running the same inputs must land on the same state. + let live = createMatch(config); + live = run(live, 300, busyScript); + + const snapshot = cloneState(live); + const straightThrough = run(live, 200, (f) => busyScript(f + 300)); + const fromSnapshot = run(snapshot, 200, (f) => busyScript(f + 300)); + + expect(checksum(fromSnapshot)).toBe(checksum(straightThrough)); + }); + + it('survives a long match without producing NaN or leaving the arena', () => { + const final = run(createMatch(config), 3000, busyScript); + for (const fighter of final.fighters) { + expect(Number.isFinite(fighter.pos.x)).toBe(true); + expect(Number.isFinite(fighter.pos.y)).toBe(true); + expect(Number.isFinite(fighter.pos.z)).toBe(true); + expect(Number.isFinite(fighter.health)).toBe(true); + const radius = Math.hypot(fighter.pos.x, fighter.pos.z); + expect(radius).toBeLessThanOrEqual(27); + expect(fighter.health).toBeGreaterThanOrEqual(0); + expect(fighter.ki).toBeGreaterThanOrEqual(0); + } + }); + + it('keeps fighters from occupying the same space', () => { + const final = run(createMatch(config), 600, () => pair(press(6, 0), press(4, 0))); + const [a, b] = final.fighters; + expect(Math.hypot(a.pos.x - b.pos.x, a.pos.z - b.pos.z)).toBeGreaterThan(1); + }); + + it('lands a jab on an adjacent opponent and deals damage', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[0].pos = vec3(0, 0, -0.8); + state.fighters[1].pos = vec3(0, 0, 0.8); + const startingHealth = state.fighters[1].health; + + state = run(state, 14, () => pair(press(5, Button.Light))); + expect(state.fighters[1].health).toBeLessThan(startingHealth); + }); + + it('deals no damage through a guarding opponent beyond chip', () => { + let state = toFightPhase(createMatch(config)); + state.fighters[0].pos = vec3(0, 0, -0.8); + state.fighters[1].pos = vec3(0, 0, 0.8); + const startingHealth = state.fighters[1].health; + + state = run(state, 14, () => pair(press(5, Button.Light), press(5, Button.Guard))); + // The universal jab has no chip damage, so a clean block costs nothing. + expect(state.fighters[1].health).toBe(startingHealth); + expect(state.fighters[1].drive).toBeLessThan(state.fighters[0].drive); + }); + + it('does not let fighters act during the intro', () => { + const state = run(createMatch(config), 30, () => pair(press(6, Button.Heavy))); + expect(state.fighters[0].state).toBe('idle'); + expect(state.phase).toBe('intro'); + }); +}); diff --git a/ascendants/src/engine/sim/step.ts b/ascendants/src/engine/sim/step.ts new file mode 100644 index 0000000..72f7301 --- /dev/null +++ b/ascendants/src/engine/sim/step.ts @@ -0,0 +1,116 @@ +import { getCharacter } from '../data/characters'; +import { getMove } from '../data/moves'; +import { spawnProjectile, tickProjectiles } from '../combat/projectiles'; +import { resolveMelee } from '../combat/resolve'; +import type { CombatContext } from '../combat/apply'; +import { selectMove, startMove } from '../fighter/actions'; +import { tickLocomotion, tryVanish } from '../fighter/locomotion'; +import { faceOpponent, separate, tickPhysics } from '../fighter/movement'; +import { advanceState, tickTimers } from '../fighter/timers'; +import { pushInput, latestInput } from '../input/buffer'; +import { tickMatchFlow } from '../match/rounds'; +import type { Fighter } from '../types/fighter'; +import type { FrameInputs } from '../types/input'; +import type { MatchConfig, MatchState } from '../types/match'; +import { cloneState } from './clone'; + +/** + * The tick. + * + * `step` is pure: it copies the incoming state, mutates the copy, and returns + * it. Rollback depends on that contract absolutely — the same state and the + * same inputs must always produce the same result, no matter how many times the + * frame is replayed. Nothing in here may read a clock, call Math.random, or + * touch anything outside the arguments. + * + * Order is load-bearing and is documented step by step below; swapping two + * stages changes the game's feel even when nothing desyncs. + */ + +function statsOf(fighter: Fighter) { + return getCharacter(fighter.characterId).stats; +} + +/** Fire a move's projectile on the exact frame its startup completes. */ +function spawnPending(state: MatchState, fighter: Fighter): void { + if (fighter.state !== 'attack' || fighter.moveId === null || fighter.hitstop > 0) { + return; + } + const move = getMove(fighter.moveId); + if (move.projectile && fighter.moveFrame === move.startup) { + spawnProjectile(state, fighter, move.projectile); + } +} + +export function step(previous: MatchState, inputs: FrameInputs, config: MatchConfig): MatchState { + const state = cloneState(previous); + state.frame += 1; + + const ctx: CombatContext = { + stats: [statsOf(state.fighters[0]), statsOf(state.fighters[1])], + effects: state.effects, + }; + + // 1. Record inputs first: everything downstream reads motion history, and a + // move entered this frame must be visible to this frame's selection. + for (const index of [0, 1] as const) { + pushInput(state.fighters[index].inputHistory, inputs[index]); + } + + // 2. Round flow decides whether fighters may act at all. + const live = tickMatchFlow(state, config); + + for (const index of [0, 1] as const) { + const fighter = state.fighters[index]; + const opponent = state.fighters[index === 0 ? 1 : 0]; + const stats = ctx.stats[index]; + + // 3. Timers always run, even between rounds, so meters and stun drain + // while the announcer is talking. + tickTimers(fighter, stats); + + if (!live) { + continue; + } + + const input = latestInput(fighter.inputHistory); + + // 4. The Vanish escape is checked before state resolution: it is the one + // action legal from inside hitstun, and it must beat the stun timer. + if (!tryVanish(fighter, opponent, input, state.effects)) { + advanceState(fighter); + const chosen = selectMove(fighter, input); + if (chosen) { + startMove(fighter, chosen, stats); + } + tickLocomotion(fighter, input, stats); + } + + spawnPending(state, fighter); + } + + // 5. Physics after intent, so a launch applied this frame moves this frame. + for (const fighter of state.fighters) { + tickPhysics(fighter); + } + + // 6. Lock-on. Attacks keep their committed angle so a hitbox cannot be + // steered onto a target mid-active. + for (const index of [0, 1] as const) { + const fighter = state.fighters[index]; + const opponent = state.fighters[index === 0 ? 1 : 0]; + if (fighter.state !== 'attack' && fighter.state !== 'dazed') { + faceOpponent(fighter, opponent.pos.x, opponent.pos.z); + } + } + + // 7. Resolve contact, then projectiles, then push bodies apart last so no + // hitbox check ever runs against an interpenetrated pair. + if (live) { + resolveMelee(state, ctx); + } + tickProjectiles(state, ctx); + separate(state.fighters[0], state.fighters[1]); + + return state; +} diff --git a/ascendants/src/engine/types/fighter.ts b/ascendants/src/engine/types/fighter.ts new file mode 100644 index 0000000..4291f96 --- /dev/null +++ b/ascendants/src/engine/types/fighter.ts @@ -0,0 +1,122 @@ +import type { Vec3 } from '../math/vec3'; + +/** + * The fighter state machine. Attack phases are not separate states: a fighter + * in `attack` carries a moveId plus a frame counter, and the move's own + * startup/active/recovery data decides what is happening. That keeps the + * machine small and makes every attack behave by the same rules. + */ +export type FighterState = + | 'idle' + | 'walk' + | 'dash' + | 'backdash' + | 'jump' + | 'air' + | 'land' + | 'fly' + | 'charge' + | 'attack' + | 'guard' + | 'blockstun' + | 'parry' + | 'hitstun' + | 'launched' + | 'knockdown' + | 'wakeup' + | 'vanish' + | 'transform' + /** Left standing and helpless when a match-ending blow lands — the Erasure window. */ + | 'dazed' + | 'erased' + | 'defeated'; + +/** Per-fighter simulation state. Plain data only, so snapshots are cheap. */ +export type Fighter = { + index: 0 | 1; + characterId: string; + + pos: Vec3; + vel: Vec3; + /** Yaw in radians; 0 faces +z. */ + facing: number; + + state: FighterState; + stateFrame: number; + moveId: string | null; + moveFrame: number; + /** Move ids already used in the current chain, to enforce cancel rules. */ + chain: string[]; + /** Ids of things already hit by the active move, preventing double hits. */ + connected: number[]; + + health: number; + maxHealth: number; + ki: number; + drive: number; + superMeter: number; + + /** > 0 means Burnout: no drive options, chip kills, worse blockstun. */ + burnout: number; + stun: number; + hitstop: number; + + juggle: number; + comboHits: number; + comboDamage: number; + + grounded: boolean; + flying: boolean; + transformed: boolean; + transformFrames: number; + + fatalUsed: boolean; + vanishCooldown: number; + parryHold: number; + armor: number; + + wins: number; + + /** Committed dash direction in fighter-local axes, held for the dash's duration. */ + dashForward: number; + dashStrafe: number; + + /** + * Packed inputs for the last INPUT_HISTORY frames, newest last. + * + * WHY it lives on the fighter rather than in a side buffer: motion inputs are + * state, and rollback must rewind them exactly like position or health. + * A separate buffer would survive a rollback and let a rewound frame + * recognise a dragon punch that never happened. + */ + inputHistory: number[]; +}; + +export const INPUT_HISTORY = 18; + +export const FIGHTER_RADIUS = 0.55; +export const FIGHTER_HEIGHT = 1.8; + +/** Fighters in these states cannot act until the state resolves. */ +export function isBusy(f: Fighter): boolean { + return ( + f.state === 'attack' || + f.state === 'hitstun' || + f.state === 'blockstun' || + f.state === 'launched' || + f.state === 'knockdown' || + f.state === 'wakeup' || + f.state === 'vanish' || + f.state === 'transform' || + f.state === 'defeated' + ); +} + +/** Being hit or blocked — the window where a Vanish escape is legal. */ +export function isReeling(f: Fighter): boolean { + return f.state === 'hitstun' || f.state === 'launched'; +} + +export function isDefeated(f: Fighter): boolean { + return f.health <= 0; +} diff --git a/ascendants/src/engine/types/index.ts b/ascendants/src/engine/types/index.ts new file mode 100644 index 0000000..b1ccde4 --- /dev/null +++ b/ascendants/src/engine/types/index.ts @@ -0,0 +1,4 @@ +export * from './input'; +export * from './move'; +export * from './fighter'; +export * from './match'; diff --git a/ascendants/src/engine/types/input.ts b/ascendants/src/engine/types/input.ts new file mode 100644 index 0000000..4f259da --- /dev/null +++ b/ascendants/src/engine/types/input.ts @@ -0,0 +1,70 @@ +/** + * Player input contract. + * + * `dir` uses numpad notation in TARGET-RELATIVE space: 6 is always "toward the + * opponent", 4 is always "away", 8 ascends, 2 descends/crouches. Resolving + * direction against the lock-on target on the client keeps the simulation free + * of any camera knowledge, and means motion inputs never need side-switch + * mirroring inside the engine. + * + * Both fields are integers so a frame of input packs into 16 bits — small + * enough that rollback can ship several frames per datagram. + */ + +export const Button = { + Light: 1 << 0, + Medium: 1 << 1, + Heavy: 1 << 2, + Ki: 1 << 3, + Guard: 1 << 4, + Drive: 1 << 5, + Jump: 1 << 6, + Dash: 1 << 7, + Charge: 1 << 8, + Vanish: 1 << 9, +} as const; + +export type ButtonMask = number; + +export type PlayerInput = { + /** Numpad 1-9, target-relative. 5 = neutral. */ + readonly dir: number; + readonly buttons: ButtonMask; +}; + +export const NEUTRAL_INPUT: PlayerInput = { dir: 5, buttons: 0 }; + +/** Inputs for both fighters on a single simulated frame. */ +export type FrameInputs = readonly [PlayerInput, PlayerInput]; + +export function isDown(input: PlayerInput, button: number): boolean { + return (input.buttons & button) !== 0; +} + +/** True only on the frame a button transitions from released to pressed. */ +export function isPressed(current: PlayerInput, previous: PlayerInput, button: number): boolean { + return (current.buttons & button) !== 0 && (previous.buttons & button) === 0; +} + +/** Numpad direction decomposed into forward (+toward target) and vertical axes. */ +export function dirForward(dir: number): number { + if (dir === 3 || dir === 6 || dir === 9) { + return 1; + } + return dir === 1 || dir === 4 || dir === 7 ? -1 : 0; +} + +export function dirVertical(dir: number): number { + if (dir >= 7) { + return 1; + } + return dir <= 3 ? -1 : 0; +} + +export function packInput(input: PlayerInput): number { + return (input.dir & 0xf) | ((input.buttons & 0xfff) << 4); +} + +export function unpackInput(packed: number): PlayerInput { + return { dir: packed & 0xf, buttons: (packed >>> 4) & 0xfff }; +} diff --git a/ascendants/src/engine/types/match.ts b/ascendants/src/engine/types/match.ts new file mode 100644 index 0000000..f4ede94 --- /dev/null +++ b/ascendants/src/engine/types/match.ts @@ -0,0 +1,90 @@ +import type { RngState } from '../math/rng'; +import type { Vec3 } from '../math/vec3'; +import type { Fighter } from './fighter'; +import type { HitProps } from './move'; + +export type MatchPhase = + | 'intro' + | 'fight' + | 'roundEnd' + /** The opponent is left standing and helpless: PUT THEM DOWN. */ + | 'finisher' + /** A finisher connected and its cinematic is playing. */ + | 'erasure' + | 'matchEnd'; + +export type Projectile = { + id: number; + owner: 0 | 1; + kind: 'blast' | 'beam'; + pos: Vec3; + vel: Vec3; + radius: number; + life: number; + homing: number; + clashPower: number; + pierce: boolean; + hit: HitProps; + /** Id of the projectile currently being contested, or -1. */ + clashWith: number; + clashFrames: number; +}; + +/** + * Render-only signals emitted by the simulation each frame (impacts, beams + * firing, aura ignition). They are rebuilt from scratch every frame and are + * deliberately excluded from the state checksum so a peer that drops a visual + * effect can never desync the match. + */ +export type EffectEvent = { + kind: + | 'impact' + | 'block' + | 'parry' + | 'launch' + | 'clash' + | 'transform' + | 'vanish' + | 'ko' + | 'daze' + | 'erasure'; + pos: Vec3; + power: number; + owner: 0 | 1; +}; + +export type MatchState = { + frame: number; + phase: MatchPhase; + phaseFrame: number; + round: number; + /** Frames remaining in the round. */ + timer: number; + fighters: [Fighter, Fighter]; + projectiles: Projectile[]; + effects: EffectEvent[]; + rng: RngState; + stageId: string; + nextProjectileId: number; + /** Winner of the last completed round, or -1 while a round is live. */ + lastRoundWinner: number; + finisher: FinisherState; +}; + +/** Tracks the Erasure window that opens on a match-ending blow. */ +export type FinisherState = { + /** Frames left to land an Erasure, or 0 when no window is open. */ + window: number; + /** Fighter who may perform it, or -1. */ + performer: number; + /** Id of the erasure that connected, or null. */ + performed: string | null; +}; + +export type MatchConfig = { + readonly roundsToWin: number; + readonly roundTimeFrames: number; + readonly stageId: string; + readonly seed: number; + readonly characterIds: readonly [string, string]; +}; diff --git a/ascendants/src/engine/types/move.ts b/ascendants/src/engine/types/move.ts new file mode 100644 index 0000000..594ee89 --- /dev/null +++ b/ascendants/src/engine/types/move.ts @@ -0,0 +1,121 @@ +import type { Vec3 } from '../math/vec3'; + +export type MoveKind = + | 'normal' // chainable light/medium/heavy + | 'command' // direction + button + | 'special' // motion input, costs nothing + | 'overdrive' // EX special, costs drive + | 'super' // level 3, costs full super meter + | 'fatal' // once-per-match cinematic finisher + | 'blast' // ki projectile + | 'beam' // sustained beam, clashable + | 'throw' + | 'driveImpact' + | 'transform'; + +/** Motion notation in numpad form, e.g. [2,3,6] for a quarter-circle forward. */ +export type Motion = readonly number[]; + +export type MoveInput = { + readonly motion: Motion; + readonly button: number; + /** Held direction required at the moment of the button press (0 = any). */ + readonly holdDir?: number; +}; + +/** + * A capsule hitbox in fighter-local space: +z is forward, +y is up. + * Active only while the move's frame counter sits inside [startFrame, endFrame]. + */ +export type Hitbox = { + readonly offset: Vec3; + readonly radius: number; + readonly length: number; + readonly startFrame: number; + readonly endFrame: number; +}; + +export type HitProps = { + readonly damage: number; + readonly chipDamage: number; + readonly hitstun: number; + readonly blockstun: number; + /** Freeze frames applied to both fighters — the "weight" of an impact. */ + readonly hitstop: number; + readonly pushback: number; + /** Upward velocity imparted; > 0 launches into a juggle. */ + readonly launch: number; + readonly juggleCost: number; + readonly knockdown: boolean; + readonly wallBounce: boolean; + readonly guardBreak: boolean; + readonly kiGainOnHit: number; + readonly kiGainOnBlock: number; + /** Drive gauge burned from the defender when this is blocked (SF6 rule). */ + readonly driveDamageOnBlock: number; +}; + +export type ProjectileSpec = { + readonly kind: 'blast' | 'beam'; + readonly speed: number; + readonly radius: number; + readonly lifetime: number; + readonly spawnOffset: Vec3; + /** Radians per frame of tracking toward the target; 0 = straight. */ + readonly homing: number; + /** Contest weight when two projectiles collide head-on. */ + readonly clashPower: number; + readonly pierce: boolean; + readonly hit: HitProps; +}; + +export type InvulnWindow = { + readonly start: number; + readonly end: number; + readonly kind: 'full' | 'strike' | 'projectile' | 'armor'; + /** Hits absorbed before armor breaks; ignored for non-armor windows. */ + readonly armorHits?: number; +}; + +export type MoveCost = { + readonly ki: number; + readonly drive: number; + readonly superMeter: number; +}; + +export type Move = { + readonly id: string; + readonly name: string; + readonly kind: MoveKind; + readonly input: MoveInput; + readonly startup: number; + readonly active: number; + readonly recovery: number; + readonly hitboxes: readonly Hitbox[]; + readonly hit: HitProps; + readonly cost: MoveCost; + /** Move ids this move may cancel into once it has connected. */ + readonly cancels: readonly string[]; + readonly groundOk: boolean; + readonly airOk: boolean; + /** Motion imparted to the user, applied on the first startup frame. */ + readonly travel?: { readonly forward: number; readonly up: number }; + readonly projectile?: ProjectileSpec; + readonly invuln?: InvulnWindow; + /** Landing this move ends the round with a cinematic. */ + readonly finisher?: boolean; +}; + +export function totalFrames(move: Move): number { + return move.startup + move.active + move.recovery; +} + +/** Frame advantage on block: negative means the defender acts first. */ +export function onBlock(move: Move): number { + return move.hit.blockstun - (move.active + move.recovery); +} + +/** Frame advantage on hit. */ +export function onHit(move: Move): number { + return move.hit.hitstun - (move.active + move.recovery); +} diff --git a/ascendants/src/net/protocol.ts b/ascendants/src/net/protocol.ts new file mode 100644 index 0000000..d05ba9a --- /dev/null +++ b/ascendants/src/net/protocol.ts @@ -0,0 +1,97 @@ +/** + * Wire protocol. + * + * Messages are JSON: at two players and a handful of messages per frame the + * bandwidth is irrelevant, and being able to read a packet capture during a + * desync investigation is worth far more than the bytes saved. + * + * Inputs are the exception that matters — every input message carries a short + * window of recent frames rather than a single frame. UDP-style loss over a + * WebSocket is rare but reordering and stalls are not, and redundancy means a + * dropped packet costs nothing instead of forcing a rollback. + */ + +export const PROTOCOL_VERSION = 1; + +export type PlayerIndex = 0 | 1; + +export type HelloMessage = { + type: 'hello'; + version: number; + roomId: string; + characterId: string; +}; + +/** Server tells a client which side it is playing. */ +export type AssignMessage = { + type: 'assign'; + playerIndex: PlayerIndex; + roomId: string; +}; + +export type StartMessage = { + type: 'start'; + seed: number; + characterIds: [string, string]; + stageId: string; +}; + +export type InputMessage = { + type: 'input'; + /** Frame of the LAST input in `packed`; earlier entries precede it. */ + frame: number; + packed: number[]; +}; + +/** Periodic state hash so a desync is caught immediately, not eventually. */ +export type ChecksumMessage = { + type: 'checksum'; + frame: number; + value: number; +}; + +export type PingMessage = { type: 'ping'; sent: number }; +export type PongMessage = { type: 'pong'; sent: number }; +export type ByeMessage = { type: 'bye'; reason: string }; +export type ErrorMessage = { type: 'error'; reason: string }; + +export type ClientMessage = HelloMessage | InputMessage | ChecksumMessage | PingMessage | PongMessage | ByeMessage; +export type ServerMessage = AssignMessage | StartMessage | InputMessage | ChecksumMessage | PingMessage | PongMessage | ByeMessage | ErrorMessage; +export type NetMessage = ClientMessage | ServerMessage; + +/** + * Narrow a decoded frame to the half of the protocol a client can receive. + * + * `hello` is the only message a client never gets, so the guard is cheap — but + * it exists so a peer cannot smuggle an unexpected shape into the client's + * message handlers. + */ +export function isServerMessage(message: NetMessage): message is ServerMessage { + return message.type !== 'hello'; +} + +/** The mirror of the above, for the server's receive path. */ +export function isClientMessage(message: NetMessage): message is ClientMessage { + return message.type !== 'assign' && message.type !== 'start' && message.type !== 'error'; +} + +export function encode(message: NetMessage): string { + return JSON.stringify(message); +} + +/** + * Parse a frame off the wire. Returns null rather than throwing: a malformed + * message from a peer must never be able to take down the match loop. + */ +export function decode(raw: string): NetMessage | null { + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + const candidate = parsed as { type?: unknown }; + return typeof candidate.type === 'string' ? (parsed as NetMessage) : null; + } catch { + return null; + } +} diff --git a/ascendants/src/net/rollback.test.ts b/ascendants/src/net/rollback.test.ts new file mode 100644 index 0000000..a9b76dc --- /dev/null +++ b/ascendants/src/net/rollback.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it } from 'vitest'; +import { defaultConfig } from '../engine/sim/init'; +import { Button, NEUTRAL_INPUT, type PlayerInput } from '../engine/types/input'; +import { MAX_ROLLBACK_FRAMES, RollbackSession } from './rollback'; + +const config = defaultConfig(); + +function scriptA(frame: number): PlayerInput { + return { + dir: [5, 6, 6, 2, 3, 6, 5, 4][frame % 8] ?? 5, + buttons: frame % 9 === 0 ? Button.Light : frame % 14 === 0 ? Button.Ki : 0, + }; +} + +function scriptB(frame: number): PlayerInput { + return { + dir: [5, 4, 8, 6, 5, 2, 6, 5][frame % 8] ?? 5, + buttons: frame % 11 === 0 ? Button.Medium : frame % 21 === 0 ? Button.Guard : 0, + }; +} + +/** + * Run two peers against each other with the network delaying every remote + * input by `delay` frames, then flush the in-flight inputs and let both settle. + * + * Comparisons must be made against the last CONFIRMED frame, never the live + * one: by definition the newest frame on each peer still contains a prediction + * of the other's input, so the two are expected to disagree there. + */ +function playMatch(frames: number, delay: number) { + const peer0 = new RollbackSession(config, 0); + const peer1 = new RollbackSession(config, 1); + + for (let frame = 0; frame < frames; frame++) { + peer0.addLocalInput(frame, scriptA(frame)); + peer1.addLocalInput(frame, scriptB(frame)); + + const arriving = frame - delay; + if (arriving >= 0) { + peer0.addRemoteInput(arriving, scriptB(arriving)); + peer1.addRemoteInput(arriving, scriptA(arriving)); + } + + peer0.advance(); + peer1.advance(); + } + + // Flush the inputs still in flight, then give each peer a frame to correct. + for (let frame = Math.max(0, frames - delay); frame < frames; frame++) { + peer0.addRemoteInput(frame, scriptB(frame)); + peer1.addRemoteInput(frame, scriptA(frame)); + } + peer0.addLocalInput(frames, scriptA(frames)); + peer1.addLocalInput(frames, scriptB(frames)); + peer0.advance(); + peer1.advance(); + + return { peer0, peer1 }; +} + +describe('rollback session', () => { + it('converges to an identical match on both peers despite delay', () => { + const frames = 600; + const { peer0, peer1 } = playMatch(frames, 4); + expect(peer0.frame).toBe(peer1.frame); + const a = peer0.checksumAt(frames - 1); + expect(a).toBeDefined(); + expect(a).toBe(peer1.checksumAt(frames - 1)); + }); + + it('still converges under heavy delay', () => { + const frames = 400; + const { peer0, peer1 } = playMatch(frames, MAX_ROLLBACK_FRAMES - 1); + const a = peer0.checksumAt(frames - 1); + expect(a).toBeDefined(); + expect(a).toBe(peer1.checksumAt(frames - 1)); + }); + + it('corrects a mispredicted frame to match what really happened', () => { + // A peer that simulated a wrong prediction must, after the real input + // arrives, land on exactly the state a peer who never guessed would reach. + const frames = 200; + const delayed = playMatch(frames, 6).peer0; + const instant = playMatch(frames, 0).peer0; + expect(delayed.checksumAt(frames - 1)).toBe(instant.checksumAt(frames - 1)); + expect(delayed.telemetry.rollbacks).toBeGreaterThan(0); + expect(instant.telemetry.rollbacks).toBe(0); + }); + + it('reports no desyncs across a clean match', () => { + const { peer0, peer1 } = playMatch(600, 4); + expect(peer0.telemetry.desyncs).toBe(0); + expect(peer1.telemetry.desyncs).toBe(0); + }); + + it('agrees frame by frame on the confirmed checksums', () => { + const { peer0, peer1 } = playMatch(300, 3); + for (let frame = 250; frame < 290; frame++) { + const a = peer0.checksumAt(frame); + const b = peer1.checksumAt(frame); + expect(a).toBeDefined(); + expect(a).toBe(b); + } + }); + + it('never rolls back once prediction has something to go on', () => { + // Prediction repeats the peer's last input, so an unchanging remote is + // always guessed correctly. The opening frames are seeded because the very + // first prediction has no history and is expected to miss. + const peer = new RollbackSession(config, 0); + const held: PlayerInput = { dir: 6, buttons: Button.Guard }; + for (let frame = 0; frame < 3; frame++) { + peer.addRemoteInput(frame, held); + } + for (let frame = 0; frame < 120; frame++) { + peer.addLocalInput(frame, scriptA(frame)); + peer.addRemoteInput(frame, held); + peer.advance(); + } + expect(peer.telemetry.rollbacks).toBe(0); + }); + + it('rolls back when a prediction turns out wrong', () => { + const peer = new RollbackSession(config, 0); + for (let frame = 0; frame < 30; frame++) { + peer.addLocalInput(frame, NEUTRAL_INPUT); + if (frame >= 2) { + peer.addRemoteInput(frame - 2, NEUTRAL_INPUT); + } + peer.advance(); + } + expect(peer.frame).toBe(30); + + // Frame 28 was simulated as neutral; it was really a heavy attack. + peer.addRemoteInput(28, { dir: 6, buttons: Button.Heavy }); + peer.addLocalInput(30, NEUTRAL_INPUT); + peer.advance(); + + expect(peer.telemetry.rollbacks).toBe(1); + expect(peer.telemetry.deepestRollback).toBe(2); + expect(peer.telemetry.resimulatedFrames).toBe(2); + }); + + it('stalls rather than outrunning the rollback budget', () => { + // A peer that has gone silent must not be predicted past the point where a + // correction is still possible. + const peer = new RollbackSession(config, 0); + let advanced = 0; + for (let frame = 0; frame < 60; frame++) { + peer.addLocalInput(frame, NEUTRAL_INPUT); + if (peer.advance()) { + advanced++; + } + } + expect(advanced).toBeLessThanOrEqual(MAX_ROLLBACK_FRAMES + 2); + expect(peer.telemetry.stalledFrames).toBeGreaterThan(0); + }); + + it('resumes advancing once the silent peer speaks again', () => { + const peer = new RollbackSession(config, 0); + for (let frame = 0; frame < 40; frame++) { + peer.addLocalInput(frame, NEUTRAL_INPUT); + peer.advance(); + } + const stalledAt = peer.frame; + for (let frame = 0; frame < stalledAt + 10; frame++) { + peer.addRemoteInput(frame, NEUTRAL_INPUT); + } + peer.addLocalInput(stalledAt, NEUTRAL_INPUT); + expect(peer.advance()).toBe(true); + expect(peer.frame).toBe(stalledAt + 1); + }); + + it('flags a checksum disagreement as a desync', () => { + const peer = new RollbackSession(config, 0); + peer.addLocalInput(0, NEUTRAL_INPUT); + peer.advance(); + expect(peer.verifyChecksum(0, 0xdeadbeef)).toBe(false); + expect(peer.telemetry.desyncs).toBe(1); + }); + + it('accepts a checksum for a frame it has not simulated', () => { + const peer = new RollbackSession(config, 0); + expect(peer.verifyChecksum(500, 123)).toBe(true); + expect(peer.telemetry.desyncs).toBe(0); + }); + + it('bundles recent local inputs for redundant sending', () => { + const peer = new RollbackSession(config, 0); + for (let frame = 0; frame < 20; frame++) { + peer.addLocalInput(frame, scriptA(frame)); + peer.addRemoteInput(frame, NEUTRAL_INPUT); + peer.advance(); + } + const bundle = peer.recentLocalInputs(5); + expect(bundle.packed).toHaveLength(5); + expect(bundle.frame).toBe(19); + expect(bundle.packed[4]).toEqual(scriptA(19)); + }); +}); diff --git a/ascendants/src/net/rollback.ts b/ascendants/src/net/rollback.ts new file mode 100644 index 0000000..1ea371f --- /dev/null +++ b/ascendants/src/net/rollback.ts @@ -0,0 +1,218 @@ +import { cloneState } from '../engine/sim/clone'; +import { checksum } from '../engine/sim/hash'; +import { createMatch } from '../engine/sim/init'; +import { step } from '../engine/sim/step'; +import { NEUTRAL_INPUT, type FrameInputs, type PlayerInput } from '../engine/types/input'; +import type { MatchConfig, MatchState } from '../engine/types/match'; + +/** + * Rollback netcode. + * + * The local player's input is applied the frame it is pressed, so the game + * never feels laggy. The remote player's input has not arrived yet, so it is + * predicted — repeat what they did last frame, which is right the large + * majority of the time because inputs are held across frames. + * + * When the real input arrives and contradicts the prediction, the session + * rewinds to that frame, corrects it, and re-simulates forward at full speed. + * The player sees a small visual snap instead of input delay, which is the + * trade every modern fighting game makes. + * + * The whole scheme rests on `step` being pure and deterministic. If that ever + * stops being true, this class silently produces two different matches. + */ + +export const MAX_ROLLBACK_FRAMES = 12; +const RING_SIZE = 64; + +export type RollbackTelemetry = { + /** HEALTH: frames simulated since the session started. */ + framesSimulated: number; + /** PRESSURE: how often prediction was wrong and cost a resimulation. */ + rollbacks: number; + resimulatedFrames: number; + deepestRollback: number; + /** EFFICIENCY: frames the local player was held back waiting on a peer. */ + stalledFrames: number; + /** FAILURE: confirmed checksum disagreements with the remote peer. */ + desyncs: number; + lastConfirmedFrame: number; +}; + +type Ring = (T | undefined)[]; + +function ringGet(ring: Ring, frame: number): T | undefined { + return frame < 0 ? undefined : ring[frame % RING_SIZE]; +} + +function ringSet(ring: Ring, frame: number, value: T): void { + ring[frame % RING_SIZE] = value; +} + +export class RollbackSession { + private state: MatchState; + private currentFrame = 0; + /** Inputs actually confirmed by their owner, per player. */ + private readonly confirmed: [Ring, Ring] = [[], []]; + /** Inputs actually used to simulate each frame, per player. */ + private readonly used: [Ring, Ring] = [[], []]; + private readonly snapshots: Ring = []; + private readonly checksums: Ring = []; + private rewindTo = -1; + private remoteConfirmedThrough = -1; + + readonly telemetry: RollbackTelemetry = { + framesSimulated: 0, + rollbacks: 0, + resimulatedFrames: 0, + deepestRollback: 0, + stalledFrames: 0, + desyncs: 0, + lastConfirmedFrame: -1, + }; + + constructor( + private readonly config: MatchConfig, + private readonly localPlayer: 0 | 1, + ) { + this.state = createMatch(config); + } + + get frame(): number { + return this.currentFrame; + } + + get current(): MatchState { + return this.state; + } + + private get remotePlayer(): 0 | 1 { + return this.localPlayer === 0 ? 1 : 0; + } + + addLocalInput(frame: number, input: PlayerInput): void { + ringSet(this.confirmed[this.localPlayer], frame, input); + } + + /** + * Accept a remote input. If it contradicts what was predicted for a frame + * already simulated, schedule a rewind to that frame. + */ + addRemoteInput(frame: number, input: PlayerInput): void { + if (frame <= this.remoteConfirmedThrough || frame < this.currentFrame - MAX_ROLLBACK_FRAMES) { + return; + } + ringSet(this.confirmed[this.remotePlayer], frame, input); + if (frame > this.remoteConfirmedThrough) { + this.remoteConfirmedThrough = frame; + this.telemetry.lastConfirmedFrame = frame; + } + + if (frame >= this.currentFrame) { + return; + } + const predicted = ringGet(this.used[this.remotePlayer], frame); + if (predicted && predicted.dir === input.dir && predicted.buttons === input.buttons) { + return; + } + this.rewindTo = this.rewindTo < 0 ? frame : Math.min(this.rewindTo, frame); + } + + /** Repeat the peer's last known input — right most of the time, cheap always. */ + private predictRemote(frame: number): PlayerInput { + for (let f = frame; f >= Math.max(0, frame - MAX_ROLLBACK_FRAMES); f--) { + const known = ringGet(this.confirmed[this.remotePlayer], f); + if (known) { + return known; + } + } + return NEUTRAL_INPUT; + } + + private inputsFor(frame: number): FrameInputs { + const local = + ringGet(this.confirmed[this.localPlayer], frame) ?? NEUTRAL_INPUT; + const remote = + ringGet(this.confirmed[this.remotePlayer], frame) ?? this.predictRemote(frame - 1); + ringSet(this.used[this.localPlayer], frame, local); + ringSet(this.used[this.remotePlayer], frame, remote); + return this.localPlayer === 0 ? [local, remote] : [remote, local]; + } + + private simulateFrame(): void { + ringSet(this.snapshots, this.currentFrame, cloneState(this.state)); + this.state = step(this.state, this.inputsFor(this.currentFrame), this.config); + ringSet(this.checksums, this.currentFrame, checksum(this.state)); + this.currentFrame += 1; + } + + private resimulate(): void { + const target = this.rewindTo; + this.rewindTo = -1; + const snapshot = ringGet(this.snapshots, target); + if (!snapshot) { + // Older than the ring can reach. Nothing correct is possible here, so + // surface it rather than quietly playing a diverged match. + this.telemetry.desyncs += 1; + return; + } + + const depth = this.currentFrame - target; + this.telemetry.rollbacks += 1; + this.telemetry.resimulatedFrames += depth; + this.telemetry.deepestRollback = Math.max(this.telemetry.deepestRollback, depth); + + const resumeAt = this.currentFrame; + this.state = cloneState(snapshot); + this.currentFrame = target; + while (this.currentFrame < resumeAt) { + this.simulateFrame(); + } + } + + /** + * Advance one frame. Returns false when the session must wait for the peer — + * running further would exceed the rollback budget and make a correction + * impossible. + */ + advance(): boolean { + if (this.rewindTo >= 0) { + this.resimulate(); + } + if (this.currentFrame - this.remoteConfirmedThrough > MAX_ROLLBACK_FRAMES) { + this.telemetry.stalledFrames += 1; + return false; + } + this.simulateFrame(); + this.telemetry.framesSimulated += 1; + return true; + } + + /** Compare a peer's reported hash for a frame we have already simulated. */ + verifyChecksum(frame: number, value: number): boolean { + const local = ringGet(this.checksums, frame); + if (local === undefined) { + return true; + } + if (local !== value) { + this.telemetry.desyncs += 1; + return false; + } + return true; + } + + checksumAt(frame: number): number | undefined { + return ringGet(this.checksums, frame); + } + + /** Local inputs from the last `count` frames, for redundant transmission. */ + recentLocalInputs(count: number): { frame: number; packed: PlayerInput[] } { + const end = this.currentFrame - 1; + const start = Math.max(0, end - count + 1); + const packed: PlayerInput[] = []; + for (let f = start; f <= end; f++) { + packed.push(ringGet(this.confirmed[this.localPlayer], f) ?? NEUTRAL_INPUT); + } + return { frame: end, packed }; + } +} diff --git a/ascendants/src/net/transport.ts b/ascendants/src/net/transport.ts new file mode 100644 index 0000000..07cce4b --- /dev/null +++ b/ascendants/src/net/transport.ts @@ -0,0 +1,159 @@ +import { decode, encode, isServerMessage, type ClientMessage, type ServerMessage } from './protocol'; + +/** + * Transport abstraction. + * + * The rollback session knows nothing about sockets — it is handed inputs and + * asked to advance. Keeping the socket behind this interface is what lets the + * same session run against a real server, a loopback for tests, or a replay + * file without changing a line of netcode. + */ + +export type MessageHandler = (message: ServerMessage) => void; + +export interface Transport { + readonly connected: boolean; + /** Round-trip time in milliseconds, or -1 before the first pong. */ + readonly rttMs: number; + send(message: ClientMessage): void; + onMessage(handler: MessageHandler): void; + onClose(handler: (reason: string) => void): void; + close(reason?: string): void; +} + +const PING_INTERVAL_MS = 2000; + +export class WebSocketTransport implements Transport { + private socket: WebSocket | null = null; + private handlers: MessageHandler[] = []; + private closeHandlers: ((reason: string) => void)[] = []; + private pingTimer: ReturnType | null = null; + private rtt = -1; + /** Queued while the socket is still opening, flushed on connect. */ + private pending: string[] = []; + + constructor(private readonly url: string) {} + + get connected(): boolean { + return this.socket?.readyState === WebSocket.OPEN; + } + + get rttMs(): number { + return this.rtt; + } + + connect(): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(this.url); + this.socket = socket; + + socket.onopen = () => { + for (const queued of this.pending) { + socket.send(queued); + } + this.pending = []; + this.pingTimer = setInterval(() => { + this.send({ type: 'ping', sent: Date.now() }); + }, PING_INTERVAL_MS); + resolve(); + }; + + socket.onerror = () => { + reject(new Error(`Could not reach ${this.url}`)); + }; + + socket.onclose = (event) => { + this.stopPing(); + for (const handler of this.closeHandlers) { + handler(event.reason || 'connection closed'); + } + }; + + socket.onmessage = (event) => { + const message = decode(String(event.data)); + if (!message || !isServerMessage(message)) { + return; + } + // Answer pings inline so latency measurement never depends on the + // game loop still running. + if (message.type === 'ping') { + this.send({ type: 'pong', sent: message.sent }); + return; + } + if (message.type === 'pong') { + this.rtt = Date.now() - message.sent; + return; + } + for (const handler of this.handlers) { + handler(message); + } + }; + }); + } + + private stopPing(): void { + if (this.pingTimer !== null) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + } + + send(message: ClientMessage): void { + const raw = encode(message); + if (this.connected) { + this.socket?.send(raw); + } else { + this.pending.push(raw); + } + } + + onMessage(handler: MessageHandler): void { + this.handlers.push(handler); + } + + onClose(handler: (reason: string) => void): void { + this.closeHandlers.push(handler); + } + + close(reason = 'client closed'): void { + this.stopPing(); + this.send({ type: 'bye', reason }); + this.socket?.close(); + this.socket = null; + } +} + +/** In-process transport used by tests and local practice against a script. */ +export class LoopbackTransport implements Transport { + private handlers: MessageHandler[] = []; + private closeHandlers: ((reason: string) => void)[] = []; + readonly sent: ClientMessage[] = []; + connected = true; + rttMs = 0; + + send(message: ClientMessage): void { + this.sent.push(message); + } + + /** Inject a message as though it had arrived from the server. */ + deliver(message: ServerMessage): void { + for (const handler of this.handlers) { + handler(message); + } + } + + onMessage(handler: MessageHandler): void { + this.handlers.push(handler); + } + + onClose(handler: (reason: string) => void): void { + this.closeHandlers.push(handler); + } + + close(reason = 'closed'): void { + this.connected = false; + for (const handler of this.closeHandlers) { + handler(reason); + } + } +} diff --git a/ascendants/src/render/camera.ts b/ascendants/src/render/camera.ts new file mode 100644 index 0000000..20e9584 --- /dev/null +++ b/ascendants/src/render/camera.ts @@ -0,0 +1,103 @@ +import * as THREE from 'three'; +import type { Fighter } from '../engine/types/fighter'; +import type { MatchState } from '../engine/types/match'; + +/** + * The fighting camera. + * + * It sits perpendicular to the line between the two fighters and pulls back as + * they separate, so the pair always reads as a side-on matchup no matter where + * they are in a round arena. That is the one job: a camera that chases the + * player instead of framing the fight makes spacing unreadable, and spacing is + * the game. + * + * The perpendicular is chosen once and then kept unless the fighters actually + * swap sides — otherwise the camera would swing wildly whenever they orbit each + * other. + */ + +const BASE_DISTANCE = 6.4; +const DISTANCE_PER_SEPARATION = 0.82; +const MAX_DISTANCE = 20; +const HEIGHT_BASE = 2.15; +const HEIGHT_PER_ALTITUDE = 0.62; +const FOLLOW = 0.12; +const SHAKE_DECAY = 0.86; + +export type FightCamera = { + update: (state: MatchState, deltaSeconds: number) => void; + shake: (amount: number) => void; +}; + +function midpoint(a: Fighter, b: Fighter, out: THREE.Vector3): void { + out.set( + (a.pos.x + b.pos.x) / 2, + (a.pos.y + b.pos.y) / 2, + (a.pos.z + b.pos.z) / 2, + ); +} + +export function createFightCamera(camera: THREE.PerspectiveCamera): FightCamera { + const center = new THREE.Vector3(); + const desired = new THREE.Vector3(); + const lookTarget = new THREE.Vector3(0, 1.2, 0); + const axis = new THREE.Vector3(1, 0, 0); + let side = 1; + let shakeAmount = 0; + + const update = (state: MatchState, deltaSeconds: number): void => { + const [a, b] = state.fighters; + midpoint(a, b, center); + + const dx = b.pos.x - a.pos.x; + const dz = b.pos.z - a.pos.z; + const separation = Math.hypot(dx, dz); + + if (separation > 0.001) { + // Perpendicular to the fighter axis on the ground plane. + const nx = -dz / separation; + const nz = dx / separation; + // Flip only when the fighters have genuinely crossed over, so the shot + // stays stable while they circle. + if (axis.x * nx + axis.z * nz < -0.35) { + side = -side; + } + axis.set(nx, 0, nz); + } + + const distance = Math.min(MAX_DISTANCE, BASE_DISTANCE + separation * DISTANCE_PER_SEPARATION); + const altitude = Math.max(a.pos.y, b.pos.y); + + desired.set( + center.x + axis.x * distance * side, + HEIGHT_BASE + altitude * HEIGHT_PER_ALTITUDE, + center.z + axis.z * distance * side, + ); + + // Frame-rate independent smoothing: the same feel at 60 and 144 Hz. + const blend = 1 - Math.pow(1 - FOLLOW, deltaSeconds * 60); + camera.position.lerp(desired, blend); + + lookTarget.lerp( + new THREE.Vector3(center.x, center.y + 1.3 + altitude * 0.25, center.z), + blend, + ); + + if (shakeAmount > 0.001) { + const magnitude = shakeAmount * 0.35; + camera.position.x += (Math.random() - 0.5) * magnitude; + camera.position.y += (Math.random() - 0.5) * magnitude; + shakeAmount *= SHAKE_DECAY; + } else { + shakeAmount = 0; + } + + camera.lookAt(lookTarget); + }; + + const shake = (amount: number): void => { + shakeAmount = Math.min(1.5, Math.max(shakeAmount, amount)); + }; + + return { update, shake }; +} diff --git a/ascendants/src/render/design/index.ts b/ascendants/src/render/design/index.ts new file mode 100644 index 0000000..c8154c1 --- /dev/null +++ b/ascendants/src/render/design/index.ts @@ -0,0 +1,262 @@ +import type { FighterDesign } from './types'; + +export * from './types'; + +/** + * The six designs. + * + * Each one is built to pass the silhouette test: blacked out and shrunk to + * thumbnail size, you should still know who you are looking at. That drove the + * proportions far more than the colour did — Korvath is a block, Sei is a + * sliver, Marrow is a vertical line, and no two share a shoulder width. + */ +export const DESIGNS: readonly FighterDesign[] = [ + { + characterId: 'vanta', + silhouette: 'Even shoulders, long braid falling to the waist, wrapped forearms.', + build: { + height: 1, + shoulderWidth: 1, + hipWidth: 1, + limbThickness: 1, + headSize: 1, + stanceLean: 0.06, + }, + palette: { + skin: '#c9a58a', + hair: '#e8e4f5', + primary: '#1a1030', + secondary: '#2e1065', + trim: '#8b5cf6', + glow: '#a78bfa', + auraCore: '#c4b5fd', + auraEdge: '#6d28d9', + }, + hair: 'braid', + costume: { + top: 'gi', + legs: 'wrapped', + shoulders: 'none', + handWraps: true, + belt: 'sash', + scarring: 0.2, + }, + aura: { shape: 'column', particles: 34, speed: 1, radius: 0.9 }, + ascended: { + hair: '#ffffff', + glow: '#ddd6fe', + auraCore: '#ffffff', + auraScale: 1.9, + afterimages: 0, + }, + }, + { + characterId: 'korvath', + silhouette: 'A block. Shoulders wider than he is tall is nearly true.', + build: { + height: 1.28, + shoulderWidth: 1.65, + hipWidth: 1.35, + limbThickness: 1.7, + headSize: 0.92, + stanceLean: 0.14, + }, + palette: { + skin: '#8a7355', + hair: '#000000', + primary: '#3b2f0b', + secondary: '#1c1508', + trim: '#c9a84c', + glow: '#ffcf5c', + auraCore: '#e0b64f', + auraEdge: '#6b4e12', + }, + hair: 'bald', + costume: { + top: 'bare', + legs: 'heavy', + shoulders: 'stone', + handWraps: false, + belt: 'heavy', + // He took the Herald head-on; the cracks never closed. + scarring: 1, + }, + aura: { shape: 'heavy', particles: 46, speed: 0.55, radius: 1.35 }, + ascended: { + hair: '#000000', + glow: '#fff1a8', + auraCore: '#ffd76a', + auraScale: 2.1, + afterimages: 0, + }, + }, + { + characterId: 'sei', + silhouette: 'A sliver. Shortest fighter, split skirt panels reading as speed lines.', + build: { + height: 0.9, + shoulderWidth: 0.82, + hipWidth: 0.88, + limbThickness: 0.78, + headSize: 1.06, + stanceLean: 0.22, + }, + palette: { + skin: '#e0b598', + hair: '#22d3ee', + primary: '#083344', + secondary: '#0e4b5a', + trim: '#67e8f9', + glow: '#a5f3fc', + auraCore: '#cffafe', + auraEdge: '#0891b2', + }, + hair: 'bob', + costume: { + top: 'bodysuit', + legs: 'skirtPanels', + shoulders: 'none', + handWraps: true, + belt: 'none', + scarring: 0, + }, + aura: { shape: 'flicker', particles: 22, speed: 2.4, radius: 0.62 }, + ascended: { + hair: '#ecfeff', + glow: '#ffffff', + auraCore: '#e0fbff', + auraScale: 1.5, + // The only fighter who ascends into afterimages rather than volume. + afterimages: 6, + }, + }, + { + characterId: 'marrow', + silhouette: 'A vertical line. Hooded, coat tails splitting below the knee.', + build: { + height: 1.12, + shoulderWidth: 0.86, + hipWidth: 0.8, + limbThickness: 0.8, + headSize: 0.96, + stanceLean: -0.04, + }, + palette: { + skin: '#6f7f74', + hair: '#04140f', + primary: '#052e22', + secondary: '#020f0b', + trim: '#34d399', + glow: '#6ee7b7', + auraCore: '#a7f3d0', + auraEdge: '#065f46', + }, + hair: 'hooded', + costume: { + top: 'coat', + legs: 'robe', + shoulders: 'none', + handWraps: true, + belt: 'none', + scarring: 0.35, + }, + aura: { shape: 'motes', particles: 18, speed: 0.4, radius: 1.5 }, + ascended: { + hair: '#04140f', + glow: '#d1fae5', + auraCore: '#ecfdf5', + auraScale: 1.7, + afterimages: 0, + }, + }, + { + characterId: 'talon', + silhouette: 'Wiry and square-shouldered, chain belt swinging off one hip.', + build: { + height: 1.02, + shoulderWidth: 1.12, + hipWidth: 0.92, + limbThickness: 0.9, + headSize: 0.98, + stanceLean: 0.02, + }, + palette: { + skin: '#b4776a', + hair: '#1c0a10', + primary: '#4c0519', + secondary: '#1f0209', + trim: '#f472b6', + glow: '#fda4d3', + auraCore: '#fbcfe8', + auraEdge: '#9d174d', + }, + hair: 'sleek', + costume: { + top: 'jacket', + legs: 'wide', + shoulders: 'none', + handWraps: true, + belt: 'chain', + scarring: 0.5, + }, + // Tight and quiet until he answers something — then it snaps outward. + aura: { shape: 'ring', particles: 26, speed: 1.3, radius: 0.72 }, + ascended: { + hair: '#1c0a10', + glow: '#ffe4f0', + auraCore: '#fce7f3', + auraScale: 1.6, + afterimages: 0, + }, + }, + { + characterId: 'helios', + silhouette: 'Broad top, narrow waist, one armoured arm and a burnt half-cape.', + build: { + height: 1.06, + shoulderWidth: 1.34, + hipWidth: 0.88, + limbThickness: 1.12, + headSize: 0.97, + stanceLean: 0.1, + }, + palette: { + skin: '#d08a5e', + hair: '#f97316', + primary: '#431407', + secondary: '#1c0a03', + trim: '#fb923c', + glow: '#fdba74', + auraCore: '#fff7ed', + auraEdge: '#c2410c', + }, + hair: 'wild', + costume: { + top: 'halfCape', + legs: 'guard', + shoulders: 'single', + handWraps: false, + belt: 'sash', + // Ember veins: he is burning himself to fight and it shows. + scarring: 0.85, + }, + aura: { shape: 'flame', particles: 52, speed: 1.8, radius: 1.15 }, + ascended: { + hair: '#fff7ed', + glow: '#ffffff', + auraCore: '#ffffff', + auraScale: 2.3, + afterimages: 2, + }, + }, +]; + +const BY_ID = new Map(DESIGNS.map((d) => [d.characterId, d])); + +export function designFor(characterId: string): FighterDesign { + const found = BY_ID.get(characterId); + if (!found) { + throw new Error(`No design for character: ${characterId}`); + } + return found; +} diff --git a/ascendants/src/render/design/types.ts b/ascendants/src/render/design/types.ts new file mode 100644 index 0000000..4b1cc8f --- /dev/null +++ b/ascendants/src/render/design/types.ts @@ -0,0 +1,76 @@ +/** + * Fighter design specifications. + * + * Every fighter is built procedurally from these numbers at runtime — there are + * no model files in this repository. That is a deliberate constraint: a + * silhouette defined as data can be re-tuned in one line, stays diffable in + * review, and keeps the whole game inside a build with no asset pipeline. + * + * This lives in the render layer, not the engine. Appearance must never be able + * to influence the simulation. + */ + +export type Build = { + /** Multiplier on the 1.8-unit baseline height. */ + readonly height: number; + readonly shoulderWidth: number; + readonly hipWidth: number; + readonly limbThickness: number; + readonly headSize: number; + /** Forward lean of the idle stance, in radians. */ + readonly stanceLean: number; +}; + +export type Palette = { + readonly skin: string; + readonly hair: string; + /** Dominant costume colour. */ + readonly primary: string; + readonly secondary: string; + readonly trim: string; + /** Emissive colour for markings, eyes and energy. */ + readonly glow: string; + readonly auraCore: string; + readonly auraEdge: string; +}; + +export type HairStyle = 'braid' | 'bald' | 'bob' | 'hooded' | 'sleek' | 'wild'; + +export type Costume = { + readonly top: 'gi' | 'bare' | 'bodysuit' | 'coat' | 'jacket' | 'halfCape'; + readonly legs: 'wrapped' | 'skirtPanels' | 'heavy' | 'robe' | 'wide' | 'guard'; + readonly shoulders: 'none' | 'stone' | 'single' | 'pads'; + readonly handWraps: boolean; + readonly belt: 'sash' | 'heavy' | 'chain' | 'none'; + /** Glowing scar lines across the torso, a mark of surviving the Herald. */ + readonly scarring: number; +}; + +export type AuraStyle = { + readonly shape: 'column' | 'heavy' | 'flicker' | 'motes' | 'ring' | 'flame'; + readonly particles: number; + readonly speed: number; + readonly radius: number; +}; + +/** What changes when the fighter ascends. */ +export type AscendedLook = { + readonly hair: string; + readonly glow: string; + readonly auraCore: string; + readonly auraScale: number; + /** Cyan-style afterimages trailing the model. */ + readonly afterimages: number; +}; + +export type FighterDesign = { + readonly characterId: string; + /** One line describing the read at a glance — the silhouette test. */ + readonly silhouette: string; + readonly build: Build; + readonly palette: Palette; + readonly hair: HairStyle; + readonly costume: Costume; + readonly aura: AuraStyle; + readonly ascended: AscendedLook; +}; diff --git a/ascendants/src/render/effects.ts b/ascendants/src/render/effects.ts new file mode 100644 index 0000000..591f192 --- /dev/null +++ b/ascendants/src/render/effects.ts @@ -0,0 +1,270 @@ +import * as THREE from 'three'; +import { KI_MAX } from '../engine/data/constants'; +import type { Fighter } from '../engine/types/fighter'; +import type { EffectEvent, Projectile } from '../engine/types/match'; +import type { Vec3 } from '../engine/math/vec3'; +import type { Rig } from './rig'; + +/** + * Visual effects: aura, impact flashes and projectile bodies. + * + * All three are pooled and reused. A fighting game spawns effects in bursts — + * a super can produce dozens in two frames — and allocating during those bursts + * is exactly when a garbage collection pause would be least forgivable. + */ + +const IMPACT_POOL = 24; +const IMPACT_LIFE = 14; + +/** Height of a fighter's chest, where beam distance is measured from. */ +const CHEST_HEIGHT = 1.15; +/** + * Arm reach (~0.6) plus a hair of clearance, so a beam leaves the hands with + * daylight in front of them rather than starting inside the torso. + */ +const HAND_CLEARANCE = 0.62; + +/** Reposition and fade a fighter's aura for the current frame. */ +export function updateAura(rig: Rig, fighter: Fighter, timeSec: number): void { + const material = rig.aura.material as THREE.PointsMaterial; + const charging = fighter.state === 'charge'; + const kiRatio = fighter.ki / KI_MAX; + + // The aura is a readout, not decoration: how bright it is tells the opponent + // how much ki you are holding, and it flares when you are charging. + const targetOpacity = fighter.transformed ? 0.95 : charging ? 0.85 : kiRatio * 0.4; + material.opacity += (targetOpacity - material.opacity) * 0.2; + if (material.opacity < 0.01) { + rig.aura.visible = false; + return; + } + rig.aura.visible = true; + + const design = rig.design; + const style = design.aura; + const scale = fighter.transformed ? design.ascended.auraScale : 1; + material.color.set(fighter.transformed ? design.ascended.auraCore : design.palette.auraCore); + material.size = 0.19 * design.build.height * scale; + + const positions = rig.aura.geometry.getAttribute('position') as THREE.BufferAttribute; + const count = positions.count; + const height = 2.4 * design.build.height * scale; + + for (let i = 0; i < count; i++) { + // Deterministic-looking scatter from the index keeps particles from + // flickering between frames while still reading as chaotic motion. + const seed = i * 12.9898; + const angle = seed + timeSec * style.speed * (charging ? 2.2 : 1); + const rise = (timeSec * style.speed * 0.8 + i / count) % 1; + const spread = style.shape === 'flame' ? 1 - rise * 0.7 : style.shape === 'motes' ? 1 : 1 - rise * 0.3; + const radius = style.radius * scale * spread * (0.4 + ((i * 7) % 10) / 14); + + positions.setXYZ( + i, + Math.cos(angle) * radius, + rise * height + (style.shape === 'heavy' ? -0.3 : 0), + Math.sin(angle) * radius, + ); + } + positions.needsUpdate = true; +} + +export type ImpactPoolHandle = { + group: THREE.Group; + spawn: (events: readonly EffectEvent[]) => void; + update: () => void; + dispose: () => void; +}; + +const EFFECT_COLORS: Record = { + impact: 0xfff1c9, + block: 0x8ab4ff, + parry: 0x9dff7a, + launch: 0xffd166, + clash: 0xffffff, + transform: 0xffe8a3, + vanish: 0xc4b5fd, + ko: 0xff5252, + daze: 0xffb3b3, + erasure: 0xff2d2d, +}; + +export function createImpactPool(): ImpactPoolHandle { + const group = new THREE.Group(); + const geometry = new THREE.IcosahedronGeometry(0.5, 1); + const meshes: THREE.Mesh[] = []; + const lives: number[] = []; + const materials: THREE.MeshBasicMaterial[] = []; + + for (let i = 0; i < IMPACT_POOL; i++) { + const material = new THREE.MeshBasicMaterial({ + transparent: true, + opacity: 0, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.visible = false; + group.add(mesh); + meshes.push(mesh); + materials.push(material); + lives.push(0); + } + + let cursor = 0; + + const spawn = (events: readonly EffectEvent[]): void => { + for (const event of events) { + const mesh = meshes[cursor]; + const material = materials[cursor]; + if (!mesh || !material) { + continue; + } + mesh.position.set(event.pos.x, event.pos.y + 1.1, event.pos.z); + mesh.scale.setScalar(0.34 + event.power * 0.3); + material.color.setHex(EFFECT_COLORS[event.kind] ?? 0xffffff); + material.opacity = 0.95; + mesh.visible = true; + lives[cursor] = IMPACT_LIFE; + // Oldest slot is overwritten first: under a burst, losing the earliest + // flash is invisible, while dropping the newest would look broken. + cursor = (cursor + 1) % IMPACT_POOL; + } + }; + + const update = (): void => { + for (let i = 0; i < meshes.length; i++) { + const life = lives[i] ?? 0; + if (life <= 0) { + continue; + } + const mesh = meshes[i]; + const material = materials[i]; + const next = life - 1; + lives[i] = next; + if (!mesh || !material) { + continue; + } + const t = next / IMPACT_LIFE; + material.opacity = t * 0.95; + // Grow modestly: a flash that expands faster than it fades ends up a + // screen-filling blob that hides the two fighters it is reporting on. + mesh.scale.multiplyScalar(1.045); + mesh.rotation.y += 0.2; + if (next <= 0) { + mesh.visible = false; + } + } + }; + + const dispose = (): void => { + geometry.dispose(); + for (const material of materials) { + material.dispose(); + } + }; + + return { group, spawn, update, dispose }; +} + +export type ProjectileViewHandle = { + group: THREE.Group; + sync: ( + projectiles: readonly Projectile[], + accents: readonly [string, string], + ownerPositions: readonly [Vec3, Vec3], + ) => void; + dispose: () => void; +}; + +/** Keeps one mesh alive per live projectile id, creating and retiring on demand. */ +export function createProjectileView(): ProjectileViewHandle { + const group = new THREE.Group(); + const blastGeometry = new THREE.SphereGeometry(1, 14, 10); + // Tapered: narrow where it leaves the caster's hands, wide at the leading + // edge. A uniform tube reads as a wall with no direction to it, so the flare + // is what tells you at a glance which way the beam is travelling. + const beamGeometry = new THREE.CylinderGeometry(1, 0.3, 1, 14, 1, true); + beamGeometry.rotateX(Math.PI / 2); + // After the rotation the wide end sits at +Z. Shifting the body to z <= 0 + // makes the projectile's position the leading TIP, with the beam extending + // back to the caster rather than straddling the position and engulfing them. + beamGeometry.translate(0, 0, -0.5); + const active = new Map(); + + const sync = ( + projectiles: readonly Projectile[], + accents: readonly [string, string], + ownerPositions: readonly [Vec3, Vec3], + ): void => { + const seen = new Set(); + + for (const projectile of projectiles) { + seen.add(projectile.id); + let mesh = active.get(projectile.id); + if (!mesh) { + const material = new THREE.MeshBasicMaterial({ + color: accents[projectile.owner], + transparent: true, + opacity: 0.7, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + mesh = new THREE.Mesh( + projectile.kind === 'beam' ? beamGeometry : blastGeometry, + material, + ); + group.add(mesh); + active.set(projectile.id, mesh); + } + + mesh.position.set(projectile.pos.x, projectile.pos.y, projectile.pos.z); + if (projectile.kind === 'beam') { + // A beam spans from its owner's hands to its leading tip, so its length + // is the distance actually travelled — not a constant, which would + // either overshoot behind the caster or fall short of them. + const origin = ownerPositions[projectile.owner]; + // Distance is measured from the caster's chest, but the beam must leave + // the HANDS with a little daylight in front of them — anchored at the + // chest it starts inside the torso. HAND_CLEARANCE is the arm reach + // plus a small gap, subtracted from the length so the tail moves + // forward while the leading tip stays put. + const travelled = Math.hypot( + projectile.pos.x - origin.x, + projectile.pos.y - (origin.y + CHEST_HEIGHT), + projectile.pos.z - origin.z, + ); + const length = Math.max(0.8, travelled - HAND_CLEARANCE); + mesh.scale.set(projectile.radius, projectile.radius, length); + mesh.lookAt( + projectile.pos.x + projectile.vel.x, + projectile.pos.y + projectile.vel.y, + projectile.pos.z + projectile.vel.z, + ); + } else { + const pulse = projectile.clashWith >= 0 ? 1.5 : 1; + mesh.scale.setScalar(projectile.radius * pulse); + } + } + + for (const [id, mesh] of active) { + if (seen.has(id)) { + continue; + } + group.remove(mesh); + (mesh.material as THREE.Material).dispose(); + active.delete(id); + } + }; + + const dispose = (): void => { + blastGeometry.dispose(); + beamGeometry.dispose(); + for (const mesh of active.values()) { + (mesh.material as THREE.Material).dispose(); + } + active.clear(); + }; + + return { group, sync, dispose }; +} diff --git a/ascendants/src/render/portrait.ts b/ascendants/src/render/portrait.ts new file mode 100644 index 0000000..969e6cc --- /dev/null +++ b/ascendants/src/render/portrait.ts @@ -0,0 +1,92 @@ +import * as THREE from 'three'; +import { ROSTER } from '../engine/data/characters'; +import { buildRig } from './rig'; +import { createToonGradient } from './scene'; + +/** + * Character-select portraits. + * + * Each portrait is the fighter's actual in-game rig, rendered once into an + * offscreen canvas and handed back as a data URL. Using the real model rather + * than an illustration means a portrait can never drift out of sync with the + * character it represents — change a design table and the select screen updates + * itself. + * + * One renderer is created, used for every fighter, and disposed. WebGL contexts + * are a scarce browser resource and a per-card renderer would exhaust them. + */ + +const WIDTH = 260; +const HEIGHT = 320; + +/** A relaxed three-quarter stance — readable, and not mid-attack. */ +function poseForPortrait(rig: ReturnType): void { + const j = rig.joints; + j.torso.rotation.y = 0.5; + j.torso.rotation.x = rig.design.build.stanceLean; + j.head.rotation.y = -0.25; + // Hands at chest height, not at the chin: a high guard hides the chest and + // shoulder line, which is most of what distinguishes these silhouettes. + j.shoulderL.rotation.set(-0.22, 0, 0.5); + j.elbowL.rotation.x = -0.66; + j.shoulderR.rotation.set(-0.12, 0, -0.42); + j.elbowR.rotation.x = -0.5; + j.hipL.rotation.set(-0.18, 0, 0.12); + j.kneeL.rotation.x = 0.3; + j.hipR.rotation.set(0.2, 0, -0.1); + j.kneeR.rotation.x = 0.26; +} + +export function renderPortraits(): Map { + const portraits = new Map(); + + const canvas = document.createElement('canvas'); + canvas.width = WIDTH; + canvas.height = HEIGHT; + + const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); + renderer.setPixelRatio(1); + renderer.setSize(WIDTH, HEIGHT, false); + renderer.setClearColor(0x000000, 0); + + const scene = new THREE.Scene(); + // Lit far hotter than the arena: several fighters wear near-black palettes, + // and at thumbnail size an accurate-but-dim portrait reads as an empty card. + const key = new THREE.DirectionalLight(0xfff4e0, 4.2); + key.position.set(3, 6, 5); + scene.add(key); + const fill = new THREE.DirectionalLight(0xffffff, 2.2); + fill.position.set(-2, 2, 6); + scene.add(fill); + const rim = new THREE.DirectionalLight(0x8b5cf6, 2.6); + rim.position.set(-4, 3, -3); + scene.add(rim); + scene.add(new THREE.AmbientLight(0xb9a8e0, 1.4)); + + const camera = new THREE.PerspectiveCamera(34, WIDTH / HEIGHT, 0.1, 40); + const gradient = createToonGradient(); + + for (const character of ROSTER) { + const rig = buildRig(character.id, gradient); + poseForPortrait(rig); + scene.add(rig.root); + // Aura is noise at thumbnail size and hides the silhouette. + rig.aura.visible = false; + + // Frame the fighter by their own height so a 0.9-scale Sei and a 1.28 + // Korvath both fill the card instead of one looking lost in it. + const height = 1.8 * rig.design.build.height; + camera.position.set(1.35, height * 0.6, 3.0); + camera.lookAt(0, height * 0.48, 0); + + renderer.render(scene, camera); + portraits.set(character.id, canvas.toDataURL('image/png')); + + scene.remove(rig.root); + rig.dispose(); + } + + gradient.dispose(); + renderer.dispose(); + return portraits; +} diff --git a/ascendants/src/render/pose.ts b/ascendants/src/render/pose.ts new file mode 100644 index 0000000..1ca4bd0 --- /dev/null +++ b/ascendants/src/render/pose.ts @@ -0,0 +1,270 @@ +import type * as THREE from 'three'; +import { getMove } from '../engine/data/moves'; +import type { Fighter } from '../engine/types/fighter'; +import type { Rig } from './rig'; + +/** + * Procedural posing. + * + * Every pose is a set of target joint rotations that the rig eases toward, so + * transitions blend for free and no state needs an explicit animation. Attack + * poses read the move's own frame data, which means a frame-data change + * retimes the animation automatically instead of desynchronising from it. + * + * This runs on the render side and may use wall-clock time — nothing here can + * affect the simulation. + */ + +type Pose = Partial>; + +const BLEND = 0.35; +const SNAP = 0.75; + +function ease(joint: THREE.Group, target: [number, number, number], rate: number): void { + joint.rotation.x += (target[0] - joint.rotation.x) * rate; + joint.rotation.y += (target[1] - joint.rotation.y) * rate; + joint.rotation.z += (target[2] - joint.rotation.z) * rate; +} + +/** Which limb leads a move, inferred from its id. */ +function limbOf(moveId: string): 'punch' | 'kick' | 'cast' { + const id = moveId.toLowerCase(); + if (/kick|heel|knee|sweep|2h|airh|talon|updraft|dive|step/.test(id)) { + return 'kick'; + } + if (/blast|palm|lance|flare|beam|eclipse|horizon|tether|wall|nova|ki/.test(id)) { + return 'cast'; + } + return 'punch'; +} + +const IDLE: Pose = { + torso: [0, 0.22, 0], + shoulderL: [-0.5, 0, 0.32], + elbowL: [-1.05, 0, 0], + shoulderR: [-0.35, 0, -0.28], + elbowR: [-0.85, 0, 0], + hipL: [-0.16, 0, 0.1], + kneeL: [0.28, 0, 0], + hipR: [0.2, 0, -0.08], + kneeR: [0.24, 0, 0], +}; + +const GUARD: Pose = { + torso: [0.1, 0.35, 0], + shoulderL: [-1.5, 0, 0.5], + elbowL: [-1.7, 0, 0], + shoulderR: [-1.4, 0, -0.45], + elbowR: [-1.75, 0, 0], + hipL: [-0.2, 0, 0.12], + kneeL: [0.4, 0, 0], + hipR: [0.14, 0, -0.1], + kneeR: [0.34, 0, 0], +}; + +const PARRY: Pose = { + torso: [-0.08, 0.1, 0], + shoulderL: [-1.9, 0, 0.2], + elbowL: [-0.5, 0, 0], + shoulderR: [-0.4, 0, -0.3], + elbowR: [-1.2, 0, 0], +}; + +const CHARGE: Pose = { + torso: [-0.22, 0, 0], + head: [-0.3, 0, 0], + shoulderL: [0.5, 0, 0.55], + elbowL: [-1.9, 0, 0], + shoulderR: [0.5, 0, -0.55], + elbowR: [-1.9, 0, 0], + hipL: [-0.35, 0, 0.2], + kneeL: [0.7, 0, 0], + hipR: [-0.35, 0, -0.2], + kneeR: [0.7, 0, 0], +}; + +const HITSTUN: Pose = { + torso: [0.35, 0, 0], + head: [0.3, 0, 0], + shoulderL: [0.6, 0, 0.7], + elbowL: [-0.4, 0, 0], + shoulderR: [0.6, 0, -0.7], + elbowR: [-0.4, 0, 0], + hipL: [0.25, 0, 0.15], + hipR: [0.25, 0, -0.15], +}; + +const LAUNCHED: Pose = { + torso: [-0.5, 0, 0], + shoulderL: [2.2, 0, 0.5], + shoulderR: [2.2, 0, -0.5], + elbowL: [-0.3, 0, 0], + elbowR: [-0.3, 0, 0], + hipL: [-0.5, 0, 0.2], + kneeL: [0.9, 0, 0], + hipR: [-0.35, 0, -0.2], + kneeR: [0.7, 0, 0], +}; + +const FLY: Pose = { + torso: [0.25, 0, 0], + shoulderL: [1.1, 0, 0.45], + elbowL: [-0.6, 0, 0], + shoulderR: [1.1, 0, -0.45], + elbowR: [-0.6, 0, 0], + hipL: [-0.15, 0, 0.1], + kneeL: [0.45, 0, 0], + hipR: [-0.1, 0, -0.1], + kneeR: [0.35, 0, 0], +}; + +const KNOCKDOWN: Pose = { + torso: [0.1, 0, 0], + shoulderL: [1.6, 0, 0.9], + shoulderR: [1.6, 0, -0.9], + hipL: [-0.9, 0, 0.2], + kneeL: [1.2, 0, 0], + hipR: [-0.8, 0, -0.2], + kneeR: [1.1, 0, 0], +}; + +const DAZED: Pose = { + torso: [0.28, 0, 0], + head: [0.45, 0, 0], + shoulderL: [0.3, 0, 0.25], + elbowL: [-0.2, 0, 0], + shoulderR: [0.3, 0, -0.25], + elbowR: [-0.2, 0, 0], + hipL: [-0.1, 0, 0.15], + kneeL: [0.35, 0, 0], + hipR: [-0.05, 0, -0.15], + kneeR: [0.3, 0, 0], +}; + +/** Attack poses interpolate between wind-up, extension and recovery. */ +function attackPose(fighter: Fighter): Pose { + if (fighter.moveId === null) { + return IDLE; + } + const move = getMove(fighter.moveId); + const frame = fighter.moveFrame; + const extended = frame >= move.startup && frame <= move.startup + move.active; + const limb = limbOf(move.id); + + if (limb === 'cast') { + return extended + ? { + torso: [0, 0.1, 0], + shoulderL: [-1.55, 0, 0.15], + elbowL: [-0.1, 0, 0], + shoulderR: [-1.55, 0, -0.15], + elbowR: [-0.1, 0, 0], + hipL: [-0.25, 0, 0.12], + kneeL: [0.4, 0, 0], + } + : { + torso: [-0.15, 0.5, 0], + shoulderL: [-0.2, 0, 0.9], + elbowL: [-2.2, 0, 0], + shoulderR: [-0.2, 0, -0.9], + elbowR: [-2.2, 0, 0], + }; + } + + if (limb === 'kick') { + return extended + ? { + torso: [0.1, 0.15, 0], + hipR: [-1.5, 0, -0.1], + kneeR: [0.15, 0, 0], + hipL: [0.2, 0, 0.1], + kneeL: [0.3, 0, 0], + shoulderL: [-0.9, 0, 0.8], + shoulderR: [-0.5, 0, -0.6], + } + : { + torso: [-0.1, 0.45, 0], + hipR: [-0.5, 0, -0.2], + kneeR: [1.4, 0, 0], + shoulderL: [-0.6, 0, 0.5], + shoulderR: [-0.4, 0, -0.4], + }; + } + + return extended + ? { + torso: [0, -0.32, 0], + shoulderR: [-1.62, 0, -0.05], + elbowR: [-0.06, 0, 0], + shoulderL: [-0.3, 0, 0.9], + elbowL: [-1.9, 0, 0], + hipR: [-0.3, 0, -0.1], + kneeR: [0.4, 0, 0], + } + : { + torso: [0, 0.6, 0], + shoulderR: [-0.5, 0, -0.75], + elbowR: [-2.1, 0, 0], + shoulderL: [-1.2, 0, 0.4], + elbowL: [-1.4, 0, 0], + }; +} + +function poseFor(fighter: Fighter): { pose: Pose; rate: number } { + switch (fighter.state) { + case 'attack': + return { pose: attackPose(fighter), rate: SNAP }; + case 'guard': + case 'blockstun': + return { pose: GUARD, rate: SNAP }; + case 'parry': + return { pose: PARRY, rate: SNAP }; + case 'charge': + return { pose: CHARGE, rate: BLEND }; + case 'hitstun': + return { pose: HITSTUN, rate: SNAP }; + case 'launched': + return { pose: LAUNCHED, rate: BLEND }; + case 'knockdown': + case 'wakeup': + return { pose: KNOCKDOWN, rate: BLEND }; + case 'fly': + return { pose: FLY, rate: BLEND }; + case 'dazed': + case 'erased': + return { pose: DAZED, rate: BLEND }; + default: + return { pose: IDLE, rate: BLEND }; + } +} + +export function poseRig(rig: Rig, fighter: Fighter, timeSec: number): void { + const { pose, rate } = poseFor(fighter); + const joints = rig.joints; + + for (const key of Object.keys(joints) as (keyof Rig['joints'])[]) { + const target = pose[key] ?? IDLE[key] ?? [0, 0, 0]; + ease(joints[key], target, rate); + } + + // Breathing and walk cycle ride on top of the pose rather than replacing it, + // so a fighter never looks frozen between actions. + const bob = Math.sin(timeSec * 3.4) * 0.012; + const walking = fighter.state === 'walk' || fighter.state === 'dash'; + if (walking) { + const cycle = Math.sin(timeSec * 13) * 0.55; + joints.hipL.rotation.x += cycle; + joints.hipR.rotation.x -= cycle; + joints.shoulderL.rotation.x -= cycle * 0.5; + joints.shoulderR.rotation.x += cycle * 0.5; + } + joints.hips.position.y = rig.design.build.height * 0.92 + bob; + joints.torso.rotation.x += rig.design.build.stanceLean; + + // Hitstop is sold by a hard freeze plus a shake, not by slowing anything down. + if (fighter.hitstop > 0) { + joints.hips.position.x = (fighter.hitstop % 2 === 0 ? 1 : -1) * 0.03; + } else { + joints.hips.position.x = 0; + } +} diff --git a/ascendants/src/render/rig.ts b/ascendants/src/render/rig.ts new file mode 100644 index 0000000..84b208f --- /dev/null +++ b/ascendants/src/render/rig.ts @@ -0,0 +1,282 @@ +import * as THREE from 'three'; +import { designFor, type FighterDesign } from './design'; +import { boxPart, capsule, createAura, createMaterials, limb, scarLines, spherePart, type RigMaterials } from './rigParts'; + +/** + * Fighter assembly. + * + * The rig is a plain Object3D hierarchy with named joints. Posing is done by + * writing rotations onto those joints (see pose.ts) — there is no skinning and + * no animation data, which keeps every frame of every character inspectable and + * tweakable as numbers. + */ + +export type RigJoints = { + hips: THREE.Group; + torso: THREE.Group; + head: THREE.Group; + shoulderL: THREE.Group; + elbowL: THREE.Group; + shoulderR: THREE.Group; + elbowR: THREE.Group; + hipL: THREE.Group; + kneeL: THREE.Group; + hipR: THREE.Group; + kneeR: THREE.Group; +}; + +export type Rig = { + design: FighterDesign; + root: THREE.Group; + joints: RigJoints; + materials: RigMaterials; + aura: THREE.Points; + /** Trailing copies used by fighters whose ascended form leaves afterimages. */ + afterimages: THREE.Group[]; + dispose: () => void; +}; + +const BASE = { + hipHeight: 0.92, + torsoLength: 0.58, + neckHeight: 0.6, + headRadius: 0.155, + shoulderY: 0.5, + upperArm: 0.31, + forearm: 0.29, + armRadius: 0.072, + thigh: 0.44, + shin: 0.42, + legRadius: 0.1, +}; + +function addCostume( + design: FighterDesign, + joints: RigJoints, + materials: RigMaterials, + scale: number, + shoulderWidth: number, +): void { + const { costume } = design; + + if (costume.shoulders === 'stone') { + for (const side of [-1, 1]) { + const pad = boxPart(0.3 * scale, 0.18 * scale, 0.3 * scale, materials.secondary, materials.outline); + pad.position.set(side * shoulderWidth * 1.05, BASE.shoulderY * scale, 0); + joints.torso.add(pad); + } + } else if (costume.shoulders === 'single') { + const pad = boxPart(0.26 * scale, 0.16 * scale, 0.28 * scale, materials.trim, materials.outline); + pad.position.set(shoulderWidth * 1.05, BASE.shoulderY * scale, 0); + joints.torso.add(pad); + } else if (costume.shoulders === 'pads') { + for (const side of [-1, 1]) { + const pad = boxPart(0.22 * scale, 0.1 * scale, 0.24 * scale, materials.trim, materials.outline); + pad.position.set(side * shoulderWidth, BASE.shoulderY * scale, 0); + joints.torso.add(pad); + } + } + + if (costume.top === 'halfCape' || costume.top === 'coat') { + const length = costume.top === 'coat' ? 0.95 : 0.7; + const cape = boxPart( + shoulderWidth * 1.9, + length * scale, + 0.05 * scale, + materials.secondary, + materials.outline, + ); + cape.position.set(0, (BASE.shoulderY - length / 2) * scale, -0.16 * scale); + joints.torso.add(cape); + } + + if (costume.legs === 'skirtPanels') { + for (const side of [-1, 1]) { + const panel = boxPart(0.16 * scale, 0.42 * scale, 0.06 * scale, materials.trim, materials.outline); + panel.position.set(side * 0.16 * scale, -0.2 * scale, 0.02 * scale); + joints.hips.add(panel); + } + } else if (costume.legs === 'robe') { + const robe = boxPart(0.42 * scale, 0.86 * scale, 0.3 * scale, materials.primary, materials.outline); + robe.position.y = -0.42 * scale; + joints.hips.add(robe); + } + + if (costume.belt !== 'none') { + const belt = boxPart( + 0.4 * scale, + costume.belt === 'heavy' ? 0.12 * scale : 0.07 * scale, + 0.3 * scale, + costume.belt === 'chain' ? materials.trim : materials.secondary, + materials.outline, + ); + joints.hips.add(belt); + } + + const scarMaterial = materials.glow; + joints.torso.add( + scarLines(costume.scarring, 0.42 * scale, BASE.torsoLength * scale, scarMaterial), + ); +} + +function addHair(design: FighterDesign, joints: RigJoints, materials: RigMaterials, scale: number): void { + const r = BASE.headRadius * design.build.headSize * scale; + switch (design.hair) { + case 'bald': + return; + case 'braid': { + const cap = spherePart(r * 1.08, materials.hair, materials.outline); + cap.scale.set(1, 0.9, 1); + joints.head.add(cap); + const braid = capsule(r * 0.3, r * 4.4, materials.hair, materials.outline); + braid.position.set(0, -r * 2.3, -r * 1.0); + joints.head.add(braid); + return; + } + case 'bob': { + const cap = spherePart(r * 1.14, materials.hair, materials.outline); + cap.scale.set(1, 0.95, 1.05); + cap.position.y = r * 0.1; + joints.head.add(cap); + return; + } + case 'hooded': { + const hood = spherePart(r * 1.5, materials.primary, materials.outline); + hood.scale.set(1, 1.2, 1.1); + hood.position.set(0, r * 0.15, -r * 0.15); + joints.head.add(hood); + return; + } + case 'sleek': { + const cap = spherePart(r * 1.06, materials.hair, materials.outline); + cap.scale.set(1, 0.8, 1.2); + cap.position.set(0, r * 0.15, -r * 0.2); + joints.head.add(cap); + return; + } + case 'wild': { + for (let i = 0; i < 7; i++) { + const spike = capsule(r * 0.2, r * (1.1 + (i % 3) * 0.5), materials.hair, materials.outline); + const angle = (i / 7) * Math.PI * 2; + spike.position.set(Math.cos(angle) * r * 0.6, r * 1.1, Math.sin(angle) * r * 0.6); + spike.rotation.set(Math.sin(angle) * 0.7, 0, -Math.cos(angle) * 0.7); + joints.head.add(spike); + } + return; + } + default: + return; + } +} + +export function buildRig(characterId: string, gradient: THREE.DataTexture): Rig { + const design = designFor(characterId); + const materials = createMaterials(design.palette, gradient); + const { build } = design; + const s = build.height; + const shoulderWidth = 0.21 * build.shoulderWidth * s; + const hipWidth = 0.13 * build.hipWidth * s; + const armR = BASE.armRadius * build.limbThickness * s; + const legR = BASE.legRadius * build.limbThickness * s; + + const root = new THREE.Group(); + const hips = new THREE.Group(); + hips.position.y = BASE.hipHeight * s; + root.add(hips); + + const torso = new THREE.Group(); + hips.add(torso); + + const chest = capsule(0.2 * build.shoulderWidth * s, BASE.torsoLength * s * 0.7, materials.primary, materials.outline); + chest.position.y = BASE.torsoLength * s * 0.42; + torso.add(chest); + + const pelvis = capsule(0.16 * build.hipWidth * s, 0.12 * s, materials.primary, materials.outline); + hips.add(pelvis); + + const head = new THREE.Group(); + head.position.y = BASE.neckHeight * s; + torso.add(head); + const skull = spherePart(BASE.headRadius * build.headSize * s, materials.skin, materials.outline); + head.add(skull); + const eyes = new THREE.Mesh( + new THREE.BoxGeometry(0.11 * s, 0.022 * s, 0.01 * s), + materials.glow, + ); + eyes.position.set(0, 0.02 * s, BASE.headRadius * build.headSize * s * 0.95); + head.add(eyes); + + const makeArm = (side: number): [THREE.Group, THREE.Group] => { + const shoulder = new THREE.Group(); + shoulder.position.set(side * shoulderWidth, BASE.shoulderY * s, 0); + torso.add(shoulder); + shoulder.add(limb(armR, BASE.upperArm * s, materials.skin, materials.outline)); + + const elbow = new THREE.Group(); + elbow.position.y = -BASE.upperArm * s; + shoulder.add(elbow); + elbow.add( + limb( + armR * 0.92, + BASE.forearm * s, + design.costume.handWraps ? materials.trim : materials.skin, + materials.outline, + ), + ); + const hand = spherePart(armR * 1.25, materials.skin, materials.outline); + hand.position.y = -BASE.forearm * s; + elbow.add(hand); + return [shoulder, elbow]; + }; + + const makeLeg = (side: number): [THREE.Group, THREE.Group] => { + const hip = new THREE.Group(); + hip.position.set(side * hipWidth, -0.04 * s, 0); + hips.add(hip); + hip.add(limb(legR, BASE.thigh * s, materials.primary, materials.outline)); + + const knee = new THREE.Group(); + knee.position.y = -BASE.thigh * s; + hip.add(knee); + knee.add(limb(legR * 0.88, BASE.shin * s, materials.secondary, materials.outline)); + const foot = boxPart(legR * 2.1, legR * 1.1, legR * 3.2, materials.secondary, materials.outline); + foot.position.set(0, -BASE.shin * s, legR * 0.7); + knee.add(foot); + return [hip, knee]; + }; + + const [shoulderL, elbowL] = makeArm(-1); + const [shoulderR, elbowR] = makeArm(1); + const [hipL, kneeL] = makeLeg(-1); + const [hipR, kneeR] = makeLeg(1); + + const joints: RigJoints = { + hips, torso, head, shoulderL, elbowL, shoulderR, elbowR, hipL, kneeL, hipR, kneeR, + }; + + addCostume(design, joints, materials, s, shoulderWidth); + addHair(design, joints, materials, s); + + const aura = createAura(design.aura.particles, design.palette.auraCore, 0.19 * s); + root.add(aura); + + const afterimages: THREE.Group[] = []; + for (let i = 0; i < design.ascended.afterimages; i++) { + const ghost = new THREE.Group(); + ghost.visible = false; + afterimages.push(ghost); + } + + const dispose = (): void => { + root.traverse((object) => { + if (object instanceof THREE.Mesh || object instanceof THREE.Points) { + object.geometry.dispose(); + } + }); + for (const material of materials.all) { + material.dispose(); + } + }; + + return { design, root, joints, materials, aura, afterimages, dispose }; +} diff --git a/ascendants/src/render/rigParts.ts b/ascendants/src/render/rigParts.ts new file mode 100644 index 0000000..c422c08 --- /dev/null +++ b/ascendants/src/render/rigParts.ts @@ -0,0 +1,153 @@ +import * as THREE from 'three'; +import type { Palette } from './design/types'; + +/** + * Primitive builders for the procedural fighters. + * + * Nothing here loads an asset. A fighter is a few dozen capsules and boxes + * arranged by numbers from the design table, which means a new character costs + * one data entry instead of a modelling pipeline. + */ + +export type RigMaterials = { + skin: THREE.MeshToonMaterial; + hair: THREE.MeshToonMaterial; + primary: THREE.MeshToonMaterial; + secondary: THREE.MeshToonMaterial; + trim: THREE.MeshToonMaterial; + glow: THREE.MeshBasicMaterial; + outline: THREE.MeshBasicMaterial; + all: THREE.Material[]; +}; + +export function createMaterials(palette: Palette, gradient: THREE.DataTexture): RigMaterials { + const toon = (color: string): THREE.MeshToonMaterial => + new THREE.MeshToonMaterial({ color, gradientMap: gradient }); + + const skin = toon(palette.skin); + const hair = toon(palette.hair); + const primary = toon(palette.primary); + const secondary = toon(palette.secondary); + const trim = toon(palette.trim); + const glow = new THREE.MeshBasicMaterial({ + color: palette.glow, + transparent: true, + opacity: 0.95, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + // Backface-expanded shell: the cheapest convincing anime outline there is. + const outline = new THREE.MeshBasicMaterial({ color: 0x05030a, side: THREE.BackSide }); + + return { + skin, + hair, + primary, + secondary, + trim, + glow, + outline, + all: [skin, hair, primary, secondary, trim, glow, outline], + }; +} + +const OUTLINE_SCALE = 1.07; + +/** Wrap a mesh with its outline shell and return the pair as one object. */ +export function outlined(mesh: THREE.Mesh, outline: THREE.Material): THREE.Mesh { + const shell = new THREE.Mesh(mesh.geometry, outline); + shell.scale.multiplyScalar(OUTLINE_SCALE); + mesh.add(shell); + mesh.castShadow = true; + return mesh; +} + +export function capsule( + radius: number, + length: number, + material: THREE.Material, + outline: THREE.Material, +): THREE.Mesh { + const geometry = new THREE.CapsuleGeometry(radius, length, 4, 10); + return outlined(new THREE.Mesh(geometry, material), outline); +} + +export function boxPart( + width: number, + height: number, + depth: number, + material: THREE.Material, + outline: THREE.Material, +): THREE.Mesh { + const geometry = new THREE.BoxGeometry(width, height, depth); + return outlined(new THREE.Mesh(geometry, material), outline); +} + +export function spherePart( + radius: number, + material: THREE.Material, + outline: THREE.Material, +): THREE.Mesh { + const geometry = new THREE.SphereGeometry(radius, 16, 12); + return outlined(new THREE.Mesh(geometry, material), outline); +} + +/** A limb segment hanging downward from its joint origin. */ +export function limb( + radius: number, + length: number, + material: THREE.Material, + outline: THREE.Material, +): THREE.Mesh { + const mesh = capsule(radius, length, material, outline); + mesh.position.y = -length / 2; + return mesh; +} + +/** + * Glowing scar lines across the torso — the mark of having survived the Herald. + * Intensity comes from the design's `scarring` value. + */ +export function scarLines( + intensity: number, + width: number, + height: number, + material: THREE.Material, +): THREE.Group { + const group = new THREE.Group(); + if (intensity <= 0) { + return group; + } + const count = Math.round(2 + intensity * 4); + for (let i = 0; i < count; i++) { + const geometry = new THREE.BoxGeometry(width * (0.3 + (i % 3) * 0.22), height * 0.035, 0.02); + const line = new THREE.Mesh(geometry, material); + line.position.set( + (i % 2 === 0 ? 1 : -1) * width * 0.12, + height * (0.18 - i * 0.09), + width * 0.34, + ); + line.rotation.z = (i % 2 === 0 ? 1 : -1) * 0.5; + group.add(line); + } + return group; +} + +/** Particle aura. Positions are re-seeded each frame by the effects layer. */ +export function createAura(count: number, color: string, size: number): THREE.Points { + const positions = new Float32Array(count * 3); + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const material = new THREE.PointsMaterial({ + color, + size, + transparent: true, + opacity: 0, + blending: THREE.AdditiveBlending, + depthWrite: false, + sizeAttenuation: true, + }); + const points = new THREE.Points(geometry, material); + points.frustumCulled = false; + return points; +} diff --git a/ascendants/src/render/scene.ts b/ascendants/src/render/scene.ts new file mode 100644 index 0000000..bf6b33a --- /dev/null +++ b/ascendants/src/render/scene.ts @@ -0,0 +1,101 @@ +import * as THREE from 'three'; + +/** + * Renderer and lighting. + * + * The look is cel-shaded: flat banded lighting, hard rim light, heavy bloom-ish + * additive effects. That is achieved with MeshToonMaterial and a two-step + * gradient ramp rather than a post-processing stack, which keeps the frame + * budget almost entirely free for the simulation and the particle work. + */ + +export type SceneBundle = { + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + /** Shared two-band ramp; every toon material samples this. */ + gradient: THREE.DataTexture; + resize: () => void; + dispose: () => void; +}; + +/** A hard two-step ramp: lit or unlit, with no soft falloff between. */ +export function createToonGradient(steps = 3): THREE.DataTexture { + const data = new Uint8Array(steps * 4); + for (let i = 0; i < steps; i++) { + // Bias the ramp dark so the lit band reads as a highlight, not a wash. + const value = Math.round(60 + (195 * i) / Math.max(1, steps - 1)); + data[i * 4] = value; + data[i * 4 + 1] = value; + data[i * 4 + 2] = value; + data[i * 4 + 3] = 255; + } + const texture = new THREE.DataTexture(data, steps, 1, THREE.RGBAFormat); + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.generateMipmaps = false; + texture.needsUpdate = true; + return texture; +} + +export function createScene(canvas: HTMLCanvasElement): SceneBundle { + const renderer = new THREE.WebGLRenderer({ + canvas, + antialias: true, + powerPreference: 'high-performance', + }); + renderer.setClearColor(0x05040a, 1); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + + const scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x0b0714, 0.012); + + const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 400); + camera.position.set(0, 4, -10); + + // Key light casts the shadows; the two rims are what actually sell the + // silhouette against a dark stage. + const key = new THREE.DirectionalLight(0xfff4e0, 2.1); + key.position.set(6, 14, -8); + key.castShadow = true; + key.shadow.mapSize.set(1024, 1024); + key.shadow.camera.near = 1; + key.shadow.camera.far = 60; + key.shadow.camera.left = -22; + key.shadow.camera.right = 22; + key.shadow.camera.top = 22; + key.shadow.camera.bottom = -22; + scene.add(key); + + const rimViolet = new THREE.DirectionalLight(0x8b5cf6, 1.5); + rimViolet.position.set(-10, 6, 9); + scene.add(rimViolet); + + const rimAmber = new THREE.DirectionalLight(0xffa14a, 0.9); + rimAmber.position.set(9, 3, 11); + scene.add(rimAmber); + + scene.add(new THREE.HemisphereLight(0x4c3a7a, 0x120a1c, 0.7)); + + const gradient = createToonGradient(); + + const resize = (): void => { + const width = canvas.clientWidth || window.innerWidth; + const height = canvas.clientHeight || window.innerHeight; + // Cap the device pixel ratio: a fighting game must hold 60fps far more + // than it needs a razor-sharp image on a high-DPI panel. + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.setSize(width, height, false); + camera.aspect = width / Math.max(1, height); + camera.updateProjectionMatrix(); + }; + resize(); + + const dispose = (): void => { + gradient.dispose(); + renderer.dispose(); + }; + + return { renderer, scene, camera, gradient, resize, dispose }; +} diff --git a/ascendants/src/render/stage.ts b/ascendants/src/render/stage.ts new file mode 100644 index 0000000..80009e3 --- /dev/null +++ b/ascendants/src/render/stage.ts @@ -0,0 +1,114 @@ +import * as THREE from 'three'; +import { ARENA_CEILING, ARENA_RADIUS } from '../engine/data/constants'; + +/** + * The arena: a cracked crater floor under an open sky, ringed by an energy + * barrier that only becomes visible when someone is pushed into it. + * + * The barrier is the readable version of a rule the simulation already + * enforces — fighters cannot leave the disc — and showing it only on contact + * keeps the space feeling open while still explaining the wall. + */ + +export type Stage = { + group: THREE.Group; + /** Flash the barrier where a fighter hit it. */ + pulseBarrier: (x: number, z: number, strength: number) => void; + update: (timeSec: number) => void; + dispose: () => void; +}; + +export function createStage(gradient: THREE.DataTexture): Stage { + const group = new THREE.Group(); + const disposables: { dispose: () => void }[] = []; + + const floorGeometry = new THREE.CircleGeometry(ARENA_RADIUS, 96); + floorGeometry.rotateX(-Math.PI / 2); + const floorMaterial = new THREE.MeshToonMaterial({ + color: 0x241a33, + gradientMap: gradient, + }); + const floor = new THREE.Mesh(floorGeometry, floorMaterial); + floor.receiveShadow = true; + group.add(floor); + disposables.push(floorGeometry, floorMaterial); + + // Concentric rings give the eye a sense of distance and closing speed. + const ringMaterial = new THREE.MeshBasicMaterial({ + color: 0x6d28d9, + transparent: true, + opacity: 0.22, + side: THREE.DoubleSide, + }); + disposables.push(ringMaterial); + for (const radius of [6, 12, 18, ARENA_RADIUS - 0.4]) { + const ringGeometry = new THREE.RingGeometry(radius - 0.06, radius, 96); + ringGeometry.rotateX(-Math.PI / 2); + const ring = new THREE.Mesh(ringGeometry, ringMaterial); + ring.position.y = 0.01; + group.add(ring); + disposables.push(ringGeometry); + } + + const barrierGeometry = new THREE.CylinderGeometry( + ARENA_RADIUS, + ARENA_RADIUS, + ARENA_CEILING, + 72, + 1, + true, + ); + const barrierMaterial = new THREE.MeshBasicMaterial({ + color: 0x8b5cf6, + transparent: true, + opacity: 0.04, + side: THREE.BackSide, + depthWrite: false, + }); + const barrier = new THREE.Mesh(barrierGeometry, barrierMaterial); + barrier.position.y = ARENA_CEILING / 2; + group.add(barrier); + disposables.push(barrierGeometry, barrierMaterial); + + const impactGeometry = new THREE.SphereGeometry(1.6, 16, 12); + const impactMaterial = new THREE.MeshBasicMaterial({ + color: 0xc4b5fd, + transparent: true, + opacity: 0, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + const impact = new THREE.Mesh(impactGeometry, impactMaterial); + impact.visible = false; + group.add(impact); + disposables.push(impactGeometry, impactMaterial); + + let barrierFlash = 0; + + const pulseBarrier = (x: number, z: number, strength: number): void => { + impact.position.set(x, 1.4, z); + impact.visible = true; + barrierFlash = Math.min(1, strength); + }; + + const update = (timeSec: number): void => { + // Slow breathing on the barrier so the arena never looks frozen. + barrierMaterial.opacity = 0.04 + Math.sin(timeSec * 0.8) * 0.012 + barrierFlash * 0.3; + if (barrierFlash > 0) { + barrierFlash = Math.max(0, barrierFlash - 0.05); + impactMaterial.opacity = barrierFlash * 0.7; + impact.scale.setScalar(1 + (1 - barrierFlash) * 0.45); + if (barrierFlash === 0) { + impact.visible = false; + } + } + }; + + const dispose = (): void => { + for (const item of disposables) { + item.dispose(); + } + }; + + return { group, pulseBarrier, update, dispose }; +} diff --git a/ascendants/src/render/view.ts b/ascendants/src/render/view.ts new file mode 100644 index 0000000..8382a24 --- /dev/null +++ b/ascendants/src/render/view.ts @@ -0,0 +1,141 @@ +import * as THREE from 'three'; +import { ARENA_RADIUS } from '../engine/data/constants'; +import { getCharacter } from '../engine/data/characters'; +import type { EffectEvent, MatchState } from '../engine/types/match'; +import { createFightCamera, type FightCamera } from './camera'; +import { createImpactPool, createProjectileView, updateAura } from './effects'; +import { poseRig } from './pose'; +import { buildRig, type Rig } from './rig'; +import { createScene, type SceneBundle } from './scene'; +import { createStage, type Stage } from './stage'; + +/** + * The bridge from simulation state to pixels. + * + * The view is strictly a reader: it never writes to MatchState and holds no + * authority over anything. That separation is what allows the renderer to skip + * frames, interpolate, or be swapped out entirely without touching the game. + * + * Effects are passed in accumulated rather than read off the current state, + * because the simulation may have advanced several frames since the last draw + * and a dropped impact flash is a dropped piece of feedback. + */ + +const SHAKE_THRESHOLD = 0.8; +const WALL_MARGIN = 1.6; + +export class MatchView { + private readonly bundle: SceneBundle; + private readonly stage: Stage; + private readonly rigs: [Rig, Rig]; + private readonly impacts = createImpactPool(); + private readonly projectiles = createProjectileView(); + private readonly camera: FightCamera; + private readonly accents: [string, string]; + private elapsed = 0; + + constructor(canvas: HTMLCanvasElement, characterIds: readonly [string, string]) { + this.bundle = createScene(canvas); + this.stage = createStage(this.bundle.gradient); + this.bundle.scene.add(this.stage.group); + + this.rigs = [ + buildRig(characterIds[0], this.bundle.gradient), + buildRig(characterIds[1], this.bundle.gradient), + ]; + for (const rig of this.rigs) { + this.bundle.scene.add(rig.root); + } + + this.bundle.scene.add(this.impacts.group); + this.bundle.scene.add(this.projectiles.group); + this.camera = createFightCamera(this.bundle.camera); + this.accents = [ + getCharacter(characterIds[0]).accent, + getCharacter(characterIds[1]).accent, + ]; + } + + resize(): void { + this.bundle.resize(); + } + + render(state: MatchState, effects: readonly EffectEvent[], deltaSeconds: number): void { + this.elapsed += deltaSeconds; + + for (const index of [0, 1] as const) { + const fighter = state.fighters[index]; + const rig = this.rigs[index]; + + rig.root.position.set(fighter.pos.x, fighter.pos.y, fighter.pos.z); + rig.root.rotation.y = fighter.facing; + // An erased fighter sinks out of the arena rather than simply vanishing. + rig.root.visible = fighter.state !== 'erased' || fighter.stateFrame < 60; + if (fighter.state === 'erased') { + rig.root.position.y -= fighter.stateFrame * 0.02; + } + + poseRig(rig, fighter, this.elapsed); + updateAura(rig, fighter, this.elapsed); + + const radius = Math.hypot(fighter.pos.x, fighter.pos.z); + if (radius > ARENA_RADIUS - WALL_MARGIN) { + this.stage.pulseBarrier(fighter.pos.x, fighter.pos.z, 0.5); + } + } + + this.impacts.spawn(effects); + for (const effect of effects) { + if (effect.power >= SHAKE_THRESHOLD || effect.kind === 'erasure') { + this.camera.shake(Math.min(1.2, effect.power * 0.6)); + } + } + this.impacts.update(); + + this.projectiles.sync(state.projectiles, this.accents, [ + state.fighters[0].pos, + state.fighters[1].pos, + ]); + this.stage.update(this.elapsed); + this.camera.update(state, deltaSeconds); + + this.bundle.renderer.render(this.bundle.scene, this.bundle.camera); + } + + /** + * Whether the opponent currently sits to the screen-right of this fighter. + * + * The client needs this to translate "player pressed right" into "toward the + * opponent". It is derived from the live camera orientation, so it stays + * correct through cross-ups and camera side flips alike. + */ + towardSign(state: MatchState, index: 0 | 1): number { + const self = state.fighters[index]; + const opponent = state.fighters[index === 0 ? 1 : 0]; + const right = new THREE.Vector3(1, 0, 0).applyQuaternion(this.bundle.camera.quaternion); + const toOpponent = new THREE.Vector3( + opponent.pos.x - self.pos.x, + 0, + opponent.pos.z - self.pos.z, + ); + if (toOpponent.lengthSq() < 1e-6) { + return 1; + } + return right.dot(toOpponent) >= 0 ? 1 : -1; + } + + /** Exposed so the client can report EFFICIENCY telemetry without a global. */ + get drawInfo(): THREE.WebGLInfo['render'] { + return this.bundle.renderer.info.render; + } + + dispose(): void { + for (const rig of this.rigs) { + rig.dispose(); + } + this.stage.dispose(); + this.impacts.dispose(); + this.projectiles.dispose(); + this.bundle.dispose(); + } +} diff --git a/ascendants/src/ui/hud.ts b/ascendants/src/ui/hud.ts new file mode 100644 index 0000000..574a13c --- /dev/null +++ b/ascendants/src/ui/hud.ts @@ -0,0 +1,219 @@ +import { DRIVE_BAR, DRIVE_MAX, FATAL_HEALTH_RATIO, KI_MAX, SUPER_MAX, TICK_RATE } from '../engine/data/constants'; +import { getCharacter } from '../engine/data/characters'; +import { erasureById, finisherCallout, FINISHER_SUBTITLE } from '../engine/data/finishers'; +import type { Fighter } from '../engine/types/fighter'; +import type { MatchState } from '../engine/types/match'; + +/** + * The heads-up display. + * + * Built from DOM rather than drawn into the 3D scene: text stays crisp at every + * resolution, the layout costs nothing on the render thread, and the bars can + * animate with CSS transitions instead of per-frame geometry work. + * + * The health bar carries a slower "ghost" behind it showing damage just taken. + * That single detail is how a player reads the size of a combo at a glance + * while they are still being hit by it. + */ + +const DRIVE_SEGMENTS = DRIVE_MAX / DRIVE_BAR; + +function bars(side: 'left' | 'right'): string { + const segments = Array.from( + { length: DRIVE_SEGMENTS }, + () => '
', + ).join(''); + return ` +
+
+ + +
+
+
+
+
+
+
${segments}
+
+
+
+
+
+
`; +} + +type SideRefs = { + name: HTMLElement; + wins: HTMLElement; + health: HTMLElement; + ghost: HTMLElement; + danger: HTMLElement; + driveTrack: HTMLElement; + driveSegments: HTMLElement[]; + ki: HTMLElement; + superMeter: HTMLElement; + status: HTMLElement; + ghostValue: number; +}; + +function refsFor(root: HTMLElement, side: string): SideRefs { + const scope = root.querySelector(`.side.${side}`); + if (!scope) { + throw new Error(`HUD side missing: ${side}`); + } + const pick = (selector: string): HTMLElement => { + const found = scope.querySelector(selector); + if (!found) { + throw new Error(`HUD element missing: ${selector}`); + } + return found; + }; + return { + name: pick('[data-name]'), + wins: pick('[data-wins]'), + health: pick('[data-health]'), + ghost: pick('[data-ghost]'), + danger: pick('[data-danger]'), + driveTrack: pick('[data-drive-track]'), + driveSegments: Array.from(scope.querySelectorAll('.drive-seg i')), + ki: pick('[data-ki]'), + superMeter: pick('[data-super]'), + status: pick('[data-status]'), + ghostValue: 1, + }; +} + +export type HudExtras = { + /** Shown in the corner during online play. */ + readonly netLine?: string; +}; + +export class Hud { + private readonly root: HTMLElement; + private readonly sides: [SideRefs, SideRefs]; + private readonly timer: HTMLElement; + private readonly banner: HTMLElement; + private readonly bannerSub: HTMLElement; + private readonly combo: HTMLElement; + private readonly net: HTMLElement; + + constructor(container: HTMLElement) { + container.innerHTML = ` +
+ ${bars('left')} +
+
99
+
+
+ ${bars('right')} +
+ +
`; + + this.root = container; + this.sides = [refsFor(container, 'left'), refsFor(container, 'right')]; + this.timer = this.require('[data-timer]'); + this.banner = this.require('[data-banner]'); + this.bannerSub = this.require('[data-banner-sub]'); + this.combo = this.require('[data-combo]'); + this.net = this.require('[data-net]'); + } + + private require(selector: string): HTMLElement { + const found = this.root.querySelector(selector); + if (!found) { + throw new Error(`HUD element missing: ${selector}`); + } + return found; + } + + private updateSide(refs: SideRefs, fighter: Fighter): void { + const character = getCharacter(fighter.characterId); + const ratio = fighter.health / fighter.maxHealth; + + refs.name.textContent = character.name; + refs.name.style.color = character.accent; + refs.wins.textContent = '●'.repeat(fighter.wins) + '○'.repeat(Math.max(0, 2 - fighter.wins)); + refs.health.style.width = `${ratio * 100}%`; + refs.health.style.background = `linear-gradient(90deg, ${character.accent}, ${character.secondary})`; + + // The ghost chases the real bar, so a long combo leaves a visible tail. + refs.ghostValue += (ratio - refs.ghostValue) * 0.08; + refs.ghost.style.width = `${Math.max(ratio, refs.ghostValue) * 100}%`; + + // Fatal Blow territory gets its own colour so the threat is obvious. + refs.danger.style.opacity = ratio <= FATAL_HEALTH_RATIO && !fighter.fatalUsed ? '1' : '0'; + + const burnedOut = fighter.burnout > 0; + refs.driveTrack.classList.toggle('burnout', burnedOut); + const filledSegments = fighter.drive / DRIVE_BAR; + refs.driveSegments.forEach((segment, index) => { + const fill = Math.max(0, Math.min(1, filledSegments - index)); + segment.style.width = `${fill * 100}%`; + }); + + refs.ki.style.width = `${(fighter.ki / KI_MAX) * 100}%`; + refs.ki.classList.toggle('full', fighter.ki >= KI_MAX); + refs.superMeter.style.width = `${Math.min(1, fighter.superMeter / SUPER_MAX) * 100}%`; + + const status: string[] = []; + if (burnedOut) status.push('BURNOUT'); + if (fighter.transformed) status.push('ASCENDED'); + if (fighter.superMeter >= SUPER_MAX) status.push('SUPER READY'); + if (!fighter.fatalUsed && ratio <= FATAL_HEALTH_RATIO) status.push('FATAL BLOW'); + refs.status.textContent = status.join(' · '); + } + + private bannerFor(state: MatchState): [string, string] { + switch (state.phase) { + case 'intro': + return [`ROUND ${state.round}`, state.phaseFrame > 60 ? 'FIGHT' : '']; + case 'roundEnd': + return [state.lastRoundWinner < 0 ? 'DRAW' : 'K.O.', '']; + case 'finisher': { + const loser = state.fighters[state.finisher.performer === 0 ? 1 : 0]; + return [finisherCallout(loser.characterId), FINISHER_SUBTITLE]; + } + case 'erasure': { + const erasure = state.finisher.performed + ? erasureById(state.finisher.performed) + : undefined; + return [erasure?.name ?? 'ERASURE', erasure?.caption ?? '']; + } + case 'matchEnd': { + const winner = state.fighters[0].wins > state.fighters[1].wins ? 0 : 1; + return [`${getCharacter(state.fighters[winner].characterId).name} WINS`, '']; + } + default: + return ['', '']; + } + } + + update(state: MatchState, extras: HudExtras = {}): void { + this.updateSide(this.sides[0], state.fighters[0]); + this.updateSide(this.sides[1], state.fighters[1]); + + this.timer.textContent = String(Math.ceil(state.timer / TICK_RATE)); + this.timer.classList.toggle('urgent', state.timer < 10 * TICK_RATE); + + // A combo counter belongs to whoever is landing it, so it reads off the + // hits the *other* fighter is currently taking. + const comboOnLeft = state.fighters[1].comboHits; + const comboOnRight = state.fighters[0].comboHits; + const hits = Math.max(comboOnLeft, comboOnRight); + this.combo.textContent = hits > 1 ? `${hits} HITS` : ''; + this.combo.classList.toggle('active', hits > 1); + + const [title, subtitle] = this.bannerFor(state); + this.banner.textContent = title; + this.bannerSub.textContent = subtitle; + this.banner.classList.toggle('show', title !== ''); + this.banner.classList.toggle('finisher', state.phase === 'finisher' || state.phase === 'erasure'); + + this.net.textContent = extras.netLine ?? ''; + } +} diff --git a/ascendants/src/ui/menu.ts b/ascendants/src/ui/menu.ts new file mode 100644 index 0000000..30c1602 --- /dev/null +++ b/ascendants/src/ui/menu.ts @@ -0,0 +1,252 @@ +import { ROSTER, type Character } from '../engine/data/characters'; +import { FINISHER_CALLOUT_GENERIC, erasureFor } from '../engine/data/finishers'; +import { getMove } from '../engine/data/moves'; +import { designFor } from '../render/design'; +import { renderPortraits } from '../render/portrait'; +import { frameNotation, inputNotation, motionNotation, buttonNotation } from './notation'; +import type { Difficulty } from '../engine/ai/cpu'; + +/** + * Front end: title, mode select, character select, room entry. + * + * Each screen resolves a promise, so the flow reads as a straight line in + * `run()` instead of a state machine with transitions to keep in sync. + */ + +export type MatchSetup = { + readonly mode: + | { kind: 'cpu'; difficulty: Difficulty } + | { kind: 'versus' } + | { kind: 'online'; roomId: string }; + readonly characterIds: [string, string]; + readonly localIndex: 0 | 1; +}; + +const DIFFICULTIES: { id: Difficulty; label: string; blurb: string }[] = [ + { id: 'rookie', label: 'ROOKIE', blurb: 'Slow to react. Blocks rarely.' }, + { id: 'fighter', label: 'FIGHTER', blurb: 'Blocks, punishes, escapes combos.' }, + { id: 'herald', label: 'HERALD', blurb: 'Reacts in three frames. Uses everything.' }, +]; + +function statBar(label: string, value: number, max = 2): string { + const percent = Math.max(4, Math.min(100, (value / max) * 100)); + // The fill is percentage-width, so it needs its own track to resolve + // against — sized against the whole row it overflows past the label. + return `
${label}
`; +} + +/** One row of the move list: name, input notation, and its frame data. */ +function skillRow(moveId: string): string { + const move = getMove(moveId); + return ` +
  • + ${move.name} + ${inputNotation(move)} + ${frameNotation(move)} +
  • `; +} + +function erasureRow(characterId: string): string { + const erasure = erasureFor(characterId); + if (!erasure) { + return ''; + } + const notation = `${motionNotation(erasure.motion)} + ${buttonNotation(erasure.button)}`; + return ` +
  • + ${erasure.name} + ${notation} + ERASURE · finisher only +
  • `; +} + +function characterCard(character: Character, portrait: string | undefined): string { + const design = designFor(character.id); + const stats = character.stats; + const portraitStyle = portrait + ? `background-image:url(${portrait});background-size:contain;background-position:center bottom;background-repeat:no-repeat;` + : ''; + return ` + `; +} + +export class Menu { + /** Rendered lazily: building six rigs is wasted work until it is needed. */ + private portraits: Map | null = null; + + constructor(private readonly root: HTMLElement) {} + + private getPortraits(): Map { + if (!this.portraits) { + try { + this.portraits = renderPortraits(); + } catch { + // A machine without WebGL still gets a usable select screen, just + // with flat colour blocks instead of rendered fighters. + this.portraits = new Map(); + } + } + return this.portraits; + } + + private show(html: string): void { + this.root.innerHTML = ``; + this.root.classList.remove('hidden'); + } + + hide(): void { + this.root.classList.add('hidden'); + this.root.innerHTML = ''; + } + + private title(): Promise { + return new Promise((resolve) => { + this.show(` +

    ASCENDANTS

    +

    The sky fell. Some of us got back up.

    + +

    ${FINISHER_CALLOUT_GENERIC}

    `); + this.root.querySelector('[data-start]')?.addEventListener('click', () => resolve()); + }); + } + + private mode(): Promise { + return new Promise((resolve) => { + this.show(` +

    SELECT MODE

    +
    + + + +
    +
    + ${DIFFICULTIES.map( + (d, i) => + ``, + ).join('')} +
    `); + + let difficulty: Difficulty = 'fighter'; + for (const chip of this.root.querySelectorAll('[data-difficulty]')) { + chip.addEventListener('click', () => { + for (const other of this.root.querySelectorAll('[data-difficulty]')) { + other.classList.remove('selected'); + } + chip.classList.add('selected'); + difficulty = (chip.dataset.difficulty as Difficulty) ?? 'fighter'; + }); + } + + for (const card of this.root.querySelectorAll('[data-mode]')) { + card.addEventListener('click', () => { + const kind = card.dataset.mode; + if (kind === 'cpu') { + resolve({ kind: 'cpu', difficulty }); + } else if (kind === 'versus') { + resolve({ kind: 'versus' }); + } else { + void this.roomCode().then((roomId) => resolve({ kind: 'online', roomId })); + } + }); + } + }); + } + + private roomCode(): Promise { + return new Promise((resolve) => { + // Suggested rather than imposed: both players need the same string, and + // typing a shared word is easier to say out loud than a generated id. + const suggested = Math.random().toString(36).slice(2, 7).toUpperCase(); + this.show(` +

    ROOM CODE

    +

    Both players enter the same code.

    + + `); + + const input = this.root.querySelector('[data-room]'); + const confirm = (): void => resolve((input?.value || suggested).trim().toUpperCase()); + this.root.querySelector('[data-confirm]')?.addEventListener('click', confirm); + input?.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + confirm(); + } + }); + input?.focus(); + }); + } + + private pickCharacter(prompt: string): Promise { + return new Promise((resolve) => { + const portraits = this.getPortraits(); + this.show(` +

    ${prompt}

    +
    + ${ROSTER.map((c) => characterCard(c, portraits.get(c.id))).join('')} +
    `); + + for (const card of this.root.querySelectorAll('[data-character]')) { + card.addEventListener('click', () => { + const id = card.dataset.character; + if (id) { + resolve(id); + } + }); + } + }); + } + + status(message: string): void { + this.show(`

    ${message}

    `); + } + + async run(): Promise { + await this.title(); + const mode = await this.mode(); + + if (mode.kind === 'online') { + // Online only picks your own fighter: the server pairs the two hellos and + // sends back the authoritative pair, so choosing an opponent here would + // be a lie the start message immediately overwrites. + const own = await this.pickCharacter('YOUR FIGHTER'); + return { mode, characterIds: [own, 'korvath'], localIndex: 0 }; + } + + const first = await this.pickCharacter('PLAYER ONE'); + const second = await this.pickCharacter(mode.kind === 'versus' ? 'PLAYER TWO' : 'OPPONENT'); + return { mode, characterIds: [first, second], localIndex: 0 }; + } +} diff --git a/ascendants/src/ui/notation.ts b/ascendants/src/ui/notation.ts new file mode 100644 index 0000000..86e858c --- /dev/null +++ b/ascendants/src/ui/notation.ts @@ -0,0 +1,61 @@ +import { Button } from '../engine/types/input'; +import type { Move } from '../engine/types/move'; +import { totalFrames } from '../engine/types/move'; + +/** + * Fighting-game notation. + * + * Move lists are read at a glance during character select, so directions are + * shown as arrows rather than numpad digits — a player who has never seen this + * game still recognises a quarter-circle from its shape. + */ + +const ARROWS: Record = { + 1: '↙', + 2: '↓', + 3: '↘', + 4: '←', + 5: '·', + 6: '→', + 7: '↖', + 8: '↑', + 9: '↗', +}; + +const BUTTON_LABELS: [number, string][] = [ + [Button.Light, 'L'], + [Button.Medium, 'M'], + [Button.Heavy, 'H'], + [Button.Ki, 'K'], + [Button.Guard, 'G'], + [Button.Drive, 'D'], + [Button.Dash, 'DS'], + [Button.Charge, 'C'], + [Button.Vanish, 'V'], +]; + +export function motionNotation(motion: readonly number[]): string { + return motion.map((dir) => ARROWS[dir] ?? String(dir)).join(''); +} + +export function buttonNotation(mask: number): string { + const parts = BUTTON_LABELS.filter(([bit]) => (mask & bit) !== 0).map(([, label]) => label); + return parts.join('+'); +} + +/** Full input for a move, e.g. "↓↘→ + K". */ +export function inputNotation(move: Move): string { + const motion = motionNotation(move.input.motion); + const hold = move.input.holdDir ? `${ARROWS[move.input.holdDir] ?? ''} ` : ''; + const buttons = buttonNotation(move.input.button); + return motion ? `${motion} + ${buttons}` : `${hold}${buttons}`; +} + +/** + * Compact frame data. Projectile moves report the projectile's damage, since + * the move itself does none — showing 0 there would read as a bug. + */ +export function frameNotation(move: Move): string { + const damage = move.projectile ? move.projectile.hit.damage : move.hit.damage; + return `${move.startup}f · ${damage} dmg · ${totalFrames(move)}f total`; +} diff --git a/ascendants/src/ui/styles.css b/ascendants/src/ui/styles.css new file mode 100644 index 0000000..6b94dec --- /dev/null +++ b/ascendants/src/ui/styles.css @@ -0,0 +1,263 @@ +/* + * Presentation layer. + * + * Dark obsidian ground, violet and gold accents, condensed display type. The + * HUD is deliberately high-contrast and static in position: in a fighting game + * the player reads health, meter and the combo counter with peripheral vision + * while watching the fighters, so nothing in it may move or animate its layout. + */ + +:root { + --obsidian: #08060f; + --panel: rgba(14, 10, 24, 0.82); + --violet: #8b5cf6; + --gold: #c9a84c; + --danger: #ff3b3b; + --text: #efeaf7; + --muted: #7a7090; + --display: 'Cinzel', 'Times New Roman', serif; + --ui: 'DM Mono', 'SF Mono', Menlo, monospace; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +html, body { + width: 100%; height: 100%; overflow: hidden; + background: var(--obsidian); color: var(--text); + font-family: var(--ui); + -webkit-font-smoothing: antialiased; +} + +#stage { position: fixed; inset: 0; width: 100%; height: 100%; display: block; } + +#hud, #menu { position: fixed; inset: 0; pointer-events: none; } +#menu { pointer-events: auto; overflow-y: auto; } +#menu.hidden { display: none; } + +/* ── HUD ─────────────────────────────────────────── */ + +.hud { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: clamp(8px, 2vw, 28px); + padding: clamp(10px, 2vh, 22px) clamp(12px, 3vw, 40px); + align-items: start; +} + +.side { display: flex; flex-direction: column; gap: 5px; } +.side.right { align-items: flex-end; } +.side.right .health-track > div, +.side.right .drive-track, +.side.right .ki-track, +.side.right .super-track { direction: rtl; } +.side.right .name-row { flex-direction: row-reverse; } +.side.right .meter-row { flex-direction: row-reverse; } + +.name-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; width: 100%; } +.name { font-family: var(--display); font-size: clamp(14px, 1.8vw, 22px); letter-spacing: 0.14em; font-weight: 700; } +.wins { color: var(--gold); font-size: 12px; letter-spacing: 3px; } + +.health-track { + position: relative; width: 100%; height: clamp(14px, 2vh, 20px); + background: #16101f; border: 1px solid rgba(255,255,255,0.14); + overflow: hidden; +} +.health-ghost { position: absolute; inset: 0 auto 0 0; height: 100%; background: rgba(255,90,90,0.5); } +.health-fill { position: absolute; inset: 0 auto 0 0; height: 100%; transition: width 90ms linear; } +.side.right .health-ghost, .side.right .health-fill { left: auto; right: 0; } +.health-danger { + position: absolute; inset: 0; border: 1px solid var(--danger); + box-shadow: inset 0 0 12px rgba(255,59,59,0.55); opacity: 0; + transition: opacity 200ms ease; pointer-events: none; +} + +.drive-track { display: flex; gap: 2px; width: 100%; height: 7px; } +.drive-seg { flex: 1; background: #16101f; border: 1px solid rgba(255,255,255,0.1); } +.drive-seg i { display: block; height: 100%; background: linear-gradient(90deg, #4ade80, #22c55e); } +.drive-track.burnout .drive-seg i { background: linear-gradient(90deg, #f87171, #b91c1c); } +.drive-track.burnout { animation: burnPulse 700ms ease-in-out infinite; } +@keyframes burnPulse { 50% { opacity: 0.55; } } + +.meter-row { display: flex; gap: 6px; width: 100%; } +.ki-track, .super-track { height: 5px; background: #16101f; border: 1px solid rgba(255,255,255,0.1); } +.ki-track { flex: 2; } +.super-track { flex: 1; } +.ki-fill { height: 100%; background: linear-gradient(90deg, #38bdf8, #a5f3fc); } +.ki-fill.full { background: linear-gradient(90deg, #fff, #a5f3fc); box-shadow: 0 0 10px #a5f3fc; } +.super-fill { height: 100%; background: linear-gradient(90deg, var(--gold), #f5d98a); } + +.status-row { min-height: 14px; font-size: 10px; letter-spacing: 2px; color: var(--gold); } + +.center { display: flex; flex-direction: column; align-items: center; gap: 6px; } +.timer { + font-family: var(--display); font-size: clamp(28px, 4vw, 46px); font-weight: 900; + line-height: 1; color: var(--text); text-shadow: 0 0 22px rgba(139,92,246,0.55); +} +.timer.urgent { color: var(--danger); text-shadow: 0 0 24px rgba(255,59,59,0.7); } +.combo { font-size: 12px; letter-spacing: 3px; color: var(--gold); opacity: 0; } +.combo.active { opacity: 1; } + +/* ── Announcer banner ────────────────────────────── */ + +.banner-wrap { + /* Sits above the fighters rather than across them: the announcer must never + obscure the thing it is announcing. */ + position: fixed; inset: 0; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 10px; pointer-events: none; + padding-bottom: 30vh; +} +.banner { + font-family: var(--display); font-weight: 900; + font-size: clamp(30px, 6.5vw, 88px); letter-spacing: 0.12em; + opacity: 0; transform: scale(0.94); transition: opacity 160ms ease, transform 160ms ease; + text-shadow: 0 0 60px rgba(139,92,246,0.6); +} +.banner.show { opacity: 1; transform: scale(1); } +.banner.finisher { + color: #ff2d2d; + text-shadow: 0 0 40px rgba(255,45,45,0.9), 0 0 120px rgba(255,45,45,0.45); + animation: finisherPulse 620ms ease-in-out infinite; +} +@keyframes finisherPulse { 50% { transform: scale(1.045); } } +.banner-sub { font-size: clamp(10px, 1.3vw, 14px); letter-spacing: 0.42em; color: var(--muted); } + +.net-line { + position: fixed; bottom: 10px; right: 14px; + font-size: 10px; letter-spacing: 1.5px; color: var(--muted); +} + +/* ── Menus ───────────────────────────────────────── */ + +.menu { + min-height: 100%; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 22px; + padding: 40px 24px; + background: radial-gradient(ellipse at 50% 0%, #1b1030 0%, var(--obsidian) 65%); +} +.logo { + font-family: var(--display); font-size: clamp(44px, 11vw, 132px); font-weight: 900; + letter-spacing: 0.16em; + background: linear-gradient(135deg, var(--violet), #f5d98a 55%, var(--gold)); + -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; +} +.menu h2 { font-family: var(--display); font-size: clamp(18px, 2.6vw, 30px); letter-spacing: 0.3em; } +.tagline { color: var(--muted); letter-spacing: 0.22em; font-size: clamp(11px, 1.4vw, 14px); text-align: center; } +.footnote { color: #4a3c5e; letter-spacing: 0.4em; font-size: 10px; margin-top: 26px; } + +.primary { + font-family: var(--display); font-weight: 700; letter-spacing: 0.28em; + background: linear-gradient(135deg, var(--violet), #6d28d9); + color: #fff; border: none; padding: 15px 54px; font-size: 15px; cursor: pointer; + box-shadow: 0 0 40px rgba(139,92,246,0.45); transition: transform 120ms ease, box-shadow 120ms ease; +} +.primary:hover { transform: scale(1.04); box-shadow: 0 0 62px rgba(139,92,246,0.75); } + +.mode-grid { display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; } +.mode-card { + display: flex; flex-direction: column; gap: 8px; align-items: flex-start; + background: var(--panel); border: 1px solid rgba(139,92,246,0.28); + padding: 22px 28px; min-width: 210px; cursor: pointer; color: var(--text); + transition: border-color 120ms ease, transform 120ms ease; +} +.mode-card:hover { border-color: var(--gold); transform: translateY(-3px); } +.mode-name { font-family: var(--display); letter-spacing: 0.2em; font-size: 17px; } +.mode-blurb { color: var(--muted); font-size: 11px; letter-spacing: 0.08em; } + +.difficulty-row { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; } +.chip { + display: flex; flex-direction: column; gap: 3px; align-items: flex-start; + background: transparent; border: 1px solid rgba(255,255,255,0.14); + color: var(--muted); padding: 9px 16px; cursor: pointer; font-family: var(--ui); +} +.chip strong { color: var(--text); letter-spacing: 0.2em; font-size: 11px; } +.chip span { font-size: 10px; } +.chip.selected { border-color: var(--gold); color: var(--gold); } + +.roster { + display: flex; flex-wrap: wrap; gap: 14px; justify-content: center; + max-width: 1320px; align-items: stretch; +} +.fighter-card { + width: 300px; background: var(--panel); border: 1px solid rgba(255,255,255,0.1); + padding: 12px; cursor: pointer; color: var(--text); text-align: left; + display: flex; flex-direction: column; gap: 10px; + transition: border-color 120ms ease, transform 120ms ease, box-shadow 120ms ease; +} +.fighter-card:hover, .fighter-card:focus-visible { + border-color: var(--accent); transform: translateY(-4px); + box-shadow: 0 10px 34px rgba(0,0,0,0.55); + outline: none; +} + +.fighter-head { display: flex; gap: 12px; align-items: stretch; } +.fighter-portrait { + width: 104px; flex: 0 0 104px; min-height: 132px; + border: 1px solid rgba(255,255,255,0.09); + /* The rendered fighter stands on the card's floor line. */ + background-blend-mode: normal; +} +.fighter-id { flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.fighter-name { + font-family: var(--display); letter-spacing: 0.14em; font-size: 17px; color: var(--accent); +} +.fighter-title { color: var(--muted); font-size: 10px; letter-spacing: 0.08em; } +.fighter-stats { display: flex; flex-direction: column; gap: 3px; margin-top: 4px; } +.stat { display: flex; align-items: center; gap: 6px; font-size: 9px; color: var(--muted); } +.stat span { width: 24px; flex: 0 0 24px; } +.stat .bar { + flex: 1; min-width: 0; height: 3px; + background: rgba(255,255,255,0.08); +} +.stat .bar i { + display: block; height: 100%; background: var(--accent); + box-shadow: 0 0 6px var(--accent); +} + +.skill-list { + list-style: none; display: flex; flex-direction: column; gap: 5px; + border-top: 1px solid rgba(255,255,255,0.07); padding-top: 8px; +} +.skill { + display: grid; grid-template-columns: 1fr auto; gap: 1px 8px; align-items: baseline; +} +.skill-name { font-size: 11px; letter-spacing: 0.06em; color: var(--text); } +.skill-input { + font-size: 12px; letter-spacing: 0.08em; color: var(--accent); + justify-self: end; white-space: nowrap; +} +.skill-frames { + grid-column: 1 / -1; font-size: 9px; color: #5c5177; letter-spacing: 0.04em; +} +.skill.erasure .skill-name { color: var(--danger); } +.skill.erasure .skill-input { color: var(--danger); } +.skill.erasure .skill-frames { color: #7a3550; } + +.fighter-silhouette { + color: #5c5177; font-size: 9px; line-height: 1.5; margin-top: auto; + border-top: 1px solid rgba(255,255,255,0.05); padding-top: 7px; +} + +.room-input { + background: #120d1e; border: 1px solid rgba(139,92,246,0.4); color: var(--text); + font-family: var(--display); font-size: 30px; letter-spacing: 0.34em; + text-align: center; padding: 14px 20px; width: min(360px, 82vw); +} + +.spinner { + width: 30px; height: 30px; border: 2px solid rgba(139,92,246,0.25); + border-top-color: var(--violet); border-radius: 50%; + animation: spin 800ms linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.fatal { + position: fixed; inset: 0; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 14px; text-align: center; padding: 24px; +} +.fatal h1 { font-family: var(--display); letter-spacing: 0.2em; color: var(--gold); } +.fatal p { color: var(--danger); font-size: 13px; } + +@media (max-width: 640px) { + .hud { grid-template-columns: 1fr auto 1fr; padding: 8px 10px; } + .fighter-card { width: min(300px, 92vw); } +} diff --git a/ascendants/tsconfig.json b/ascendants/tsconfig.json new file mode 100644 index 0000000..08d0f75 --- /dev/null +++ b/ascendants/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vitest/globals", "vite/client"], + "baseUrl": ".", + "paths": { + "@engine/*": ["src/engine/*"], + "@net/*": ["src/net/*"], + "@render/*": ["src/render/*"], + "@ui/*": ["src/ui/*"], + "@client/*": ["src/client/*"], + "@telemetry/*": ["src/telemetry/*"] + } + }, + "include": ["src", "server", "vite.config.ts"] +} diff --git a/ascendants/vite.config.ts b/ascendants/vite.config.ts new file mode 100644 index 0000000..65727a7 --- /dev/null +++ b/ascendants/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vitest/config'; +import { fileURLToPath, URL } from 'node:url'; + +const resolvePath = (dir: string) => fileURLToPath(new URL(dir, import.meta.url)); + +export default defineConfig({ + resolve: { + alias: { + '@engine': resolvePath('./src/engine'), + '@net': resolvePath('./src/net'), + '@render': resolvePath('./src/render'), + '@ui': resolvePath('./src/ui'), + '@client': resolvePath('./src/client'), + '@telemetry': resolvePath('./src/telemetry'), + }, + }, + build: { + target: 'es2022', + sourcemap: true, + // three.js dominates the bundle and every byte of it is needed on the + // first frame, so code-splitting would only add a round trip. + chunkSizeWarningLimit: 900, + }, + test: { globals: true, environment: 'node', include: ['src/**/*.test.ts'] }, +}); diff --git a/package.json b/package.json index 84872db..61ece9d 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,14 @@ "^@components/(.*)$": "/src/components/$1", "^@utils/(.*)$": "/src/utils/$1", "^@types/(.*)$": "/src/types/$1" - } + }, + "testPathIgnorePatterns": [ + "/node_modules/", + "/ascendants/" + ], + "modulePathIgnorePatterns": [ + "/ascendants/" + ] }, "description": "Run Down \u2014 Mobile battle royale game built with React Native and TypeScript", "repository": { @@ -68,4 +75,4 @@ "url": "https://github.com/cloudygetty-ai/run-down.git" }, "license": "MIT" -} \ No newline at end of file +}