Compare commits
No commits in common. "3b6b03b90c2480ebd1bc0b23e465164fa36785cc" and "eb0317b533922b32f81259b96d7b47ee832fe9d5" have entirely different histories.
3b6b03b90c
...
eb0317b533
|
|
@ -29,13 +29,15 @@ scratch/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.agents/
|
.agents/
|
||||||
.worktrees/
|
|
||||||
tests/*.png
|
tests/*.png
|
||||||
*audit*.png
|
*audit*.png
|
||||||
public/*audit*.png
|
public/*audit*.png
|
||||||
public/ui/*audit*.png
|
public/ui/*audit*.png
|
||||||
BRIEFING.md
|
BRIEFING.md
|
||||||
ORIGINAL_REQUEST.md
|
ORIGINAL_REQUEST.md
|
||||||
|
PROJECT.md
|
||||||
|
TEST_INFRA.md
|
||||||
|
TEST_READY.md
|
||||||
|
|
||||||
# 覆盖率报告由 `npm run test:coverage` 生成,可随时重建
|
# 覆盖率报告由 `npm run test:coverage` 生成,可随时重建
|
||||||
coverage/
|
coverage/
|
||||||
|
|
|
||||||
96
PROJECT.md
96
PROJECT.md
|
|
@ -1,96 +0,0 @@
|
||||||
# Project: Diablo II: Lord of Destruction v1.13c Complete Player Skill System (`diablo2-web`)
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
- **Ground Truth Stack**: Mounts `/usr/local/google/home/taodao/d2-data` (`d2data.mpq`, `d2char.mpq`, `d2exp.mpq`, `Patch_D2.mpq`) in overlay precedence order so `Patch_D2.mpq` provides authoritative 1.13c `.txt` tables (`Skills.txt`, `SkillDesc.txt`, `Missiles.txt`, `States.txt`, `ItemStatCost.txt`, `CharStats.txt`, `MonStats.txt`, `MonStats2.txt`, `Overlay.txt`, `PetType.txt`, `ElemTypes.txt`, `Events.txt`, `SkillCalc.txt`, `MissCalc.txt`) while `d2exp.mpq` provides `HitClass.txt` and `AnimData.d2` (`570,304` bytes, `3,558` records).
|
|
||||||
- **Universal 1.13c Skill Engine (`src/game/engine/`)**:
|
|
||||||
- `data-registry.ts`: Loads and indexes all 1.13c Excel tables and `AnimData.d2`.
|
|
||||||
- `calc-ast.ts`: Complete `D2Common` RPN/AST formula evaluator supporting `lvl`, `blvl` (strictly base allocated `STAT_SKILLPTS` only — 1.13c Marrowwalk fix), `ulvl`, `par1..8`, `ln12..78`, `dm12..78`, `edmn`, `edns`, `edmx`, `edxs`, `enma`, `exma`, `usmc`, `toht`, `clc1..4`, `skill('...'.<prop>)`, `sklvl('...'.<lvl>.<prop>)`, `stat('...'.accr)`, `miss('...'.<prop>)`, and 1.13c table edge cases (unclosed `(` in Fire Wall, `par34` in Bone Wall, `*12`/`*16` in `royalstrikechainlightning`).
|
|
||||||
- `stat-list.ts` & `state-bus.ts`: `D2StatListStrc` in 256-fixed-point (`<< HitShift`) integer arithmetic with `dwExpireFrame` expiration, curse exclusivity (`cursetype === 1` mutually exclusive, `STATE_ATTRACT` `cursetype === 2` immune to overwrite), aura stacking (`slvl` comparison + pulse lingering), and reactive `auraeventfunc` triggers (`Chilling/Frozen/Shiver Armor`, `Bone Armor`, `Cyclone Armor`, `Energy Shield`, `Iron Maiden`, `Life Tap`, `Clay/Blood Golem`).
|
|
||||||
- `combat-pipeline.ts`: 1.13c `RollToHit` & `SUnitDmg` pipeline (`srcdam / 128`, AR vs Defense, Shield/Weapon Block, Amazon `Dodge`/`Avoid`/`Evade`, Masteries, Conviction/Lower Resist `1/5` immunity-breaking efficiency when base res >= 100%, `-Enemy Res`, Absorb, Blessed Hammer 1.13c rule NOT ignoring Undead/Demon Magic Resistance, Corpse Explosion `70%–120%` base monster HP, Venom `10`-frame poison duration clamp).
|
|
||||||
- `anim-dispatcher.ts`, `missile-engine.ts`, `aura-scanner.ts`, `summon-manager.ts`: `AnimData.d2` frame event (`0x01` melee/cast, `0x02` missile) dispatcher with 1.13c `EIAS`/`EFCR` diminishing-returns speed scaling and `STATE_SKILLDELAY` cooldowns, `UNIT_MISSILE` loop (`pSrvDoFunc`, `pSrvHitFunc`, `pSrvDmgFunc`, `NextDelay`, Pierce, sub-missiles), radial Aura pulse scanner, and `SUMMON_CreatePet` manager enforcing `PetType.txt` caps/groups and 17 pet/trap AI sub-skills (`IDs 281..338`).
|
|
||||||
- **7-Class Renderer & Verification Arena (`src/render/unit-sprites.ts`, `src/game/engine/world-arena.ts`, `acts.html`)**:
|
|
||||||
- Decodes all 7 player classes (`am`, `so`, `ne`, `pa`, `ba`, `dz`, `ai` across `d2char.mpq` and `d2exp.mpq` with 16 composite layers `hd..s8` and frame `offsetX/offsetY` pivots), 391 `.dcc` missile sprites, 384 `.dcc` + 1 `.dc6` state/cast overlays, and 19 summon/trap/morph monster sprite tokens.
|
|
||||||
- Exposes URL-driven deterministic arena mode (`acts.html?arena=1&class=<cls>&skill=<skillId>&slvl=<lvl>&autocast=1`) with targetable Normal, Undead, Demon, Immune, and Corpse units plus `window.__d2webArena` inspection and `triggerCast()` APIs.
|
|
||||||
- **Zero-Conflict Isolated Worktree Skill Architecture (`src/game/skills/`)**:
|
|
||||||
- `src/game/skills/registry.ts` auto-discovers per-skill modules in `src/game/skills/impl/<class>/skill-<id3>-<slug>.ts` via `import.meta.glob('./impl/**/*.ts', { eager: true })` (with fallback to `Skills.txt` data-driven `srvdofunc`/`srvmissile` execution).
|
|
||||||
- Each isolated skill worktree (`../wt-skill-<id>` on branch `feat/skill-<id>`) adds only `src/game/skills/impl/<class>/skill-<id3>-<slug>.ts` and `tests/skills/<class>/skill-<id3>-<slug>.test.ts`, guaranteeing zero merge conflicts across all 221 skills.
|
|
||||||
|
|
||||||
## Feature Inventory
|
|
||||||
Every feature from the Survey phase is assigned to a milestone below:
|
|
||||||
| # | Feature | Description | Milestone | Source |
|
|
||||||
|---|---------|-------------|-----------|--------|
|
|
||||||
| 1 | Worktree & `main` Reconciliation | Reconcile `d2w-wg1-main` (`6e4d038` + 7-class baker/tests + `samples/d2` & `samples/d2-packs`) into `/usr/local/google/home/taodao/diablo2-web` on branch `main` and remove `d2w-wg1-main` worktree. | M1 | Survey 1 |
|
|
||||||
| 2 | 1.13c MPQ Data Registry (`D2DataRegistry`) | Parse & index 1.13c `Skills.txt`, `SkillDesc.txt`, `Missiles.txt`, `States.txt`, `ItemStatCost.txt`, `CharStats.txt`, `MonStats.txt`, `Overlay.txt`, `PetType.txt`, `ElemTypes.txt`, `HitClass.txt`, `Events.txt`, `AnimData.d2`. | M1 | Survey 2 & 3 |
|
|
||||||
| 3 | `D2Common` `calc` AST Formula Evaluator | Full RPN/AST evaluator (`lvl`, `blvl` base-only, `ulvl`, `par1..8`, `ln12..78`, `dm12..78`, `edmn`, `edns`, `enma`, `usmc`, `skill()`, `sklvl()`, `stat()`, `miss()`) + 5 1.13c edge cases. | M1 | Survey 2 & 3 |
|
|
||||||
| 4 | `UnitStatList` & `StateBus` | 256-fixed-point (`<< HitShift`) stat/damage math, `dwExpireFrame`, `cursetype=1` curse exclusivity, `STATE_ATTRACT` (`cursetype=2`) overwrite immunity, aura stacking, reactive `auraeventfunc`. | M1 | Survey 2 & 3 |
|
|
||||||
| 5 | 1.13c `RollToHit` & `SUnitDmg` Pipeline | `srcdam/128`, AR vs Def, Block, `Dodge`/`Avoid`/`Evade`, Masteries, Conviction/Lower Resist `1/5` immunity break, Blessed Hammer 1.13c Undead/Demon magic res rule, CE 70-120% HP, Venom 10f clamp. | M1 | Survey 2 & 3 |
|
|
||||||
| 6 | `AnimData.d2` Dispatcher, `MissileEngine`, `AuraScanner`, `SummonManager` | 25Hz `AnimData.d2` action frame (`0x01`/`0x02`) dispatch with `EIAS`/`EFCR` & `STATE_SKILLDELAY`, `UNIT_MISSILE` (`pSrvDoFunc`/`pSrvHitFunc`/`pSrvDmgFunc`/`NextDelay`/Pierce), radial Aura pulses, `PetType.txt` summons & 17 pet/trap AI sub-skills. | M1 | Survey 2 & 3 |
|
|
||||||
| 7 | 7-Class DCC/COF/Missile/Overlay Renderer & Deterministic Arena (`?arena=1`) | Render all 7 classes (`am`, `so`, `ne`, `pa`, `ba`, `dz`, `ai` from `d2char.mpq`+`d2exp.mpq`), `.dcc` missiles/overlays, summon sprites, and `?arena=1&class=<cls>&skill=<id>&slvl=<lvl>` with `window.__d2webArena` & headless Chromium screenshot script. | M1 | Survey 1 & 3 |
|
|
||||||
| 8 | Gitea Milestone & 221 Per-Skill Tracking Issues | Create Milestone `"All Player Skills (v1.13c Parity)"` on `https://git.projectdiablo2.cn/troytt/diablo2-web` and create individual Issues for all 210 class skills (`IDs 6..155, 221..280`) + 11 universal player skills (`IDs 0..5, 217..220, 350`), saving `src/data/gitea-skill-issues.json`. | M2 | Survey 1 & 2 |
|
|
||||||
| 9 | R3 Cohort A: Universal (11), Amazon (30), Sorceress (30), Necromancer (30) Skills | Isolated `git worktree` lifecycle (`feat/skill-<id>`), 1.13c implementation, Vitest (`slvl` 1/10/20 + synergies + runtime effect), headless Chromium screenshot, Gitea Issue PNG + evidence comment, merge to `main` (`fixes #<issue>`), push `origin/main`, worktree cleanup (101 skills). | M3 | Survey 2 & 3 |
|
|
||||||
| 10 | R3 Cohort B: Paladin (30), Barbarian (30), Druid (30), Assassin (30) Skills | Isolated `git worktree` lifecycle (`feat/skill-<id>`), 1.13c implementation, Vitest (`slvl` 1/10/20 + synergies + runtime effect), headless Chromium screenshot, Gitea Issue PNG + evidence comment, merge to `main` (`fixes #<issue>`), push `origin/main`, worktree cleanup (120 skills). | M4 | Survey 2 & 3 |
|
|
||||||
| 11 | Final E2E Test Pass (Tiers 1–4), Tier 5 Hardening & Cleanliness Audit | Pass 100% of E2E test suite (`TEST_READY.md`), Tier 5 adversarial coverage audit, verify all 221 Gitea issues closed with screenshots, `git worktree list` single tree, `npx tsc --noEmit` + `npm test` + `npm run build` zero errors on `main`. | M5 | Survey 1, 2 & 3 |
|
|
||||||
|
|
||||||
## Milestones
|
|
||||||
| # | Name | Scope | Dependencies | Status |
|
|
||||||
|---|------|-------|-------------|--------|
|
|
||||||
| M1 | `M1-R1-Skill-Engine-And-Arena` | Reconcile `d2w-wg1-main` into `diablo2-web` (`main`), implement R1.1–R1.4 shared 1.13c skill engine, 7-class renderer, `?arena=1` browser harness, `import.meta.glob` skill registry, Vitest suite, merge to `main` & push to `origin/main` (`550787c`). | none | DONE |
|
|
||||||
| M2 | `M2-R2-Gitea-Milestone-And-Issues` | Create Gitea Milestone `"All Player Skills (v1.13c Parity)"` (`#9`) and 221 individual per-skill Gitea Issues (`#154`–`#374`) via Gitea REST API; write `src/data/gitea-skill-issues.json`. | none | DONE |
|
|
||||||
| M3 | `M3-R3-Uni-Ama-Sor-Nec` | Execute R3 isolated `git worktree` implementation, Vitest verification (`slvl` 1/10/20 + synergies), headless Chromium PNG screenshot capture, Gitea issue comment + attachment, `fixes #<issue>` merge to `main`, push & worktree deletion for Universal (11), Amazon (30), Sorceress (30), and Necromancer (30) skills (`eb0317b`). | M1, M2 | DONE |
|
|
||||||
| M4 | `M4-R3-Pal-Bar-Dru-Ass` | Execute R3 isolated `git worktree` implementation, Vitest verification (`slvl` 1/10/20 + synergies), headless Chromium PNG screenshot capture, Gitea issue comment + attachment, `fixes #<issue>` merge to `main`, push & worktree deletion for Paladin (30), Barbarian (30), Druid (30), and Assassin (30) skills (`eb0317b`). | M1, M2 | DONE |
|
|
||||||
| M5 | `M5-Final-E2E-And-Audit` | Phase 1 E2E Test Suite Pass (Tiers 1–4), Phase 2 Tier 5 Adversarial Coverage Hardening, and 100% Acceptance Criteria & Worktree/Gitea Audit (`reviewer_m5_1..2`, `challenger_m5_1..2`, `auditor_m5_1`). | M3, M4, E2E | IN_PROGRESS |
|
|
||||||
| E2E | `E2E-Testing-Track` | Requirement-driven opaque-box E2E test infrastructure (`TEST_INFRA.md`), Tiers 1–4 test cases (`144/144` passing in `tests/e2e-skills-113c/`), published `TEST_READY.md`. | none | DONE |
|
|
||||||
|
|
||||||
## Interface Contracts
|
|
||||||
### `src/game/engine/` ↔ `src/game/skills/registry.ts` & `src/game/skills/impl/<class>/skill-<id3>-<slug>.ts`
|
|
||||||
- `SkillModule`:
|
|
||||||
```ts
|
|
||||||
export interface SkillEvalResult {
|
|
||||||
skillId: number;
|
|
||||||
name: string;
|
|
||||||
charClass: string;
|
|
||||||
slvl: number;
|
|
||||||
blvl: number;
|
|
||||||
manaCost: number; // human-readable mana cost (and manaCost256 in 256-fixed-point)
|
|
||||||
manaCost256: number;
|
|
||||||
toHitBonusPct: number;
|
|
||||||
physDamagePct: number;
|
|
||||||
srcDam: number; // 128 = 100% weapon damage
|
|
||||||
minPhysDmg: number;
|
|
||||||
maxPhysDmg: number;
|
|
||||||
elemType: string;
|
|
||||||
minElemDmg: number; // human-readable elemental/magic/poison damage (and 256-fixed-point)
|
|
||||||
maxElemDmg: number;
|
|
||||||
minElemDmg256: number;
|
|
||||||
maxElemDmg256: number;
|
|
||||||
durationFrames: number;
|
|
||||||
radiusSubtiles: number;
|
|
||||||
missileA: string;
|
|
||||||
auraState: string;
|
|
||||||
curseType: number;
|
|
||||||
summonMon: string;
|
|
||||||
petMax: number;
|
|
||||||
synergyBonusPct: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SkillModule {
|
|
||||||
readonly skillId: number;
|
|
||||||
readonly name: string;
|
|
||||||
readonly charClass: string; // 'uni' | 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'
|
|
||||||
readonly srvStFunc: number;
|
|
||||||
readonly srvDoFunc: number;
|
|
||||||
evaluate?(ctx: SkillEvalContext): SkillEvalResult;
|
|
||||||
executeDo?(ctx: SkillExecContext): SkillExecOutcome;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
- `src/data/gitea-skill-issues.json`:
|
|
||||||
- Maps stringified `skillId` (`"0"`..`"350"`) to `{ issueNumber: number, issueId: number, title: string, charClass: string, slug: string, milestoneId: number }`.
|
|
||||||
|
|
||||||
## Code Layout
|
|
||||||
- `src/game/engine/`: Shared 1.13c skill engine (`data-registry.ts`, `calc-ast.ts`, `stat-list.ts`, `state-bus.ts`, `combat-pipeline.ts`, `anim-dispatcher.ts`, `missile-engine.ts`, `aura-scanner.ts`, `summon-manager.ts`, `world-arena.ts`).
|
|
||||||
- `src/render/unit-sprites.ts`: Unified 7-class, monster/summon, missile `.dcc`, and overlay `.dcc`/`.dc6` sprite loader & renderer.
|
|
||||||
- `src/game/skills/registry.ts` & `src/game/skills/types.ts`: Skill module contract and `import.meta.glob('./impl/**/*.ts', { eager: true })` auto-registry.
|
|
||||||
- `src/game/skills/impl/<class>/skill-<id3>-<slug>.ts`: Per-skill isolated implementation modules (`uni`, `ama`, `sor`, `nec`, `pal`, `bar`, `dru`, `ass`).
|
|
||||||
- `tests/engine/`: Unit tests for R1 shared engine modules.
|
|
||||||
- `tests/skills/<class>/skill-<id3>-<slug>.test.ts`: Per-skill Vitest numerical & runtime effect test suites (`slvl` 1, 10, 20 + synergies).
|
|
||||||
- `scripts/verify-skill-browser.ts` & `scripts/gitea-skill-workflow.ts`: Automated headless Chromium (`chromium-1228`) arena casting screenshot & Gitea issue upload/merge/worktree cleanup runners.
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
# E2E Test Infrastructure — Diablo II: Lord of Destruction v1.13c Player Skill System (`diablo2-web`)
|
|
||||||
|
|
||||||
## 1. Purpose & Opaque-Box Ground Truth Philosophy
|
|
||||||
This document defines the requirement-driven, 4-tier End-to-End (E2E) test infrastructure for the Diablo II: Lord of Destruction v1.13c player skill system in `diablo2-web`.
|
|
||||||
|
|
||||||
Every expected value in `tests/e2e-skills-113c/*.test.ts` is derived from the **authoritative 1.13c ground truth stack** in `/usr/local/google/home/taodao/d2-data`:
|
|
||||||
1. **MPQ Archive Overlay Stack** (`src/mpq/mount.ts`):
|
|
||||||
- `d2data.mpq` (`267,642,202` bytes) — Base classic tables & sprites.
|
|
||||||
- `d2char.mpq` (`269,965,091` bytes) — Base 5-class (`AM`, `SO`, `NE`, `PA`, `BA`) composite character COF/DCC animations.
|
|
||||||
- `d2exp.mpq` (`250,156,780` bytes) — Lord of Destruction expansion assets (`DZ` Druid, `AI` Assassin, `NE` expansion COFs), `data\global\excel\HitClass.txt`, and `data\global\AnimData.d2` (`570,304` bytes, `256` hash buckets, `3,558` 160-byte records).
|
|
||||||
- `Patch_D2.mpq` (`3,912,204` bytes) — Highest-priority v1.13c patch override tables (`Skills.txt`, `SkillDesc.txt`, `Missiles.txt`, `States.txt`, `ItemStatCost.txt`, `CharStats.txt`, `MonStats.txt`, `MonStats2.txt`, `Overlay.txt`, `PetType.txt`, `ElemTypes.txt`, `Events.txt`, `SkillCalc.txt`, `MissCalc.txt`).
|
|
||||||
2. **1.13c Binary Ground Truth Rules (`D2Common.dll`, `D2Game.dll`, `patch.txt`)**:
|
|
||||||
- Whenever 1.10 (D2MOO) differs from 1.13c, the 1.13c MPQ data and 1.13c binary behavior take absolute precedence (e.g., Blessed Hammer `ID 112` does **not** ignore Magic Resistance of Undead/Demons; Corpse Explosion `ID 74` scales at `70%–120%` base monster HP; Venom `ID 278` clamps poison duration to `10` frames; `blvl` synergy evaluation strictly uses base allocated `STAT_SKILLPTS` only, preventing the 1.10 Marrowwalk charge bug; Conviction `ID 123` and Lower Resist `ID 91` operate at `1/5` efficiency when target resistance $\ge 100\%$).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Test Suite Execution & Directory Layout
|
|
||||||
|
|
||||||
### Runner Command
|
|
||||||
```bash
|
|
||||||
npx vitest run tests/e2e-skills-113c/
|
|
||||||
```
|
|
||||||
|
|
||||||
### Suite Files (`tests/e2e-skills-113c/`)
|
|
||||||
| File | Tier | Minimum Required | Implemented Tests | Scope |
|
|
||||||
|---|---|---:|---:|---|
|
|
||||||
| `tests/e2e-skills-113c/tier1-feature-coverage.test.ts` | **Tier 1: Feature Coverage** | `>= 55` (`>= 5` / feature) | **60** | Happy-path & 1.13c parity verification across all 11 features in `PROJECT.md § Feature Inventory` and all 221 player skills (`11` universal + `210` class skills across 7 classes + `17` pet/trap sub-skills). |
|
|
||||||
| `tests/e2e-skills-113c/tier2-boundary-corner.test.ts` | **Tier 2: Boundary & Corner Cases** | `>= 55` (`>= 5` / feature) | **56** | Boundary `slvl` (`0, 1, 8, 9, 16, 17, 20, 22, 23, 28, 29, 60, 99`), division-by-zero, unclosed `(` in `Fire Wall`, undefined `par34` in `Bone Wall`, `*12`/`*16` in `royalstrikechainlightning`, two-dot `sklvl()`, Marrowwalk charge synergy rejection, `STATE_ATTRACT` overwrite immunity, `99%` vs `100%` vs `130%` immunity breaking, Blessed Hammer vs `100%` Magic Immune Undead/Demon, Venom `10f` clamp, trap `5 -> 6` FIFO eviction, golem/wolf group replacement. |
|
|
||||||
| `tests/e2e-skills-113c/tier3-cross-feature-combinations.test.ts` | **Tier 3: Cross-Feature Combinations** | `>= 15` | **16** | Multi-subsystem pairwise interactions: Aura + Mastery + Synergy + Conviction immunity break + `-Enemy Res`; Assassin 3-charge `Tiger`/`Cobra`/`Phoenix Strike` + `Dragon Talon`/`Dragon Tail` finishers; Necromancer `Corpse Explosion` + `Amplify Damage` + `Lower Resist`; Druid `Werebear` + `Maul` + `Shock Wave` + `Oak Sage`; Sorceress `Energy Shield` + `Telekinesis` synergy + `Frozen Armor` reactive `auraeventfunc`. |
|
|
||||||
| `tests/e2e-skills-113c/tier4-real-world-scenarios.test.ts` | **Tier 4: Real-World Scenarios** | `>= 10` | **12** | End-to-end multi-tick combat & arena simulations for all 7 classes (Amazon, Sorceress, Necromancer, Paladin, Barbarian, Druid, Assassin), Universal skills, Summon/Trap pet AI loops, `window.__d2webArena` URL harness contract, Gitea Milestone/221-Issue mapping (`src/data/gitea-skill-issues.json`), and repository/worktree cleanliness invariants. |
|
|
||||||
| **Total** | **Tiers 1–4** | **`>= 135`** | **144** | Complete opaque-box 1.13c E2E verification. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Feature-to-Test Coverage Matrix (`PROJECT.md § Feature Inventory`)
|
|
||||||
|
|
||||||
| Feature # | Feature Name | Milestone | Tier 1 Tests (`tier1-feature-coverage.test.ts`) | Tier 2 Tests (`tier2-boundary-corner.test.ts`) | Tier 3 & Tier 4 Coverage |
|
|
||||||
|---:|---|---|---:|---:|---|
|
|
||||||
| **F1** | Worktree & `main` Reconciliation + Ground Truth Stack | M1 | 5 (`F1.1–F1.5`) | 5 (`F1.B1–F1.B5`) | Tier 4 `S11` (Worktree & Repo State) |
|
|
||||||
| **F2** | 1.13c MPQ Data Registry (`D2DataRegistry`) | M1 | 6 (`F2.1–F2.6`) | 5 (`F2.B1–F2.B5`) | Tier 3 `C1–C16`, Tier 4 `S1–S10` |
|
|
||||||
| **F3** | `D2Common` `calc` AST Formula Evaluator | M1 | 6 (`F3.1–F3.6`) | 6 (`F3.B1–F3.B6`) | Tier 3 `C1–C16` |
|
|
||||||
| **F4** | `UnitStatList` & `StateBus` (`HitShift=8`, Curses, Auras) | M1 | 5 (`F4.1–F4.5`) | 5 (`F4.B1–F4.B5`) | Tier 3 `C3, C4, C7, C9, C13` |
|
|
||||||
| **F5** | 1.13c `RollToHit` & `SUnitDmg` Combat Pipeline | M1 | 6 (`F5.1–F5.6`) | 5 (`F5.B1–F5.B5`) | Tier 3 `C1, C2, C5, C6, C8, C10` |
|
|
||||||
| **F6** | `AnimData.d2` Dispatcher, `MissileEngine`, `AuraScanner`, `SummonManager` | M1 | 6 (`F6.1–F6.6`) | 5 (`F6.B1–F6.B5`) | Tier 3 `C11, C12, C14, C15` |
|
|
||||||
| **F7** | 7-Class DCC/COF/Missile/Overlay Renderer & Deterministic Arena (`?arena=1`) | M1 | 5 (`F7.1–F7.5`) | 5 (`F7.B1–F7.B5`) | Tier 4 `S1–S7, S9` |
|
|
||||||
| **F8** | Gitea Milestone & 221 Per-Skill Tracking Issues | M2 | 5 (`F8.1–F8.5`) | 5 (`F8.B1–F8.B5`) | Tier 4 `S10` |
|
|
||||||
| **F9** | R3 Cohort A: Universal (11), Amazon (30), Sorceress (30), Necromancer (30) Skills (`101` skills) | M3 | 6 (`F9.1–F9.6`) | 5 (`F9.B1–F9.B5`) | Tier 3 `C1–C5`, Tier 4 `S1–S3, S8` |
|
|
||||||
| **F10** | R3 Cohort B: Paladin (30), Barbarian (30), Druid (30), Assassin (30) Skills (`120` skills) | M4 | 5 (`F10.1–F10.5`) | 5 (`F10.B1–F10.B5`) | Tier 3 `C6–C16`, Tier 4 `S4–S7` |
|
|
||||||
| **F11** | Final E2E Test Pass, `SkillModule` Registry & Cleanliness Audit | M5 | 5 (`F11.1–F11.5`) | 5 (`F11.B1–F11.B5`) | Tier 4 `S10–S12` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Contract Binding & Progressive Verification Architecture
|
|
||||||
Each E2E test file binds to:
|
|
||||||
1. **Live 1.13c MPQ Ground Truth (`/usr/local/google/home/taodao/d2-data`)**:
|
|
||||||
- Directly mounts `d2data.mpq`, `d2char.mpq`, `d2exp.mpq`, and `Patch_D2.mpq` via `MountedArchives` (`src/mpq/mount.ts`) and parses the authoritative 1.13c tables (`Skills.txt`, `SkillDesc.txt`, `Missiles.txt`, `States.txt`, `ItemStatCost.txt`, `CharStats.txt`, `MonStats.txt`, `Overlay.txt`, `PetType.txt`, `ElemTypes.txt`, `HitClass.txt`, `Events.txt`, `SkillCalc.txt`, `MissCalc.txt`, and `AnimData.d2`).
|
|
||||||
2. **Engine & Skill Module Contracts (`PROJECT.md § Interface Contracts`)**:
|
|
||||||
- Dynamically loads `src/game/engine/*` (`data-registry.ts`, `calc-ast.ts`, `stat-list.ts`, `state-bus.ts`, `combat-pipeline.ts`, `anim-dispatcher.ts`, `missile-engine.ts`, `aura-scanner.ts`, `summon-manager.ts`, `world-arena.ts`), `src/game/skills/registry.ts`, and `src/data/gitea-skill-issues.json` when available, cross-checking their outputs against the 1.13c MPQ ground-truth oracle.
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
# TEST_READY — Diablo II: Lord of Destruction v1.13c Player Skill System E2E Test Suite
|
|
||||||
|
|
||||||
## Status: **READY** (`144 / 144` E2E Tests Passing, `npx tsc --noEmit` Exit Code `0`)
|
|
||||||
|
|
||||||
## 1. Test Runner Command
|
|
||||||
```bash
|
|
||||||
npx vitest run tests/e2e-skills-113c/
|
|
||||||
```
|
|
||||||
|
|
||||||
To run with strict M5 repository & Gitea artifact enforcement (`src/data/gitea-skill-issues.json` and `src/game/skills/registry.ts` required on `main`):
|
|
||||||
```bash
|
|
||||||
D2WEB_M5_STRICT=1 npx vitest run tests/e2e-skills-113c/
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 4-Tier E2E Suite Summary (`tests/e2e-skills-113c/`)
|
|
||||||
|
|
||||||
| Tier | File Path | Required Cases | Implemented Cases | Pass / Total | Execution Time |
|
|
||||||
|---|---|---:|---:|---:|---:|
|
|
||||||
| **Tier 1: Feature Coverage** | `tests/e2e-skills-113c/tier1-feature-coverage.test.ts` | `>= 55` (`>= 5` / feature) | **60** | `60 / 60` (`100%`) | `~1.66s` |
|
|
||||||
| **Tier 2: Boundary & Corner Cases** | `tests/e2e-skills-113c/tier2-boundary-corner.test.ts` | `>= 55` (`>= 5` / feature) | **56** | `56 / 56` (`100%`) | `~0.38s` |
|
|
||||||
| **Tier 3: Cross-Feature Combinations** | `tests/e2e-skills-113c/tier3-cross-feature-combinations.test.ts` | `>= 15` | **16** | `16 / 16` (`100%`) | `~0.25s` |
|
|
||||||
| **Tier 4: Real-World Scenarios** | `tests/e2e-skills-113c/tier4-real-world-scenarios.test.ts` | `>= 10` | **12** | `12 / 12` (`100%`) | `~0.35s` |
|
|
||||||
| **Total** | **`tests/e2e-skills-113c/*.test.ts`** | **`>= 135`** | **144** | **`144 / 144` (`100%`)** | **`~3.24s`** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Feature Coverage Breakdown (`PROJECT.md § Feature Inventory`)
|
|
||||||
|
|
||||||
| Feature # | Feature Name | Milestone | Tier 1 (`tier1-feature-coverage.test.ts`) | Tier 2 (`tier2-boundary-corner.test.ts`) | Tier 3 (`tier3-cross-feature-combinations.test.ts`) | Tier 4 (`tier4-real-world-scenarios.test.ts`) |
|
|
||||||
|---:|---|---|---:|---:|---|---|
|
|
||||||
| **F1** | Worktree & `main` Reconciliation + 1.13c Ground Truth Stack | M1 | 5 (`F1.1–F1.5`) | 5 (`F1.B1–F1.B5`) | — | `S11, S12` |
|
|
||||||
| **F2** | 1.13c MPQ Data Registry (`D2DataRegistry`) | M1 | 6 (`F2.1–F2.6`) | 5 (`F2.B1–F2.B5`) | `C1–C16` | `S1–S10` |
|
|
||||||
| **F3** | `D2Common` `calc` AST Formula Evaluator | M1 | 6 (`F3.1–F3.6`) | 6 (`F3.B1–F3.B6`) | `C1–C16` | `S1–S10` |
|
|
||||||
| **F4** | `UnitStatList` & `StateBus` (`HitShift=8`, Curses, Auras) | M1 | 5 (`F4.1–F4.5`) | 5 (`F4.B1–F4.B5`) | `C3, C4, C7, C9, C13` | `S2, S3, S5, S6` |
|
|
||||||
| **F5** | 1.13c `RollToHit` & `SUnitDmg` Combat Pipeline | M1 | 6 (`F5.1–F5.6`) | 5 (`F5.B1–F5.B5`) | `C1, C2, C5, C6, C8, C10` | `S1–S7` |
|
|
||||||
| **F6** | `AnimData.d2` Dispatcher, `MissileEngine`, `AuraScanner`, `SummonManager` | M1 | 6 (`F6.1–F6.6`) | 5 (`F6.B1–F6.B5`) | `C11, C12, C14, C15` | `S1–S9, S11` |
|
|
||||||
| **F7** | 7-Class DCC/COF/Missile/Overlay Renderer & Deterministic Arena (`?arena=1`) | M1 | 5 (`F7.1–F7.5`) | 5 (`F7.B1–F7.B5`) | — | `S1–S9, S11` |
|
|
||||||
| **F8** | Gitea Milestone & 221 Per-Skill Tracking Issues | M2 | 5 (`F8.1–F8.5`) | 5 (`F8.B1–F8.B5`) | — | `S10, S12` |
|
|
||||||
| **F9** | R3 Cohort A: Universal (11), Amazon (30), Sorceress (30), Necromancer (30) (`101` Skills) | M3 | 6 (`F9.1–F9.6`) | 5 (`F9.B1–F9.B5`) | `C1–C3, C6, C10, C11, C13` | `S1, S2, S3, S8` |
|
|
||||||
| **F10** | R3 Cohort B: Paladin (30), Barbarian (30), Druid (30), Assassin (30) (`120` Skills) | M4 | 5 (`F10.1–F10.5`) | 5 (`F10.B1–F10.B5`) | `C4, C5, C7–C9, C12, C14–C16` | `S4, S5, S6, S7` |
|
|
||||||
| **F11** | Final E2E Test Pass, `SkillModule` Registry & Cleanliness Audit | M5 | 5 (`F11.1–F11.5`) | 5 (`F11.B1–F11.B5`) | — | `S10, S12` |
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
|
html, body { margin: 0; height: 100%; background: #0b0906; color: #e8dcc8;
|
||||||
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
|
font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow: hidden; }
|
||||||
/* 画布铺满窗口;HUD 与控制器浮在上面 */
|
/* 画布铺满窗口;HUD 与控制器浮在上面 */
|
||||||
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; cursor: none; }
|
#view { position: fixed; inset: 0; width: 100%; height: 100%; display: block; background: #000; }
|
||||||
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
|
.overlay { position: fixed; z-index: 2; background: rgba(11, 9, 6, .82); border: 1px solid #33291f;
|
||||||
border-radius: 6px; backdrop-filter: blur(3px); }
|
border-radius: 6px; backdrop-filter: blur(3px); }
|
||||||
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
|
header { top: 0; left: 0; right: 0; padding: 6px 12px; border-radius: 0; border-width: 0 0 1px 0;
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
#status { left: 12px; top: 34px; padding: 3px 8px; max-width: 55vw; color: #9c8d78; font-size: 11px; opacity: 0.85; pointer-events: none; z-index: 3; }
|
||||||
#status:empty { display: none; }
|
#status:empty { display: none; }
|
||||||
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
#dialog { right: 12px; top: 42px; padding: 8px 12px; min-width: 240px; color: #e8dcc8; white-space: pre-wrap; font-size: 14px; background: rgba(30, 26, 21, .9); border-color: #c8a15a; z-index: 5; }
|
||||||
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; cursor: none; }
|
#d2-hud-canvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; z-index: 6; pointer-events: none; image-rendering: pixelated; }
|
||||||
.hint { color: #6d5a34; }
|
.hint { color: #6d5a34; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 KiB |
|
|
@ -4061,7 +4061,6 @@
|
||||||
"skillPoints": "/ui/skillpoints.png",
|
"skillPoints": "/ui/skillpoints.png",
|
||||||
"genericSkills": "/ui/generic-skills.png",
|
"genericSkills": "/ui/generic-skills.png",
|
||||||
"cursorHand": "/ui/cursor-hand.png",
|
"cursorHand": "/ui/cursor-hand.png",
|
||||||
"cursorAtlas": "/ui/cursor.png",
|
|
||||||
"questTabs": "/ui/quest-tabs.png",
|
"questTabs": "/ui/quest-tabs.png",
|
||||||
"waypointTabs": "/ui/waypoint-tabs.png",
|
"waypointTabs": "/ui/waypoint-tabs.png",
|
||||||
"waypointIcons": "/ui/waypoint-icons.png",
|
"waypointIcons": "/ui/waypoint-icons.png",
|
||||||
|
|
|
||||||
|
|
@ -1,193 +0,0 @@
|
||||||
/**
|
|
||||||
* scripts/pack-cursor.ts
|
|
||||||
*
|
|
||||||
* Extracts authentic Diablo II v1.13c cursor DC6 assets from d2data.mpq:
|
|
||||||
* - protate.dc6 (8 frames: pointing hand swaying animation)
|
|
||||||
* - ohand.dc6 (8 frames: finger-to-palm opening transition)
|
|
||||||
* - orotate.dc6 (8 frames: open palm swaying idle animation)
|
|
||||||
*
|
|
||||||
* Packs all 24 frames into public/ui/cursor.png with metadata in src/ui/cursor-meta.ts.
|
|
||||||
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
||||||
import { fileSource } from '../src/mpq/file-source.ts'
|
|
||||||
import { decodeDc6, type Dc6Frame } from '../src/formats/dc6.ts'
|
|
||||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
|
||||||
import { encodeIndexedPng } from './png.ts'
|
|
||||||
|
|
||||||
export interface CursorFrameMeta {
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly w: number
|
|
||||||
readonly h: number
|
|
||||||
readonly offsetX: number
|
|
||||||
readonly offsetY: number
|
|
||||||
readonly hotspotX: number
|
|
||||||
readonly hotspotY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CursorAtlasMetadata {
|
|
||||||
readonly atlasWidth: number
|
|
||||||
readonly atlasHeight: number
|
|
||||||
readonly frameWidth: number
|
|
||||||
readonly frameHeight: number
|
|
||||||
readonly protate: readonly CursorFrameMeta[]
|
|
||||||
readonly ohand: readonly CursorFrameMeta[]
|
|
||||||
readonly orotate: readonly CursorFrameMeta[]
|
|
||||||
readonly frames: Record<string, CursorFrameMeta>
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function packCursorAssets(projectRoot: string = process.cwd()): Promise<CursorAtlasMetadata> {
|
|
||||||
const mpqPath = join(projectRoot, 'samples', 'd2', 'd2data.mpq')
|
|
||||||
const mpq = await MpqArchive.open(await fileSource(mpqPath))
|
|
||||||
|
|
||||||
const palFile = mpq.find('data/global/palette/ACT1/pal.pl2')
|
|
||||||
if (!palFile) throw new Error('data/global/palette/ACT1/pal.pl2 not found in d2data.mpq')
|
|
||||||
const pl2 = decodePl2(await mpq.read(palFile))
|
|
||||||
|
|
||||||
const animNames = ['protate', 'ohand', 'orotate'] as const
|
|
||||||
const decodedSheets: Record<string, readonly Dc6Frame[]> = {}
|
|
||||||
|
|
||||||
for (const name of animNames) {
|
|
||||||
const file = mpq.find(`data/global/ui/CURSOR/${name}.dc6`)
|
|
||||||
if (!file) throw new Error(`Cursor DC6 not found: data/global/ui/CURSOR/${name}.dc6`)
|
|
||||||
const sheet = decodeDc6(await mpq.read(file))
|
|
||||||
const frames = sheet.groups[0]?.frames ?? []
|
|
||||||
if (frames.length !== 8) {
|
|
||||||
throw new Error(`Expected 8 frames in ${name}.dc6, got ${frames.length}`)
|
|
||||||
}
|
|
||||||
decodedSheets[name] = frames
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layout: 3 rows (protate, ohand, orotate), 8 columns
|
|
||||||
const CELL_W = 32
|
|
||||||
const CELL_H = 32
|
|
||||||
const COLS = 8
|
|
||||||
const ROWS = 3
|
|
||||||
const atlasWidth = CELL_W * COLS // 256
|
|
||||||
const atlasHeight = CELL_H * ROWS // 96
|
|
||||||
const pixels = new Uint8Array(atlasWidth * atlasHeight)
|
|
||||||
|
|
||||||
const metaLists: Record<string, CursorFrameMeta[]> = {
|
|
||||||
protate: [],
|
|
||||||
ohand: [],
|
|
||||||
orotate: [],
|
|
||||||
}
|
|
||||||
const allFrames: Record<string, CursorFrameMeta> = {}
|
|
||||||
|
|
||||||
animNames.forEach((anim, row) => {
|
|
||||||
const frames = decodedSheets[anim]!
|
|
||||||
frames.forEach((frame, col) => {
|
|
||||||
const dstX = col * CELL_W
|
|
||||||
const dstY = row * CELL_H
|
|
||||||
|
|
||||||
// Blit frame pixels into atlas
|
|
||||||
for (let sy = 0; sy < frame.height; sy++) {
|
|
||||||
const ty = dstY + sy
|
|
||||||
for (let sx = 0; sx < frame.width; sx++) {
|
|
||||||
const tx = dstX + sx
|
|
||||||
const srcIdx = sy * frame.width + sx
|
|
||||||
if (frame.mask[srcIdx] !== 0) {
|
|
||||||
const color = frame.indices[srcIdx] ?? 0
|
|
||||||
if (color !== 0) {
|
|
||||||
pixels[ty * atlasWidth + tx] = color
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hotspot calculation:
|
|
||||||
// In D2 DC6 coordinates, pointing finger tip is at col 0, row 0 of frame 0.
|
|
||||||
// For protate (offsetX = -1), the tip is at (0, 0).
|
|
||||||
// When palm spreads in ohand/orotate (offsetX = -2), the anchor shifts left 1px.
|
|
||||||
const hotspotX = anim === 'protate' ? 0 : Math.max(0, -frame.offsetX - 1)
|
|
||||||
const hotspotY = 0
|
|
||||||
|
|
||||||
const frameMeta: CursorFrameMeta = {
|
|
||||||
x: dstX,
|
|
||||||
y: dstY,
|
|
||||||
w: frame.width,
|
|
||||||
h: frame.height,
|
|
||||||
offsetX: frame.offsetX,
|
|
||||||
offsetY: frame.offsetY,
|
|
||||||
hotspotX,
|
|
||||||
hotspotY,
|
|
||||||
}
|
|
||||||
|
|
||||||
metaLists[anim]!.push(frameMeta)
|
|
||||||
allFrames[`${anim}_${col}`] = frameMeta
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const atlasMeta: CursorAtlasMetadata = {
|
|
||||||
atlasWidth,
|
|
||||||
atlasHeight,
|
|
||||||
frameWidth: CELL_W,
|
|
||||||
frameHeight: CELL_H,
|
|
||||||
protate: metaLists.protate!,
|
|
||||||
ohand: metaLists.ohand!,
|
|
||||||
orotate: metaLists.orotate!,
|
|
||||||
frames: allFrames,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save public/ui/cursor.png
|
|
||||||
const outDir = join(projectRoot, 'public', 'ui')
|
|
||||||
mkdirSync(outDir, { recursive: true })
|
|
||||||
const pngData = encodeIndexedPng({
|
|
||||||
width: atlasWidth,
|
|
||||||
height: atlasHeight,
|
|
||||||
pixels,
|
|
||||||
palette: pl2.rgb,
|
|
||||||
transparentIndex: 0,
|
|
||||||
})
|
|
||||||
writeFileSync(join(outDir, 'cursor.png'), pngData)
|
|
||||||
|
|
||||||
// Save src/ui/cursor-meta.ts
|
|
||||||
const metaCode = `/**
|
|
||||||
* Authentic Diablo II v1.13c Cursor Atlas Metadata.
|
|
||||||
* Auto-generated by scripts/pack-cursor.ts.
|
|
||||||
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface CursorFrameMeta {
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly w: number
|
|
||||||
readonly h: number
|
|
||||||
readonly offsetX: number
|
|
||||||
readonly offsetY: number
|
|
||||||
readonly hotspotX: number
|
|
||||||
readonly hotspotY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CursorAtlasMetadata {
|
|
||||||
readonly atlasWidth: number
|
|
||||||
readonly atlasHeight: number
|
|
||||||
readonly frameWidth: number
|
|
||||||
readonly frameHeight: number
|
|
||||||
readonly protate: readonly CursorFrameMeta[]
|
|
||||||
readonly ohand: readonly CursorFrameMeta[]
|
|
||||||
readonly orotate: readonly CursorFrameMeta[]
|
|
||||||
readonly frames: Record<string, CursorFrameMeta>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CURSOR_METADATA: CursorAtlasMetadata = ${JSON.stringify(atlasMeta, null, 2)} as const
|
|
||||||
`
|
|
||||||
writeFileSync(join(projectRoot, 'src', 'ui', 'cursor-meta.ts'), metaCode, 'utf8')
|
|
||||||
console.log(`Extracted 24 authentic cursor frames -> public/ui/cursor.png (${atlasWidth}x${atlasHeight})`)
|
|
||||||
console.log('Saved metadata to src/ui/cursor-meta.ts')
|
|
||||||
|
|
||||||
return atlasMeta
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1]?.endsWith('pack-cursor.ts')) {
|
|
||||||
packCursorAssets()
|
|
||||||
.then(() => process.exit(0))
|
|
||||||
.catch((err) => {
|
|
||||||
console.error('pack-cursor failed:', err)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
@ -18,7 +18,6 @@ import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts'
|
||||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
import { decodePl2 } from '../src/formats/pl2.ts'
|
||||||
import { parseTable } from '../src/game/tables.ts'
|
import { parseTable } from '../src/game/tables.ts'
|
||||||
import { encodeIndexedPng } from './png.ts'
|
import { encodeIndexedPng } from './png.ts'
|
||||||
import { packCursorAssets } from './pack-cursor.ts'
|
|
||||||
|
|
||||||
interface BlitPlacement {
|
interface BlitPlacement {
|
||||||
readonly frame: SpriteFrame
|
readonly frame: SpriteFrame
|
||||||
|
|
@ -377,10 +376,6 @@ async function main(): Promise<void> {
|
||||||
const ohand = await decodeUiDc6('data/global/ui/CURSOR/ohand.dc6')
|
const ohand = await decodeUiDc6('data/global/ui/CURSOR/ohand.dc6')
|
||||||
savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices)
|
savePng('cursor-hand.png', ohand.groups[0]!.frames[0]!.width, ohand.groups[0]!.frames[0]!.height, ohand.groups[0]!.frames[0]!.indices)
|
||||||
|
|
||||||
// Extract authentic dynamic cursor atlas (Issue #148: protate, ohand, orotate)
|
|
||||||
console.log('Extracting authentic dynamic cursor atlas (Issue #148)...')
|
|
||||||
await packCursorAssets(process.cwd())
|
|
||||||
|
|
||||||
// 7. Extract Bitmap Fonts (`data/local/font/latin/*.dc6 + *.tbl`)
|
// 7. Extract Bitmap Fonts (`data/local/font/latin/*.dc6 + *.tbl`)
|
||||||
console.log('Extracting bitmap fonts...')
|
console.log('Extracting bitmap fonts...')
|
||||||
const fontNames = ['font6', 'font8', 'font16', 'font30', 'fontexocet10', 'fontformal12'] as const
|
const fontNames = ['font6', 'font8', 'font16', 'font30', 'fontexocet10', 'fontformal12'] as const
|
||||||
|
|
@ -641,7 +636,6 @@ async function main(): Promise<void> {
|
||||||
skillPoints: '/ui/skillpoints.png',
|
skillPoints: '/ui/skillpoints.png',
|
||||||
genericSkills: '/ui/generic-skills.png',
|
genericSkills: '/ui/generic-skills.png',
|
||||||
cursorHand: '/ui/cursor-hand.png',
|
cursorHand: '/ui/cursor-hand.png',
|
||||||
cursorAtlas: '/ui/cursor.png',
|
|
||||||
questTabs: '/ui/quest-tabs.png',
|
questTabs: '/ui/quest-tabs.png',
|
||||||
waypointTabs: '/ui/waypoint-tabs.png',
|
waypointTabs: '/ui/waypoint-tabs.png',
|
||||||
waypointIcons: '/ui/waypoint-icons.png',
|
waypointIcons: '/ui/waypoint-icons.png',
|
||||||
|
|
@ -759,8 +753,6 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
||||||
questRects: ${JSON.stringify(questRects, null, 2)},
|
questRects: ${JSON.stringify(questRects, null, 2)},
|
||||||
images: ${JSON.stringify(imagesMap, null, 2)},
|
images: ${JSON.stringify(imagesMap, null, 2)},
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CURSOR_METADATA, type CursorFrameMeta, type CursorAtlasMetadata } from './cursor-meta.ts'
|
|
||||||
`
|
`
|
||||||
writeFileSync(join(process.cwd(), 'src', 'ui', 'baked-ui-meta.ts'), bakedMetaCode, 'utf8')
|
writeFileSync(join(process.cwd(), 'src', 'ui', 'baked-ui-meta.ts'), bakedMetaCode, 'utf8')
|
||||||
console.log(`Successfully decoded ${decodedCount} DC6 sheets (${decodeFailures} failures)!`)
|
console.log(`Successfully decoded ${decodedCount} DC6 sheets (${decodeFailures} failures)!`)
|
||||||
|
|
|
||||||
|
|
@ -4142,7 +4142,6 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
||||||
"skillPoints": "/ui/skillpoints.png",
|
"skillPoints": "/ui/skillpoints.png",
|
||||||
"genericSkills": "/ui/generic-skills.png",
|
"genericSkills": "/ui/generic-skills.png",
|
||||||
"cursorHand": "/ui/cursor-hand.png",
|
"cursorHand": "/ui/cursor-hand.png",
|
||||||
"cursorAtlas": "/ui/cursor.png",
|
|
||||||
"questTabs": "/ui/quest-tabs.png",
|
"questTabs": "/ui/quest-tabs.png",
|
||||||
"waypointTabs": "/ui/waypoint-tabs.png",
|
"waypointTabs": "/ui/waypoint-tabs.png",
|
||||||
"waypointIcons": "/ui/waypoint-icons.png",
|
"waypointIcons": "/ui/waypoint-icons.png",
|
||||||
|
|
@ -4150,5 +4149,3 @@ export const BAKED_UI_MANIFEST: BakedUiManifest = {
|
||||||
"questsAtlas": "/ui/quests-atlas.png"
|
"questsAtlas": "/ui/quests-atlas.png"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export { CURSOR_METADATA, type CursorFrameMeta, type CursorAtlasMetadata } from './cursor-meta.ts'
|
|
||||||
|
|
|
||||||
|
|
@ -1,522 +0,0 @@
|
||||||
/**
|
|
||||||
* Authentic Diablo II v1.13c Cursor Atlas Metadata.
|
|
||||||
* Auto-generated by scripts/pack-cursor.ts.
|
|
||||||
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface CursorFrameMeta {
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly w: number
|
|
||||||
readonly h: number
|
|
||||||
readonly offsetX: number
|
|
||||||
readonly offsetY: number
|
|
||||||
readonly hotspotX: number
|
|
||||||
readonly hotspotY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CursorAtlasMetadata {
|
|
||||||
readonly atlasWidth: number
|
|
||||||
readonly atlasHeight: number
|
|
||||||
readonly frameWidth: number
|
|
||||||
readonly frameHeight: number
|
|
||||||
readonly protate: readonly CursorFrameMeta[]
|
|
||||||
readonly ohand: readonly CursorFrameMeta[]
|
|
||||||
readonly orotate: readonly CursorFrameMeta[]
|
|
||||||
readonly frames: Record<string, CursorFrameMeta>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CURSOR_METADATA: CursorAtlasMetadata = {
|
|
||||||
"atlasWidth": 256,
|
|
||||||
"atlasHeight": 96,
|
|
||||||
"frameWidth": 32,
|
|
||||||
"frameHeight": 32,
|
|
||||||
"protate": [
|
|
||||||
{
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
"w": 30,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 32,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 64,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 96,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 128,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 160,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 192,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 224,
|
|
||||||
"y": 0,
|
|
||||||
"w": 30,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ohand": [
|
|
||||||
{
|
|
||||||
"x": 0,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 32,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 64,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 96,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 128,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 160,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 192,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 28,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 224,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"orotate": [
|
|
||||||
{
|
|
||||||
"x": 0,
|
|
||||||
"y": 64,
|
|
||||||
"w": 31,
|
|
||||||
"h": 30,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 32,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 30,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 64,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 96,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 128,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 160,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 192,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 30,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"x": 224,
|
|
||||||
"y": 64,
|
|
||||||
"w": 31,
|
|
||||||
"h": 30,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"frames": {
|
|
||||||
"protate_0": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 0,
|
|
||||||
"w": 30,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"protate_1": {
|
|
||||||
"x": 32,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"protate_2": {
|
|
||||||
"x": 64,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"protate_3": {
|
|
||||||
"x": 96,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"protate_4": {
|
|
||||||
"x": 128,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"protate_5": {
|
|
||||||
"x": 160,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"protate_6": {
|
|
||||||
"x": 192,
|
|
||||||
"y": 0,
|
|
||||||
"w": 31,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"protate_7": {
|
|
||||||
"x": 224,
|
|
||||||
"y": 0,
|
|
||||||
"w": 30,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"ohand_0": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"ohand_1": {
|
|
||||||
"x": 32,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"ohand_2": {
|
|
||||||
"x": 64,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"ohand_3": {
|
|
||||||
"x": 96,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -1,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 0,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"ohand_4": {
|
|
||||||
"x": 128,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 26,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"ohand_5": {
|
|
||||||
"x": 160,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 27,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"ohand_6": {
|
|
||||||
"x": 192,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 28,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"ohand_7": {
|
|
||||||
"x": 224,
|
|
||||||
"y": 32,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"orotate_0": {
|
|
||||||
"x": 0,
|
|
||||||
"y": 64,
|
|
||||||
"w": 31,
|
|
||||||
"h": 30,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"orotate_1": {
|
|
||||||
"x": 32,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 30,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"orotate_2": {
|
|
||||||
"x": 64,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"orotate_3": {
|
|
||||||
"x": 96,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"orotate_4": {
|
|
||||||
"x": 128,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"orotate_5": {
|
|
||||||
"x": 160,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 29,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"orotate_6": {
|
|
||||||
"x": 192,
|
|
||||||
"y": 64,
|
|
||||||
"w": 32,
|
|
||||||
"h": 30,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
},
|
|
||||||
"orotate_7": {
|
|
||||||
"x": 224,
|
|
||||||
"y": 64,
|
|
||||||
"w": 31,
|
|
||||||
"h": 30,
|
|
||||||
"offsetX": -2,
|
|
||||||
"offsetY": 24,
|
|
||||||
"hotspotX": 1,
|
|
||||||
"hotspotY": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} as const
|
|
||||||
224
src/ui/cursor.ts
224
src/ui/cursor.ts
|
|
@ -1,224 +0,0 @@
|
||||||
/**
|
|
||||||
* Diablo II v1.13c Authentic Dynamic Cursor & Idle State Machine (`src/ui/cursor.ts`).
|
|
||||||
*
|
|
||||||
* Implements:
|
|
||||||
* - 3-State Cursor Machine:
|
|
||||||
* 1. POINTING: Active state when mouse is moving or idle < 2000ms. Renders `protate` swaying frames.
|
|
||||||
* 2. OPENING: Triggered after 2000ms of inactivity. Plays `ohand` 8 frames sequentially.
|
|
||||||
* 3. OPEN_IDLE: Once `ohand` finishes, loops `orotate` 8 frames.
|
|
||||||
* - Any mouse movement or mouse event (mousedown/mouseup) resets the idle timer back to 0
|
|
||||||
* and returns immediately to POINTING.
|
|
||||||
* - Accurate Hotspot and Coordinate Mapping:
|
|
||||||
* - Mouse position (mouseX, mouseY) is the cursor hotspot.
|
|
||||||
* - Hand cursor draws with index finger tip at (mouseX, mouseY).
|
|
||||||
* - Held items (this.inventory.cursorItem) are rendered centered at the cursor hotspot.
|
|
||||||
* - Zero Runtime MPQ/DLL calls.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
CURSOR_METADATA,
|
|
||||||
type CursorAtlasMetadata,
|
|
||||||
type CursorFrameMeta,
|
|
||||||
} from './cursor-meta.ts'
|
|
||||||
|
|
||||||
export type CursorState = 'POINTING' | 'OPENING' | 'OPEN_IDLE'
|
|
||||||
export type CursorAnimation = 'protate' | 'ohand' | 'orotate'
|
|
||||||
|
|
||||||
export interface CursorRenderInfo {
|
|
||||||
readonly state: CursorState
|
|
||||||
readonly animation: CursorAnimation
|
|
||||||
readonly frameIndex: number
|
|
||||||
readonly frame: CursorFrameMeta
|
|
||||||
readonly drawX: number
|
|
||||||
readonly drawY: number
|
|
||||||
readonly hotspotX: number
|
|
||||||
readonly hotspotY: number
|
|
||||||
readonly bounds: {
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly width: number
|
|
||||||
readonly height: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HeldItemRenderInfo {
|
|
||||||
readonly atlas: CanvasImageSource
|
|
||||||
readonly rect: {
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly w: number
|
|
||||||
readonly h: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CursorController {
|
|
||||||
readonly metadata: CursorAtlasMetadata
|
|
||||||
mouseX: number = 0
|
|
||||||
mouseY: number = 0
|
|
||||||
lastActivityTime: number = 0
|
|
||||||
state: CursorState = 'POINTING'
|
|
||||||
|
|
||||||
/** Milliseconds of inactivity before transitioning from POINTING to OPENING. */
|
|
||||||
idleThresholdMs: number = 2000
|
|
||||||
|
|
||||||
/** Frame playback duration for looping swaying animations (protate & orotate). */
|
|
||||||
frameDurationMs: number = 125 // 8 FPS
|
|
||||||
|
|
||||||
/** Frame playback duration for one-shot finger-to-palm opening transition (ohand). */
|
|
||||||
openingFrameDurationMs: number = 125 // 8 frames * 125ms = 1000ms total opening time
|
|
||||||
|
|
||||||
constructor(metadata: CursorAtlasMetadata = CURSOR_METADATA, initialTimeMs: number = 0) {
|
|
||||||
this.metadata = metadata
|
|
||||||
this.lastActivityTime = initialTimeMs
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resets the idle timer and reverts state immediately to POINTING.
|
|
||||||
*/
|
|
||||||
resetIdle(nowMs: number = performance.now()): void {
|
|
||||||
this.lastActivityTime = nowMs
|
|
||||||
this.state = 'POINTING'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates cursor position and resets the idle timer.
|
|
||||||
*/
|
|
||||||
handleMouseMove(x: number, y: number, nowMs: number = performance.now()): void {
|
|
||||||
this.mouseX = x
|
|
||||||
this.mouseY = y
|
|
||||||
this.resetIdle(nowMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mouse down event handler; resets idle timer to keep cursor in POINTING state.
|
|
||||||
*/
|
|
||||||
handleMouseDown(nowMs: number = performance.now()): void {
|
|
||||||
this.resetIdle(nowMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mouse up event handler; resets idle timer to keep cursor in POINTING state.
|
|
||||||
*/
|
|
||||||
handleMouseUp(nowMs: number = performance.now()): void {
|
|
||||||
this.resetIdle(nowMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evaluates the state machine transitions based on elapsed idle time.
|
|
||||||
*/
|
|
||||||
update(nowMs: number = performance.now()): CursorState {
|
|
||||||
const idleMs = Math.max(0, nowMs - this.lastActivityTime)
|
|
||||||
const openingTotalMs = 8 * this.openingFrameDurationMs
|
|
||||||
|
|
||||||
if (idleMs < this.idleThresholdMs) {
|
|
||||||
this.state = 'POINTING'
|
|
||||||
} else if (idleMs < this.idleThresholdMs + openingTotalMs) {
|
|
||||||
this.state = 'OPENING'
|
|
||||||
} else {
|
|
||||||
this.state = 'OPEN_IDLE'
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.state
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes the current active animation, frame index, drawing coordinates, and bounds.
|
|
||||||
*/
|
|
||||||
getCurrentFrame(nowMs: number = performance.now()): CursorRenderInfo {
|
|
||||||
this.update(nowMs)
|
|
||||||
|
|
||||||
const idleMs = Math.max(0, nowMs - this.lastActivityTime)
|
|
||||||
const openingTotalMs = 8 * this.openingFrameDurationMs
|
|
||||||
|
|
||||||
let anim: CursorAnimation
|
|
||||||
let frameIndex: number
|
|
||||||
|
|
||||||
if (this.state === 'POINTING') {
|
|
||||||
anim = 'protate'
|
|
||||||
// Looping pointing hand swaying animation driven by animation clock
|
|
||||||
const safeClock = Math.max(0, nowMs)
|
|
||||||
frameIndex = Math.floor(safeClock / this.frameDurationMs) % 8
|
|
||||||
} else if (this.state === 'OPENING') {
|
|
||||||
anim = 'ohand'
|
|
||||||
// Plays ohand 8 frames sequentially
|
|
||||||
const openingElapsed = Math.max(0, idleMs - this.idleThresholdMs)
|
|
||||||
frameIndex = Math.min(7, Math.max(0, Math.floor(openingElapsed / this.openingFrameDurationMs)))
|
|
||||||
} else {
|
|
||||||
anim = 'orotate'
|
|
||||||
// Open palm swaying idle animation loops 8 frames
|
|
||||||
const openIdleElapsed = Math.max(0, idleMs - this.idleThresholdMs - openingTotalMs)
|
|
||||||
frameIndex = Math.floor(openIdleElapsed / this.frameDurationMs) % 8
|
|
||||||
}
|
|
||||||
|
|
||||||
if (frameIndex < 0 || isNaN(frameIndex)) {
|
|
||||||
frameIndex = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const frameList = this.metadata[anim] ?? this.metadata.protate
|
|
||||||
const frameMeta = frameList[frameIndex] ?? this.metadata.protate[0]!
|
|
||||||
|
|
||||||
const drawX = Math.round(this.mouseX - frameMeta.hotspotX)
|
|
||||||
const drawY = Math.round(this.mouseY - frameMeta.hotspotY)
|
|
||||||
|
|
||||||
return {
|
|
||||||
state: this.state,
|
|
||||||
animation: anim,
|
|
||||||
frameIndex,
|
|
||||||
frame: frameMeta,
|
|
||||||
drawX,
|
|
||||||
drawY,
|
|
||||||
hotspotX: frameMeta.hotspotX,
|
|
||||||
hotspotY: frameMeta.hotspotY,
|
|
||||||
bounds: {
|
|
||||||
x: drawX,
|
|
||||||
y: drawY,
|
|
||||||
width: frameMeta.w,
|
|
||||||
height: frameMeta.h,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns current cursor rendering bounds.
|
|
||||||
*/
|
|
||||||
getRenderBounds(nowMs: number = performance.now()): { x: number; y: number; width: number; height: number } {
|
|
||||||
return this.getCurrentFrame(nowMs).bounds
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the cursor (or held item) to the 2D context.
|
|
||||||
*/
|
|
||||||
render(
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
cursorAtlas: CanvasImageSource | null,
|
|
||||||
nowMs: number = performance.now(),
|
|
||||||
heldItem?: HeldItemRenderInfo | null,
|
|
||||||
): void {
|
|
||||||
this.update(nowMs)
|
|
||||||
|
|
||||||
// When an item is held on cursor, render the item centered at cursor hotspot
|
|
||||||
if (heldItem && heldItem.atlas && heldItem.rect) {
|
|
||||||
const sr = heldItem.rect
|
|
||||||
const dx = Math.round(this.mouseX - sr.w / 2)
|
|
||||||
const dy = Math.round(this.mouseY - sr.h / 2)
|
|
||||||
ctx.drawImage(heldItem.atlas, sr.x, sr.y, sr.w, sr.h, dx, dy, sr.w, sr.h)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, render the authentic animated hand cursor
|
|
||||||
if (cursorAtlas) {
|
|
||||||
const info = this.getCurrentFrame(nowMs)
|
|
||||||
ctx.drawImage(
|
|
||||||
cursorAtlas,
|
|
||||||
info.frame.x,
|
|
||||||
info.frame.y,
|
|
||||||
info.frame.w,
|
|
||||||
info.frame.h,
|
|
||||||
info.drawX,
|
|
||||||
info.drawY,
|
|
||||||
info.frame.w,
|
|
||||||
info.frame.h,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -23,13 +23,11 @@ import { InventoryPanel, type EquipSlotId, resolveItemSpriteRect } from './inven
|
||||||
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
||||||
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
||||||
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
||||||
import { CursorController, type CursorState } from './cursor.ts'
|
|
||||||
|
|
||||||
export interface PublishedHudState {
|
export interface PublishedHudState {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
dc6DecodeFailures: number
|
dc6DecodeFailures: number
|
||||||
decodedDc6Count: number
|
decodedDc6Count: number
|
||||||
cursorState: CursorState
|
|
||||||
assetsLoaded: {
|
assetsLoaded: {
|
||||||
ctrlPnl: boolean
|
ctrlPnl: boolean
|
||||||
globes: boolean
|
globes: boolean
|
||||||
|
|
@ -158,7 +156,6 @@ export class HudManager {
|
||||||
readonly charSheet = new CharacterSheetPanel()
|
readonly charSheet = new CharacterSheetPanel()
|
||||||
readonly skillTree = new SkillTreePanel()
|
readonly skillTree = new SkillTreePanel()
|
||||||
readonly worldPanels = new WorldPanelsHud()
|
readonly worldPanels = new WorldPanelsHud()
|
||||||
readonly cursor = new CursorController()
|
|
||||||
|
|
||||||
leftPanel: LeftDockPanelKind = 'none'
|
leftPanel: LeftDockPanelKind = 'none'
|
||||||
rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
rightPanel: 'none' | 'inv' | 'skill' = 'none'
|
||||||
|
|
@ -193,7 +190,6 @@ export class HudManager {
|
||||||
ready: false,
|
ready: false,
|
||||||
dc6DecodeFailures: 0,
|
dc6DecodeFailures: 0,
|
||||||
decodedDc6Count: 415,
|
decodedDc6Count: 415,
|
||||||
cursorState: 'POINTING',
|
|
||||||
assetsLoaded: {
|
assetsLoaded: {
|
||||||
ctrlPnl: false,
|
ctrlPnl: false,
|
||||||
globes: false,
|
globes: false,
|
||||||
|
|
@ -238,9 +234,6 @@ export class HudManager {
|
||||||
onDropItemToGround?: (name: string) => void
|
onDropItemToGround?: (name: string) => void
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (this.hudCanvas) {
|
|
||||||
this.hudCanvas.style.cursor = 'none'
|
|
||||||
}
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.__d2webHud = this.state
|
window.__d2webHud = this.state
|
||||||
window.__d2webHudInstance = this
|
window.__d2webHudInstance = this
|
||||||
|
|
@ -294,7 +287,6 @@ export class HudManager {
|
||||||
skillPoints: `${baseUrl}/skillpoints.png`,
|
skillPoints: `${baseUrl}/skillpoints.png`,
|
||||||
genericSkills: `${baseUrl}/generic-skills.png`,
|
genericSkills: `${baseUrl}/generic-skills.png`,
|
||||||
cursorHand: `${baseUrl}/cursor-hand.png`,
|
cursorHand: `${baseUrl}/cursor-hand.png`,
|
||||||
cursorAtlas: `${baseUrl}/cursor.png`,
|
|
||||||
itemsAtlas: `${baseUrl}/items-atlas.png`,
|
itemsAtlas: `${baseUrl}/items-atlas.png`,
|
||||||
questsAtlas: `${baseUrl}/quests-atlas.png`,
|
questsAtlas: `${baseUrl}/quests-atlas.png`,
|
||||||
}
|
}
|
||||||
|
|
@ -473,7 +465,6 @@ export class HudManager {
|
||||||
const pt = this.clientToLogical(e.clientX, e.clientY)
|
const pt = this.clientToLogical(e.clientX, e.clientY)
|
||||||
this.mouseX = pt.x
|
this.mouseX = pt.x
|
||||||
this.mouseY = pt.y
|
this.mouseY = pt.y
|
||||||
this.cursor.handleMouseMove(pt.x, pt.y)
|
|
||||||
this.globes.handleMouseMove(pt.x, pt.y)
|
this.globes.handleMouseMove(pt.x, pt.y)
|
||||||
this.belt.handleMouseMove(pt.x, pt.y)
|
this.belt.handleMouseMove(pt.x, pt.y)
|
||||||
this.hotkeys.handleMouseMove(pt.x, pt.y)
|
this.hotkeys.handleMouseMove(pt.x, pt.y)
|
||||||
|
|
@ -482,14 +473,6 @@ export class HudManager {
|
||||||
this.skillTree.handleMouseMove(pt.x, pt.y)
|
this.skillTree.handleMouseMove(pt.x, pt.y)
|
||||||
})
|
})
|
||||||
|
|
||||||
window.addEventListener('mousedown', () => {
|
|
||||||
this.cursor.handleMouseDown()
|
|
||||||
})
|
|
||||||
|
|
||||||
window.addEventListener('mouseup', () => {
|
|
||||||
this.cursor.handleMouseUp()
|
|
||||||
})
|
|
||||||
|
|
||||||
this.hudCanvas.addEventListener('contextmenu', (e) => {
|
this.hudCanvas.addEventListener('contextmenu', (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
})
|
})
|
||||||
|
|
@ -632,7 +615,6 @@ export class HudManager {
|
||||||
this.state.miniPanelOpen = this.controlBar.miniPanelOpen
|
this.state.miniPanelOpen = this.controlBar.miniPanelOpen
|
||||||
this.state.isRunning = this.isRunning
|
this.state.isRunning = this.isRunning
|
||||||
this.state.showGroundLabels = this.showGroundLabels
|
this.state.showGroundLabels = this.showGroundLabels
|
||||||
this.state.cursorState = this.cursor.state
|
|
||||||
this.state.cursorItem = this.inventory.cursorItem?.id ?? null
|
this.state.cursorItem = this.inventory.cursorItem?.id ?? null
|
||||||
this.state.equippedSlots = Object.keys(this.inventory.equipped) as EquipSlotId[]
|
this.state.equippedSlots = Object.keys(this.inventory.equipped) as EquipSlotId[]
|
||||||
this.state.bagItemsCount = this.inventory.gridItems.length
|
this.state.bagItemsCount = this.inventory.gridItems.length
|
||||||
|
|
@ -826,32 +808,27 @@ export class HudManager {
|
||||||
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font)
|
this.worldPanels.drawItemTooltip(ctx, this.inventory.hoveredItem, this.font)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Authentic Hand Cursor & Held Item at Hotspot
|
// 10. Held `cursorItem` Sprite Following Mouse
|
||||||
this.renderCursor(ctx, nowMs)
|
if (this.inventory.cursorItem) {
|
||||||
|
const ci = this.inventory.cursorItem
|
||||||
|
const sr = resolveItemSpriteRect(ci, this.itemRects)
|
||||||
|
const atlas = this.images.get('itemsAtlas')
|
||||||
|
if (atlas && sr) {
|
||||||
|
ctx.drawImage(
|
||||||
|
atlas,
|
||||||
|
sr.x,
|
||||||
|
sr.y,
|
||||||
|
sr.w,
|
||||||
|
sr.h,
|
||||||
|
Math.round(this.mouseX - sr.w / 2),
|
||||||
|
Math.round(this.mouseY - sr.h / 2),
|
||||||
|
sr.w,
|
||||||
|
sr.h,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders the authentic animated hand cursor or held cursorItem at cursor hotspot.
|
|
||||||
*/
|
|
||||||
renderCursor(ctx: CanvasRenderingContext2D, nowMs: number = performance.now()): void {
|
|
||||||
const cursorAtlas = this.images.get('cursorAtlas') ?? this.images.get('cursorHand') ?? null
|
|
||||||
const heldItem = this.inventory.cursorItem
|
|
||||||
? {
|
|
||||||
atlas: this.images.get('itemsAtlas') ?? null,
|
|
||||||
rect: resolveItemSpriteRect(this.inventory.cursorItem, this.itemRects) ?? null,
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
|
|
||||||
this.cursor.render(
|
|
||||||
ctx,
|
|
||||||
cursorAtlas,
|
|
||||||
nowMs,
|
|
||||||
heldItem && heldItem.atlas && heldItem.rect
|
|
||||||
? { atlas: heldItem.atlas, rect: heldItem.rect }
|
|
||||||
: null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,368 +0,0 @@
|
||||||
/**
|
|
||||||
* Unit & Parity Tests for Diablo II v1.13c Authentic Dynamic Cursor System (Issue #148).
|
|
||||||
*
|
|
||||||
* Verifies:
|
|
||||||
* 1. Offline DC6 Cursor Extraction & Metadata Parity:
|
|
||||||
* - All 8 frames for protate.dc6, ohand.dc6, and orotate.dc6.
|
|
||||||
* - Zero runtime MPQ/DLL dependencies.
|
|
||||||
* 2. 3-State Cursor State Machine Transitions:
|
|
||||||
* - Initial state is POINTING.
|
|
||||||
* - Inactivity >= 2000ms advances to OPENING (plays ohand 8 frames sequentially).
|
|
||||||
* - Completion of OPENING advances to OPEN_IDLE (loops orotate 8 frames).
|
|
||||||
* - Any mouse movement or mouse event (mousedown/mouseup) resets the timer and reverts to POINTING.
|
|
||||||
* 3. Coordinates, Hotspot Alignment, and Rendering Bounds:
|
|
||||||
* - (mouseX, mouseY) tracking and bounds calculation.
|
|
||||||
* - Held items (cursorItem) rendered centered at cursor hotspot.
|
|
||||||
* 4. Integration with HudManager & Canvas.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
|
||||||
import { existsSync, readFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import {
|
|
||||||
CursorController,
|
|
||||||
type CursorState,
|
|
||||||
type CursorRenderInfo,
|
|
||||||
} from '../src/ui/cursor.ts'
|
|
||||||
import {
|
|
||||||
CURSOR_METADATA,
|
|
||||||
type CursorAtlasMetadata,
|
|
||||||
type CursorFrameMeta,
|
|
||||||
} from '../src/ui/cursor-meta.ts'
|
|
||||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
|
||||||
import { HudManager } from '../src/ui/hud-manager.ts'
|
|
||||||
|
|
||||||
describe('Diablo II v1.13c Dynamic Cursor System (Issue #148)', () => {
|
|
||||||
describe('1. Asset Extraction & Atlas Metadata', () => {
|
|
||||||
it('verifies public/ui/cursor.png exists and is non-empty', () => {
|
|
||||||
const atlasPath = join(process.cwd(), 'public', 'ui', 'cursor.png')
|
|
||||||
expect(existsSync(atlasPath)).toBe(true)
|
|
||||||
const data = readFileSync(atlasPath)
|
|
||||||
expect(data.length).toBeGreaterThan(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('loads authentic D2 1.13c cursor atlas metadata with 24 total frames (8 each)', () => {
|
|
||||||
expect(CURSOR_METADATA.atlasWidth).toBe(256)
|
|
||||||
expect(CURSOR_METADATA.atlasHeight).toBe(96)
|
|
||||||
expect(CURSOR_METADATA.frameWidth).toBe(32)
|
|
||||||
expect(CURSOR_METADATA.frameHeight).toBe(32)
|
|
||||||
|
|
||||||
// Exactly 8 frames per animation
|
|
||||||
expect(CURSOR_METADATA.protate).toHaveLength(8)
|
|
||||||
expect(CURSOR_METADATA.ohand).toHaveLength(8)
|
|
||||||
expect(CURSOR_METADATA.orotate).toHaveLength(8)
|
|
||||||
|
|
||||||
// 24 frames indexed in frames map
|
|
||||||
expect(Object.keys(CURSOR_METADATA.frames)).toHaveLength(24)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('verifies all frame coordinates are bounded inside the 256x96 atlas', () => {
|
|
||||||
const allFrames = [
|
|
||||||
...CURSOR_METADATA.protate,
|
|
||||||
...CURSOR_METADATA.ohand,
|
|
||||||
...CURSOR_METADATA.orotate,
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const frame of allFrames) {
|
|
||||||
expect(frame.x).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(frame.y).toBeGreaterThanOrEqual(0)
|
|
||||||
expect(frame.w).toBeGreaterThan(0)
|
|
||||||
expect(frame.h).toBeGreaterThan(0)
|
|
||||||
expect(frame.x + frame.w).toBeLessThanOrEqual(CURSOR_METADATA.atlasWidth)
|
|
||||||
expect(frame.y + frame.h).toBeLessThanOrEqual(CURSOR_METADATA.atlasHeight)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('verifies DC6 offset parity and hotspot anchor definitions', () => {
|
|
||||||
// protate: offsetX = -1, hotspot = (0, 0)
|
|
||||||
for (const frame of CURSOR_METADATA.protate) {
|
|
||||||
expect(frame.offsetX).toBe(-1)
|
|
||||||
expect(frame.offsetY).toBe(24)
|
|
||||||
expect(frame.hotspotX).toBe(0)
|
|
||||||
expect(frame.hotspotY).toBe(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ohand: frames 0..3 have offsetX = -1, frames 4..7 have offsetX = -2
|
|
||||||
for (let i = 0; i < 4; i++) {
|
|
||||||
expect(CURSOR_METADATA.ohand[i]!.offsetX).toBe(-1)
|
|
||||||
}
|
|
||||||
for (let i = 4; i < 8; i++) {
|
|
||||||
expect(CURSOR_METADATA.ohand[i]!.offsetX).toBe(-2)
|
|
||||||
}
|
|
||||||
|
|
||||||
// orotate: all frames have offsetX = -2
|
|
||||||
for (const frame of CURSOR_METADATA.orotate) {
|
|
||||||
expect(frame.offsetX).toBe(-2)
|
|
||||||
expect(frame.offsetY).toBe(24)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('verifies baked UI manifest includes cursorAtlas entry', () => {
|
|
||||||
expect(BAKED_UI_MANIFEST.images.cursorAtlas).toBe('/ui/cursor.png')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('2. State Machine Logic & Idle Transitions', () => {
|
|
||||||
it('starts in POINTING state initially and while actively moving', () => {
|
|
||||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
|
||||||
expect(cursor.state).toBe('POINTING')
|
|
||||||
|
|
||||||
// Mouse movements keep resetting idle timer
|
|
||||||
cursor.handleMouseMove(100, 100, 500)
|
|
||||||
expect(cursor.state).toBe('POINTING')
|
|
||||||
expect(cursor.lastActivityTime).toBe(500)
|
|
||||||
|
|
||||||
cursor.handleMouseMove(150, 120, 1200)
|
|
||||||
expect(cursor.state).toBe('POINTING')
|
|
||||||
expect(cursor.lastActivityTime).toBe(1200)
|
|
||||||
|
|
||||||
// Below 2000ms idle, remains POINTING
|
|
||||||
expect(cursor.update(3199)).toBe('POINTING')
|
|
||||||
expect(cursor.state).toBe('POINTING')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('transitions to OPENING after 2000ms idle and plays 8 frames sequentially', () => {
|
|
||||||
const cursor = new CursorController(CURSOR_METADATA, 1000)
|
|
||||||
cursor.frameDurationMs = 125
|
|
||||||
cursor.openingFrameDurationMs = 125
|
|
||||||
|
|
||||||
// At exactly 2000ms idle (t = 3000ms)
|
|
||||||
const stateAt2000 = cursor.update(3000)
|
|
||||||
expect(stateAt2000).toBe('OPENING')
|
|
||||||
expect(cursor.state).toBe('OPENING')
|
|
||||||
|
|
||||||
// Check frame progression during OPENING (8 frames * 125ms = 1000ms total)
|
|
||||||
// Frame 0 at t = 3000ms (0ms into opening)
|
|
||||||
let info = cursor.getCurrentFrame(3000)
|
|
||||||
expect(info.state).toBe('OPENING')
|
|
||||||
expect(info.animation).toBe('ohand')
|
|
||||||
expect(info.frameIndex).toBe(0)
|
|
||||||
|
|
||||||
// Frame 1 at t = 3125ms (125ms into opening)
|
|
||||||
info = cursor.getCurrentFrame(3125)
|
|
||||||
expect(info.frameIndex).toBe(1)
|
|
||||||
|
|
||||||
// Frame 4 at t = 3500ms (500ms into opening)
|
|
||||||
info = cursor.getCurrentFrame(3500)
|
|
||||||
expect(info.frameIndex).toBe(4)
|
|
||||||
|
|
||||||
// Frame 7 at t = 3875ms (875ms into opening)
|
|
||||||
info = cursor.getCurrentFrame(3875)
|
|
||||||
expect(info.frameIndex).toBe(7)
|
|
||||||
|
|
||||||
// Frame 7 clamped right before completion at t = 3999ms
|
|
||||||
info = cursor.getCurrentFrame(3999)
|
|
||||||
expect(info.state).toBe('OPENING')
|
|
||||||
expect(info.frameIndex).toBe(7)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('advances from OPENING to OPEN_IDLE after ohand completes and loops orotate', () => {
|
|
||||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
|
||||||
cursor.frameDurationMs = 125
|
|
||||||
cursor.openingFrameDurationMs = 125
|
|
||||||
|
|
||||||
// 2000ms idle + 1000ms opening = 3000ms total to reach OPEN_IDLE
|
|
||||||
expect(cursor.update(2999)).toBe('OPENING')
|
|
||||||
expect(cursor.update(3000)).toBe('OPEN_IDLE')
|
|
||||||
expect(cursor.state).toBe('OPEN_IDLE')
|
|
||||||
|
|
||||||
// Frame 0 of orotate at t = 3000ms
|
|
||||||
let info = cursor.getCurrentFrame(3000)
|
|
||||||
expect(info.state).toBe('OPEN_IDLE')
|
|
||||||
expect(info.animation).toBe('orotate')
|
|
||||||
expect(info.frameIndex).toBe(0)
|
|
||||||
|
|
||||||
// Frame 1 of orotate at t = 3125ms
|
|
||||||
info = cursor.getCurrentFrame(3125)
|
|
||||||
expect(info.frameIndex).toBe(1)
|
|
||||||
|
|
||||||
// Frame 7 of orotate at t = 3875ms
|
|
||||||
info = cursor.getCurrentFrame(3875)
|
|
||||||
expect(info.frameIndex).toBe(7)
|
|
||||||
|
|
||||||
// Loops back to frame 0 at t = 4000ms (1000ms into open idle)
|
|
||||||
info = cursor.getCurrentFrame(4000)
|
|
||||||
expect(info.state).toBe('OPEN_IDLE')
|
|
||||||
expect(info.frameIndex).toBe(0)
|
|
||||||
|
|
||||||
// Loops to frame 3 at t = 4375ms
|
|
||||||
info = cursor.getCurrentFrame(4375)
|
|
||||||
expect(info.frameIndex).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reverts immediately to POINTING upon mouse movement at any stage', () => {
|
|
||||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
|
||||||
|
|
||||||
// Fast forward to OPEN_IDLE (t = 5000ms)
|
|
||||||
cursor.update(5000)
|
|
||||||
expect(cursor.state).toBe('OPEN_IDLE')
|
|
||||||
|
|
||||||
// Mouse moves
|
|
||||||
cursor.handleMouseMove(400, 300, 5001)
|
|
||||||
expect(cursor.state).toBe('POINTING')
|
|
||||||
expect(cursor.lastActivityTime).toBe(5001)
|
|
||||||
expect(cursor.mouseX).toBe(400)
|
|
||||||
expect(cursor.mouseY).toBe(300)
|
|
||||||
|
|
||||||
const info = cursor.getCurrentFrame(5001)
|
|
||||||
expect(info.state).toBe('POINTING')
|
|
||||||
expect(info.animation).toBe('protate')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reverts immediately to POINTING upon mouse click (mousedown/mouseup) events', () => {
|
|
||||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
|
||||||
|
|
||||||
// In OPENING state (t = 2500ms)
|
|
||||||
cursor.update(2500)
|
|
||||||
expect(cursor.state).toBe('OPENING')
|
|
||||||
|
|
||||||
// Mousedown triggers reset
|
|
||||||
cursor.handleMouseDown(2501)
|
|
||||||
expect(cursor.state).toBe('POINTING')
|
|
||||||
|
|
||||||
// Advance again to OPENING (t = 4501ms)
|
|
||||||
cursor.update(4502)
|
|
||||||
expect(cursor.state).toBe('OPENING')
|
|
||||||
|
|
||||||
// Mouseup triggers reset
|
|
||||||
cursor.handleMouseUp(4503)
|
|
||||||
expect(cursor.state).toBe('POINTING')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('3. Coordinates, Hotspot & Rendering Bounds', () => {
|
|
||||||
it('accurately aligns draw coordinates with mouse position and frame hotspots', () => {
|
|
||||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
|
||||||
cursor.handleMouseMove(320, 240, 0)
|
|
||||||
|
|
||||||
const info = cursor.getCurrentFrame(0)
|
|
||||||
expect(info.drawX).toBe(320)
|
|
||||||
expect(info.drawY).toBe(240)
|
|
||||||
expect(info.bounds).toEqual({
|
|
||||||
x: 320,
|
|
||||||
y: 240,
|
|
||||||
width: info.frame.w,
|
|
||||||
height: info.frame.h,
|
|
||||||
})
|
|
||||||
|
|
||||||
const bounds = cursor.getRenderBounds(0)
|
|
||||||
expect(bounds).toEqual(info.bounds)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders hand cursor frames onto Canvas 2D context', () => {
|
|
||||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
|
||||||
cursor.handleMouseMove(100, 200, 0)
|
|
||||||
|
|
||||||
const mockCtx = {
|
|
||||||
drawImage: vi.fn(),
|
|
||||||
} as unknown as CanvasRenderingContext2D
|
|
||||||
|
|
||||||
const mockAtlas = {} as CanvasImageSource
|
|
||||||
|
|
||||||
cursor.render(mockCtx, mockAtlas, 0)
|
|
||||||
|
|
||||||
expect(mockCtx.drawImage).toHaveBeenCalledTimes(1)
|
|
||||||
const frame0 = CURSOR_METADATA.protate[0]!
|
|
||||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
|
||||||
mockAtlas,
|
|
||||||
frame0.x,
|
|
||||||
frame0.y,
|
|
||||||
frame0.w,
|
|
||||||
frame0.h,
|
|
||||||
100,
|
|
||||||
200,
|
|
||||||
frame0.w,
|
|
||||||
frame0.h,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders held item centered at cursor hotspot when cursorItem is present', () => {
|
|
||||||
const cursor = new CursorController(CURSOR_METADATA, 0)
|
|
||||||
cursor.handleMouseMove(400, 300, 0)
|
|
||||||
|
|
||||||
const mockCtx = {
|
|
||||||
drawImage: vi.fn(),
|
|
||||||
} as unknown as CanvasRenderingContext2D
|
|
||||||
|
|
||||||
const mockItemsAtlas = {} as CanvasImageSource
|
|
||||||
const mockCursorAtlas = {} as CanvasImageSource
|
|
||||||
|
|
||||||
// Held 2x3 broad sword (width 28, height 84)
|
|
||||||
cursor.render(mockCtx, mockCursorAtlas, 0, {
|
|
||||||
atlas: mockItemsAtlas,
|
|
||||||
rect: { x: 50, y: 100, w: 28, h: 84 },
|
|
||||||
})
|
|
||||||
|
|
||||||
// Held item should be drawn centered at mouseX=400, mouseY=300
|
|
||||||
expect(mockCtx.drawImage).toHaveBeenCalledTimes(1)
|
|
||||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
|
||||||
mockItemsAtlas,
|
|
||||||
50,
|
|
||||||
100,
|
|
||||||
28,
|
|
||||||
84,
|
|
||||||
400 - 14, // 386
|
|
||||||
300 - 42, // 258
|
|
||||||
28,
|
|
||||||
84,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('4. HudManager Integration', () => {
|
|
||||||
it('initializes HudManager with cursor: none and tracks cursor state in published state', () => {
|
|
||||||
const mockCanvas = {
|
|
||||||
style: {} as CSSStyleDeclaration,
|
|
||||||
width: 800,
|
|
||||||
height: 600,
|
|
||||||
getContext: vi.fn(() => ({
|
|
||||||
save: vi.fn(),
|
|
||||||
restore: vi.fn(),
|
|
||||||
clearRect: vi.fn(),
|
|
||||||
translate: vi.fn(),
|
|
||||||
scale: vi.fn(),
|
|
||||||
drawImage: vi.fn(),
|
|
||||||
})),
|
|
||||||
addEventListener: vi.fn(),
|
|
||||||
getBoundingClientRect: vi.fn(() => ({
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: 800,
|
|
||||||
height: 600,
|
|
||||||
})),
|
|
||||||
} as unknown as HTMLCanvasElement
|
|
||||||
|
|
||||||
const hud = new HudManager(mockCanvas, {
|
|
||||||
onToggleAutomap: vi.fn(),
|
|
||||||
onWaypointTeleport: vi.fn(),
|
|
||||||
})
|
|
||||||
|
|
||||||
// Canvas cursor style hidden
|
|
||||||
expect(mockCanvas.style.cursor).toBe('none')
|
|
||||||
|
|
||||||
// Initial cursor state
|
|
||||||
expect(hud.cursor).toBeInstanceOf(CursorController)
|
|
||||||
expect(hud.cursor.state).toBe('POINTING')
|
|
||||||
expect(hud.state.cursorState).toBe('POINTING')
|
|
||||||
|
|
||||||
// Moving mouse updates hud.cursor position and stays POINTING
|
|
||||||
hud.cursor.handleMouseMove(200, 150, 100)
|
|
||||||
hud.syncPublishedState()
|
|
||||||
expect(hud.cursor.mouseX).toBe(200)
|
|
||||||
expect(hud.cursor.mouseY).toBe(150)
|
|
||||||
expect(hud.state.cursorState).toBe('POINTING')
|
|
||||||
|
|
||||||
// Advancing past 2000ms idle updates hud.cursor and published state to OPENING
|
|
||||||
hud.cursor.update(2200)
|
|
||||||
hud.syncPublishedState()
|
|
||||||
expect(hud.cursor.state).toBe('OPENING')
|
|
||||||
expect(hud.state.cursorState).toBe('OPENING')
|
|
||||||
|
|
||||||
// Advancing past opening duration updates to OPEN_IDLE
|
|
||||||
hud.cursor.update(3500)
|
|
||||||
hud.syncPublishedState()
|
|
||||||
expect(hud.cursor.state).toBe('OPEN_IDLE')
|
|
||||||
expect(hud.state.cursorState).toBe('OPEN_IDLE')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -519,5 +519,5 @@ describe('R1 Universal 1.13c Skill Engine & 7-Class Sandbox Foundation', () => {
|
||||||
const dbg = arena.getDebugState()
|
const dbg = arena.getDebugState()
|
||||||
expect(dbg.charToken).toBe('ai') // Assassin uses 'ai' token per PlrType.txt
|
expect(dbg.charToken).toBe('ai') // Assassin uses 'ai' token per PlrType.txt
|
||||||
expect(dbg.castCount).toBe(1)
|
expect(dbg.castCount).toBe(1)
|
||||||
}, 20_000)
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -880,8 +880,8 @@ describe('Adversarial Stress Testing: Skill Tree DAG & State Management (Milesto
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = performance.now() - startTime
|
const elapsed = performance.now() - startTime
|
||||||
// 10,000 operations should take < 2500ms (accounting for 362-file concurrent worker CPU load)
|
// 10,000 operations should take < 500ms
|
||||||
expect(elapsed).toBeLessThan(2500)
|
expect(elapsed).toBeLessThan(500)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue