Compare commits
17 Commits
efc14b9925
...
8f8ee010ca
| Author | SHA1 | Date |
|---|---|---|
|
|
8f8ee010ca | |
|
|
4f6eadea6c | |
|
|
775f4113c9 | |
|
|
090de33935 | |
|
|
7f810e40e3 | |
|
|
fb43185990 | |
|
|
67cf0e576b | |
|
|
3b631b4c0c | |
|
|
6f3cf6b8bb | |
|
|
0c4af3770d | |
|
|
43748eac53 | |
|
|
609149fa5f | |
|
|
82e32a813e | |
|
|
0df0dfede2 | |
|
|
9fac33a1e2 | |
|
|
1f1996f65b | |
|
|
bc6b5cd331 |
165
README.md
|
|
@ -1,7 +1,7 @@
|
|||
# d2web
|
||||
|
||||
从零实现的**暗黑破坏神 II 引擎**(纯 TypeScript + Vite + WebGL2,**零运行时依赖**),在浏览器里读取
|
||||
用户自备的经典版 D2 归档(MPQ),渲染等距地图、驱动角色走动,并支持把地图离线烘焙成 web 原生格式。
|
||||
用户自备的经典版 D2 归档(MPQ),渲染等距地图、驱动角色走动、释放 221 个原版技能,并支持把地图与模型离线烘焙成 web 原生格式。
|
||||
|
||||
- **仓库不含任何暴雪资源**:没有 `.mpq` / `.dc6` / `.dt1` / 图集 PNG;原版数据要自己放进 `samples/d2/`。
|
||||
- **代码可复现一切**:格式解码、地图构建、资源包烘焙、角色合成都在这份代码里,脚本可重跑。
|
||||
|
|
@ -12,17 +12,16 @@
|
|||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| **等距地图渲染** | DS1 + DT1 投影(2:1 dimetric,格 80×40 px),地板 / 墙 / **屋顶** 三层分别绘制;逐格按 `RarityFrameIndex` 加权随机选瓦片变体;画家序排序 |
|
||||
| **碰撞** | 每格 5×5 个 sub-tile 标志位(一层内所有层做 OR 合并,含 `type 13` 影子层),角色按 sub-tile 判定走位 |
|
||||
| **碰撞系统** | 每格 5×5 个 sub-tile 标志位(一层内所有层做 OR 合并,含 `type 13` 影子层),角色按 sub-tile 判定走位,支持沿梯度脱困 |
|
||||
| **真实关卡** | 已烘焙 **365 张地图**,覆盖 5 个 act:35 个预置关卡(罗格营地、修道院、大教堂、地下墓穴、鲁高因、库拉斯特码头、哈洛加斯…)+ **70 个随机迷宫 + 31 个野外关卡**(生成逻辑移植自 D2MOO,按官方参数表复现) |
|
||||
| **真·女法师** | `d2char.mpq` 的 DCC + COF 逐层合成(8 方向;行走 `SOWLHTH`、站立 `SONUHTH`),不是占位棋子 |
|
||||
| **资源包(离线烘焙)** | 索引色 PNG 图集页(≤2048²,PLTE + tRNS 取自该 act 的 `pal.pl2`)+ `scene.json`(绘制列表、碰撞 RLE、出生点、对象元数据);首屏只加载视口需要的页,其余后台补 |
|
||||
| **七大职业与角色合成** | 完整支持亚马逊(Amazon)、刺客(Assassin)、野蛮人(Barbarian)、德鲁伊(Druid)、死灵法师(Necromancer)、圣骑士(Paladin)、女巫(Sorceress)七大职业的 DCC + COF 16 层组件装配与 8 方向动画,支持游戏中实时切换职业与初始专属装备 |
|
||||
| **全技能系统(221 技能)** | 210 个各职业技能(每职业 3 系 × 10 技能)与 11 个通用玩家技能(普攻/踢击/投掷/反召唤/辨识/回城等),严格复现 1.13c 公式、协同加成(Synergies)、投射物引擎、光环扫描与延迟机制 |
|
||||
| **完整 UI / HUD 交互** | 800×600 原版底栏控制台(红蓝血球与中毒覆盖、技能快捷槽、腰带)、左/右宽屏 400px 停靠面板(背包、纸娃娃、属性面板、技能树、储物箱、方块、任务日志、传送点菜单)、动态鼠标指针、Alt 物品高亮等 |
|
||||
| **资源包(离线烘焙)** | 索引色 PNG 图集页(≤2048²,PLTE + tRNS 取自该 act 的 `pal.pl2`)+ `scene.json`(绘制列表、碰撞 RLE、出生点、对象元数据)+ `manifest.json` |
|
||||
| **按需读归档** | 也可不烘焙,直接用 HTTP Range 只拉需要的 MPQ 字节当场解码(`?live=1`,慢,作为回退路径) |
|
||||
| **三级关卡选择器** | 章节 → 场景 → 细分场景(如 第一章 / 罗格营地 / 北);中文场景名见 `src/game/level-names-zh.ts` |
|
||||
|
||||
**尚未实现**:真实地图上的怪物还是占位配置——怪物种类、密度、精英怪、超级唯一怪都尚未从
|
||||
`Levels.txt` / `MonStats.txt` / `SuperUniques.txt` 读取(见 M7);关卡之间还不连通(见 M8);
|
||||
战斗数值不是暗黑2 的公式(见 M9);可破坏物品的美术(位置与美术成员已进包,DCC 帧未接)。
|
||||
完整的里程碑排期见 [`ROADMAP.md`](ROADMAP.md)。
|
||||
**开发进展**:当前底层引擎、七大职业表现、221 技能完整实现与全套 UI 面板已全面打通。下一步将深入完善野外怪物密度与 AI 行为、装备词缀掉落平衡与 `.d2s` 官方存档双向互通,详见 [`ROADMAP.md`](ROADMAP.md)。
|
||||
|
||||
### 代码结构
|
||||
|
||||
|
|
@ -30,21 +29,124 @@
|
|||
src/mpq/ MPQ v1 容器:hash/block 表、Storm 加密、分扇区编解码、PKWARE implode/explode、Range 读取
|
||||
src/formats/ dc6 / ds1 / dt1 / pal / pl2 / cel / pcx / tbl / sprite / dcc / cof / bitstream
|
||||
src/game/ 等距场景与碰撞、关卡表解析、DCC+COF 角色合成、对象与物品解析、确定性 RNG
|
||||
engine/ 1.13c 真实战斗引擎、AST 公式求值器、状态机、投射物调度、光环与召唤物管理
|
||||
skills/ 221 个技能的具体实现模块(uni / ama / sor / nec / pal / bar / dru / ass)
|
||||
src/ui/ 完整 UI / HUD 模块:背包、属性面板、技能树、控制底栏、传送点、储物箱、任务日志、字体、鼠标指针
|
||||
src/render/ WebGL2 渲染器(多图集;纹理切换时 flush 以保画家序)
|
||||
src/scene/ 页面级装配(资源包优先,失败回退到直读归档)
|
||||
src/net/ src/sim/ 联机中继与确定性锁步(夹具地图上的 2–4 人合作)
|
||||
src/data/ 技能目录、Gitea 跟踪 Issue 数据库、掉落表与任务官方文本数据
|
||||
scripts/ 资源包烘焙、格式/数据/部署校验、无头浏览器检查、夹具生成
|
||||
*.html 五个入口页面:acts / index / map / walk / net
|
||||
*.html 主入口页面:acts.html(游戏全功能主页面)、arena.html(数值与技能验证竞技场)
|
||||
```
|
||||
|
||||
## 美术资源与图集资产(Assets)
|
||||
|
||||
项目在离线烘焙和客户端运行时划分了清晰的美术资产目录结构,所有视觉素材均可从 1.13c 原版 MPQ 自动导出与打包:
|
||||
|
||||
### 1. UI 界面图集与面板切片 (`public/ui/`)
|
||||
|
||||
- **综合图集 (Atlases)**:
|
||||
- `items-atlas.png`:装备与物品大图集,覆盖所有武器、防具、饰品(戒指、护身符、珠宝)、符文、药水等,完整支持大/中/小护身符与首饰的原版 `VarInvGfx` 多视觉外观变体。
|
||||
- `quests-atlas.png`:1–5 幕全部任务状态图标图集(未触发、进行中、已完成)。
|
||||
- `skill-tree-tabs.png`:七大职业技能树的 3 系专属垂直选项卡图集(激活/未激活切片)。
|
||||
- **经典功能面板背景与控件**:
|
||||
- `ctrlpnl-800.png`:800×600 原版底栏控制台面板(包含技能槽底框、经验槽、腰带槽)。
|
||||
- `inv-sheet.png` / `inv-tab-0.png` / `inv-tab-1.png`:装备纸娃娃界面与 10×4 背包网格、主副手武器切换 Tab。
|
||||
- `char-sheet.png`:角色属性与抗性详情面板。
|
||||
- `stash-bg.png`:营地私人储物箱面板(6×8 格子)。
|
||||
- `cube-bg.png`:赫拉迪克方块(3×4 合成面板)。
|
||||
- `vendor-bg.png` / `buysellbtn.png`:NPC 商人交易界面与买卖交互按钮。
|
||||
- `quest-bg.png` / `quest-tabs.png`:羊皮纸任务日志背景与 1–5 幕选项卡。
|
||||
- `waypoint-bg.png` / `waypoint-tabs.png` / `waypoint-icons.png`:传送点菜单、各幕选项卡与石碑传送状态图标。
|
||||
- **指示器与动态视觉元素**:
|
||||
- `globe-life.png` / `globe-mana.png` / `globe-poison.png`:经典流体红蓝血球与中毒、法力燃烧覆盖层。
|
||||
- `cursor.png` / `cursor-hand.png`:硬件感暗黑手套鼠标指针(支持空闲状态机与点击动作动画)。
|
||||
- `border-left.png` / `border-right.png` / `overlap-left.png` / `overlap-right.png`:宽屏自适应下的左右面板停靠石柱边框与过渡阴影。
|
||||
- `minipanel.png` / `minipanel-btns.png` / `popbelt.png` / `runbutton.png` / `menubutton.png` / `level-btn.png` / `skillpoints.png`:菜单微型按钮、升级提示羽毛球、跑步状态切换钮等。
|
||||
- **原版位图字体 (`public/ui/fonts/`)**:
|
||||
- 提供 6 套暗黑 II 经典字体纹理:`font6.png`、`font8.png`、`font16.png`、`font30.png`、`fontexocet10.png`、`fontformal12.png`。
|
||||
|
||||
### 2. 技能树背景与技能图标资产 (`public/skills/` 与 `public/ui/`)
|
||||
|
||||
- **全职业技能树背景切片 (`public/skills/bg_<cls>_<tab>.png`)**:
|
||||
- 共 21 张 256×432 px 原版复古羊皮纸技能树背景原画,覆盖 7 大职业各自的 3 大技能分支:
|
||||
- 亚马逊:`bg_ama_0.png`(标枪与长矛)、`bg_ama_1.png`(被动与魔法)、`bg_ama_2.png`(弓与十字弓)
|
||||
- 刺客:`bg_ass_0.png`(武学艺术)、`bg_ass_1.png`(影子训练)、`bg_ass_2.png`(陷阱)
|
||||
- 野蛮人:`bg_bar_0.png`(战吼)、`bg_bar_1.png`(战斗专家)、`bg_bar_2.png`(战斗技能)
|
||||
- 德鲁伊:`bg_dru_0.png`(元素)、`bg_dru_1.png`(变形)、`bg_dru_2.png`(召唤)
|
||||
- 死灵法师:`bg_nec_0.png`(召唤术)、`bg_nec_1.png`(毒素与白骨)、`bg_nec_2.png`(诅咒)
|
||||
- 圣骑士:`bg_pal_0.png`(战斗技能)、`bg_pal_1.png`(攻击灵气)、`bg_pal_2.png`(防御灵气)
|
||||
- 女巫:`bg_sor_0.png`(火焰法术)、`bg_sor_1.png`(闪电法术)、`bg_sor_2.png`(冰冷法术)
|
||||
- **技能图标资产 (`public/skills/icon_*.png` 与 `public/ui/`)**:
|
||||
- 包含 221 个技能的 48×48 px 原版技能图标,细分为普通态(`icon_<id>.png`)与激活/按下高光态(`icon_<id>_active.png`)。
|
||||
- 涵盖七大职业全部 210 个技能,以及 11 个通用技能(普通攻击、踢击、投掷、反召唤、辨识/回城卷轴与书、迪勒瑞姆变身等)。
|
||||
- 动态普通攻击图标:根据当前主手装备自动切换拳头图标(`icon_0_fist.png`)或武器剑图标(`icon_0_sword.png`),并具备投掷(`icon_2.png`)与反召唤(`icon_3.png`)专用图标。
|
||||
|
||||
### 3. 角色与怪物实体动画包 (`samples/d2-packs/entities/`)
|
||||
|
||||
- **七大职业模型**:
|
||||
- 亚马逊(`char-am`)、刺客(`char-ai`)、野蛮人(`char-ba`)、德鲁伊(`char-dz`)、死灵法师(`char-ne`)、圣骑士(`char-pa`)、女巫(`char-so`)。
|
||||
- 由 16 个装备图层复合装配,解包出行走、跑动、各种武器攻击、施法、受击、死亡等 8 方向完整序列。
|
||||
- 伴随同名 `.r8` 紧凑二进制像素包,用于在 WebGL/Canvas 中零拷贝极速解码渲染。
|
||||
- **怪物实体模型**:
|
||||
- 包含沉沦魔(Fallen)、骷髅(Skeleton)、僵尸(Zombie)、血鸟(Blood Raven)、督瑞尔(Duriel)、安达利尔(Andariel)、墨菲斯托(Mephisto)、暗黑破坏神(Diablo)、巴尔(Baal)等数十种怪物的完整动画包与 `.r8` 调色板像素数据。
|
||||
|
||||
### 4. 场景瓦片图集 (`samples/d2-packs/act[1-5]/`)
|
||||
|
||||
- 365 个地图场景分块切片图集,每个图集包含 ≤2048² 尺寸的索引色 PNG 瓦片页(地板层、墙体层、屋顶层),附带 PLTE 调色板与透明度表。
|
||||
|
||||
## 结构化数据与 JSON 规范(Data & JSON)
|
||||
|
||||
引擎采用结构化 JSON 数据来驱动界面排版、图集映射、场景还原与游戏逻辑:
|
||||
|
||||
### 1. `public/ui/manifest.json`(UI 资源与图集总清单)
|
||||
由 `scripts/pack-ui.ts` 自动烘焙生成,为 UI 系统提供像素级定位映射:
|
||||
- `atlasWidth` / `atlasHeight`:`items-atlas.png` 装备图集的分辨率规格。
|
||||
- `questsAtlasWidth` / `questsAtlasHeight`:`quests-atlas.png` 任务图集的分辨率规格。
|
||||
- `codeToInvFile`:物品 3 字母代码(如 `rin`、`amu`、`cm1`、`hpot`)到原版 MPQ 图像文件名的映射表。
|
||||
- `itemRects`:所有物品及装备的 UV 矩形坐标表(`{ x, y, w, h }`),针对戒指、项链、珠宝和护身符支持 `VarInvGfx` 多图集变体定位。
|
||||
- `questRects`:1–5 幕任务日志在图集中的坐标切片。
|
||||
- `images`:各种独立 UI 面板背景、边框、按键在 `public/ui/` 下的文件索引。
|
||||
|
||||
### 2. `public/ui/fonts/metrics.json`(位图字体排版度量)
|
||||
- 记录 6 套经典字体(Exocet、Formal、Font6/8/16/30)的字形度量参数。
|
||||
- 包含每个 ASCII 字符在字体图集上的矩形包围盒、字宽(`charWidths`)、字符高度、基线偏移与字间距(Kerning),为游戏内悬浮 Tooltip、装备描述和任务日志提供完全原版的排版效果。
|
||||
|
||||
### 3. `samples/d2-packs/index.json`(全关卡全局场景目录)
|
||||
- 记录全游戏 5 幕 365 个地图块的元数据。
|
||||
- 包含字段:章节编号(Act 1..5)、关卡 ID(Level ID)、内部场景标识、关卡类型(预置场景 Preset / 随机迷宫 Maze / 野外关卡 Wilderness)、网格尺寸(Grid Bounds)、可用变体数(Variants)与对应的烘焙目录相对路径。
|
||||
|
||||
### 4. `samples/d2-packs/world-graph.json`(世界拓扑连通网络图)
|
||||
- 从 `Levels.txt` 的连接参数(`Vis0..7`、`Warp0..7`)生成的全局世界拓扑图。
|
||||
- 记录场景之间的连通边(Edges)、传送点(Waypoints)分布、楼梯出入口(Warps)、野外边界开放开口(Perimeter Openings),支撑小地图与跨关卡传送切换。
|
||||
|
||||
### 5. `samples/d2-packs/anim/animdata.json`(动画关键帧事件权威表)
|
||||
由 `scripts/pack-animdata.ts` 直接解析 1.13c `AnimData.d2` 二进制文件(570,304 字节,3,558 条记录)生成:
|
||||
- 记录每个动作 Hash 的播放帧率(Frame Speed)、总帧数(Total Frames)。
|
||||
- 精确解析动作事件标志位:`0x01` 判定为近战命中/施法触发判定帧,`0x02` 判定为投射物(Missile)发射事件帧,确保 25Hz 动作逻辑与原版单机完全一致。
|
||||
|
||||
### 6. `samples/d2-packs/anim/monstats2.json`(怪物组件与动画模式配置)
|
||||
- 结构化提取自 `MonStats2.txt`。
|
||||
- 记录所有怪物的身体构成组件、图层合成规则、各武器模式下的动作代码映射(如 `hth`、`1hs`、`2ht`、`bow`)。
|
||||
|
||||
### 7. `samples/d2-packs/entities/char-*.json` 与 `monster-*.json`(实体模型定义)
|
||||
- 描述 7 大角色职业与数十种怪物的骨骼装配结构。
|
||||
- 包含复合图层映射、8 方向动作切片元数据、每帧中心偏移(Pivot),搭配同名 `.r8` 文件在渲染管线中执行高效逐像素着色。
|
||||
|
||||
### 8. `samples/d2-packs/act<N>/<level>/scene.json` & `manifest.json`(单场景规格)
|
||||
- `manifest.json`:当前场景所需的瓦片图集页清单与调色板信息。
|
||||
- `scene.json`:记录等距地图的地板(Floor)、墙体(Wall)、屋顶(Roof)多层瓦片绘制列表与深度排序,采用 RLE 压缩的 5×5 子格碰撞阻挡网格(Collision Grid),以及预置对象(储物箱、石碑、传送门、出生点)。
|
||||
|
||||
### 9. `src/data/gitea-skill-issues.json`(技能跟踪数据库)
|
||||
- 建立 221 个技能与开发追踪系统的双向索引映射,包含 Skill ID、英文名称、职业缩写、Slug 标识与对应的 Issue/Milestone 编号。
|
||||
|
||||
## 部署与使用
|
||||
|
||||
### 1. 前置:自备游戏数据
|
||||
|
||||
把你自己合法拥有的**经典版暗黑破坏神 II(1.13c)** 数据放进 `samples/d2/`,至少需要
|
||||
`d2data.mpq`、`d2char.mpq`、`d2exp.mpq`、`Patch_D2.mpq`。资源包模式(页面默认)只需要
|
||||
`samples/d2-packs/`——它不在 Git 里(63 MB 的索引 PNG 每次重烘都会让仓库再长一份),
|
||||
按下面的「资源包从哪来」下载解包后,**没有原版数据也能看地图**。
|
||||
`samples/d2-packs/`——它不在 Git 里,按下面的「资源包从哪来」下载解包后,**没有原版数据也能看地图**。
|
||||
|
||||
`samples/d2/` 通常做成软链接,指向一个**磁盘上的持久目录**。切勿链到 `/tmp`——它是内存盘,
|
||||
重启即清空,链接会变死链而角色/怪物退化成占位方块。本机的实际摆放、清单与逆向参考用的原版
|
||||
|
|
@ -78,8 +180,11 @@ tar xJf d2-packs-2026-09-14.tar.xz # 解出 samples/d2-packs/
|
|||
npm install # 仅 vite / typescript / @types/node
|
||||
tsx scripts/make-fixtures.ts samples/fixtures # 夹具:DC6 + 调色板 + 文本表
|
||||
tsx scripts/make-map-fixtures.ts samples/fixtures # 夹具:DT1 + DS1 + 夹具 MPQ
|
||||
npm run pack:data # 从 samples/d2/ 烘焙 62 个地图块 → samples/d2-packs
|
||||
npm run dev # http://127.0.0.1:5173/acts.html
|
||||
npm run pack:data # 从 samples/d2/ 烘焙 365 场景图集 → samples/d2-packs
|
||||
npm run pack:ui # 烘焙 UI 综合图集与 manifest.json
|
||||
npm run pack:animdata # 烘焙 AnimData.d2 与 monstats2.json
|
||||
npm run pack:entities # 烘焙 7 大职业模型与怪物实体动画包
|
||||
npm run dev # 启动 Vite:http://127.0.0.1:5173/acts.html
|
||||
```
|
||||
|
||||
`pack:data` 需要原版 MPQ(约 2 分钟);只用上面下载来的 `samples/d2-packs/` 可以跳过它,
|
||||
|
|
@ -89,10 +194,10 @@ npm run dev # http://127.0.0.1:5173/act
|
|||
|
||||
| 页面 | 用途 |
|
||||
| --- | --- |
|
||||
| `/acts.html`(或访问根路径 `/`) | **主页面**:全屏画布 + 三级选择器(章节 / 场景 / 细分场景)+ HUD |
|
||||
| `/arena.html` | **战斗测试竞技场**:攻防数值数学模型、技能加成、伤害计算器(1.13c 原版公式验证) |
|
||||
| `/acts.html`(或访问根路径 `/`) | **主游戏页面**:全屏 WebGL2 画布 + HUD 控制条 + 左右停靠全套功能面板 + 7 职业漫步战斗 |
|
||||
| `/arena.html` | **战斗数值与技能测试竞技场**:攻防数值数学模型、技能加成、伤害计算器(1.13c 原版公式验证) |
|
||||
|
||||
主页面查询参数:
|
||||
#### 页面查询参数
|
||||
|
||||
```
|
||||
?act=1..5 章节
|
||||
|
|
@ -100,9 +205,32 @@ npm run dev # http://127.0.0.1:5173/act
|
|||
?quadrant=<DS1 名> 直读归档模式下指定该关卡的某个 DS1
|
||||
?live=1 不走资源包,直接按 HTTP Range 读 MPQ 解码(慢)
|
||||
?pack=<目录> 换一个资源包目录 ?base=<目录> 换一个归档目录
|
||||
?arena=1&class=<cls>&skill=<id>&slvl=<lvl>&autocast=1 启动竞技场自动验证模式
|
||||
```
|
||||
|
||||
操作:**WASD / 方向键**移动(按屏幕方向),**滚轮 或 `+` / `-`** 缩放,`0` 复位;画布随窗口自适应并铺满视口。
|
||||
#### 完整操作快捷键
|
||||
|
||||
- **移动与视口**:
|
||||
- `WASD` / 方向键:移动角色(按等距屏幕方向)
|
||||
- 鼠标滚轮 或 `+` / `-`:视口无级缩放;`0`:缩放复位
|
||||
- **功能面板(宽屏左右停靠)**:
|
||||
- `I` / `B`:开启 / 关闭背包与装备纸娃娃面板(右停靠)
|
||||
- `A` / `C`:开启 / 关闭角色属性面板(左停靠)
|
||||
- `T`:开启 / 关闭技能树面板(右停靠,3 系垂直选项卡切换与加点)
|
||||
- `Q`:开启 / 关闭任务日志面板(左停靠,按幕查看真实任务状态)
|
||||
- `V`:开启 / 关闭传送点网络菜单(左停靠,各幕传送点导航)
|
||||
- `Esc`:关闭当前开启的所有停靠面板与弹出层
|
||||
- **战斗与技能快捷栏**:
|
||||
- 鼠标左键 / 右键:施放当前选中的左 / 右手技能
|
||||
- `S` 或点击底栏技能图标:打开 Speedbar 技能选择菜单
|
||||
- 鼠标悬浮在 Speedbar 技能上按 `F1` ~ `F8`:绑定快捷键;在游戏过程中按 `F1` ~ `F8`:即时切换技能
|
||||
- `1` ~ `4`:快捷使用腰带对应列药水;`~`:展开 / 折叠腰带
|
||||
- `Alt`(按住):高亮显示地面掉落物品标签
|
||||
- `R`:切换跑步 / 走路模式
|
||||
- `W`:主副手武器切换(`I` / `II` 套)
|
||||
- **测试与调试功能**:
|
||||
- 键盘数字 `1` ~ `7`:一键热切换 7 大职业(亚马逊、刺客、野蛮人、德鲁伊、死灵法师、圣骑士、女巫)并自动装配初始专属套装与储物箱
|
||||
- `K` / `L`:快速保存 / 读取角色快照存档
|
||||
|
||||
### 5. 部署到服务器
|
||||
|
||||
|
|
@ -149,6 +277,7 @@ npm run verify:acts # 关卡表 → DS1 → DT1 → 等距场景 + 碰
|
|||
npm run verify:d2 # 真 MPQ 数据端到端解码
|
||||
npm run verify:implode # PKWARE implode 解码器(全成员)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm test # 运行 Vitest 完整测试套件
|
||||
```
|
||||
|
||||
## 数据与许可
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
|
@ -110,6 +110,8 @@ async function main() {
|
|||
for (const cls of CLASS_CODES) {
|
||||
const backDc6 = decodeDc6(await archives.read(CLASS_BACK_DC6[cls]!))
|
||||
const W = 256, H = 432
|
||||
const baseTop = backDc6.groups[0]!.frames[0]!
|
||||
const baseBot = backDc6.groups[0]!.frames[2]!
|
||||
for (let tab = 0; tab < 3; tab++) {
|
||||
const topFrameIndex = 4 + tab * 4
|
||||
const botFrameIndex = 6 + tab * 4
|
||||
|
|
@ -117,8 +119,14 @@ async function main() {
|
|||
const botFrame = backDc6.groups[0]!.frames[botFrameIndex]!
|
||||
|
||||
const pixels = new Uint8Array(W * H)
|
||||
pixels.set(topFrame.indices, 0)
|
||||
pixels.set(botFrame.indices, 256 * 256)
|
||||
for (let i = 0; i < 256 * 256; i++) {
|
||||
const p = topFrame.indices[i]!
|
||||
pixels[i] = p !== 0 ? p : baseTop.indices[i]!
|
||||
}
|
||||
for (let i = 0; i < 256 * 176; i++) {
|
||||
const p = botFrame.indices[i]!
|
||||
pixels[256 * 256 + i] = p !== 0 ? p : baseBot.indices[i]!
|
||||
}
|
||||
|
||||
const png = encodeIndexedPng({
|
||||
width: W,
|
||||
|
|
|
|||
|
|
@ -358,6 +358,63 @@ async function main(): Promise<void> {
|
|||
const skillPts = await decodeUiDc6('data/global/ui/Panel/skillpoints.dc6')
|
||||
savePng('skillpoints.png', skillPts.groups[0]!.frames[0]!.width, skillPts.groups[0]!.frames[0]!.height, skillPts.groups[0]!.frames[0]!.indices)
|
||||
|
||||
const skltreeBack = await decodeUiDc6('data/global/ui/SPELLS/skltree_s_back.DC6')
|
||||
const stF1 = skltreeBack.groups[0]!.frames[1]!
|
||||
const stF3 = skltreeBack.groups[0]!.frames[3]!
|
||||
const stF7 = skltreeBack.groups[0]!.frames[7]!
|
||||
const stF9 = skltreeBack.groups[0]!.frames[9]!
|
||||
const stF11 = skltreeBack.groups[0]!.frames[11]!
|
||||
const stF13 = skltreeBack.groups[0]!.frames[13]!
|
||||
|
||||
const sttPixels = new Uint8Array(192 * 432)
|
||||
const pasteTabCol = (colIdx: number) => {
|
||||
const ox = colIdx * 64
|
||||
for (let y = 0; y < 256; y++) {
|
||||
for (let x = 0; x < 64; x++) {
|
||||
sttPixels[y * 192 + (ox + x)] = stF1.indices[y * 64 + x]!
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < 176; y++) {
|
||||
for (let x = 0; x < 64; x++) {
|
||||
sttPixels[(256 + y) * 192 + (ox + x)] = stF3.indices[y * 64 + x]!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Col 0: Top Tab Active (y = 108..216, stF13)
|
||||
pasteTabCol(0)
|
||||
for (let y = 108; y < 216; y++) {
|
||||
for (let x = 0; x < 64; x++) {
|
||||
const idx = stF13.indices[y * 64 + x]!
|
||||
if (idx !== 0) sttPixels[y * 192 + (0 + x)] = idx
|
||||
}
|
||||
}
|
||||
|
||||
// Col 1: Middle Tab Active (y = 216..324, stF9 + stF11)
|
||||
pasteTabCol(1)
|
||||
for (let y = 216; y < 256; y++) {
|
||||
for (let x = 0; x < 64; x++) {
|
||||
const idx = stF9.indices[y * 64 + x]!
|
||||
if (idx !== 0) sttPixels[y * 192 + (64 + x)] = idx
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < 68; y++) {
|
||||
for (let x = 0; x < 64; x++) {
|
||||
const idx = stF11.indices[y * 64 + x]!
|
||||
if (idx !== 0) sttPixels[(256 + y) * 192 + (64 + x)] = idx
|
||||
}
|
||||
}
|
||||
|
||||
// Col 2: Bottom Tab Active (y = 324..432, stF7)
|
||||
pasteTabCol(2)
|
||||
for (let y = 0; y < 176; y++) {
|
||||
for (let x = 0; x < 64; x++) {
|
||||
const idx = stF7.indices[y * 64 + x]!
|
||||
if (idx !== 0) sttPixels[(256 + y) * 192 + (128 + x)] = idx
|
||||
}
|
||||
}
|
||||
savePng('skill-tree-tabs.png', 192, 432, sttPixels)
|
||||
|
||||
const expQuestTabs = await decodeUiDc6('data/global/ui/Menu/expquesttabs.dc6')
|
||||
const eqtStrip = stitchHorizontalStrip(expQuestTabs.groups[0]!.frames)
|
||||
savePng('quest-tabs.png', eqtStrip.width, eqtStrip.height, eqtStrip.pixels)
|
||||
|
|
@ -373,6 +430,22 @@ async function main(): Promise<void> {
|
|||
const genericSkills = await decodeUiDc6('data/global/ui/SPELLS/Skillicon.DC6')
|
||||
const gsStrip = stitchHorizontalStrip(genericSkills.groups[0]!.frames)
|
||||
savePng('generic-skills.png', gsStrip.width, gsStrip.height, gsStrip.pixels)
|
||||
const attackFrame = genericSkills.groups[0]!.frames[0]!
|
||||
savePng('icon_0.png', attackFrame.width, attackFrame.height, attackFrame.indices)
|
||||
try {
|
||||
const skillsOut = join(process.cwd(), 'public', 'skills')
|
||||
mkdirSync(skillsOut, { recursive: true })
|
||||
const attackPng = encodeIndexedPng({
|
||||
width: attackFrame.width,
|
||||
height: attackFrame.height,
|
||||
pixels: attackFrame.indices,
|
||||
palette: pl2.rgb,
|
||||
transparentIndex: 0,
|
||||
})
|
||||
writeFileSync(join(skillsOut, 'icon_0.png'), attackPng)
|
||||
} catch {
|
||||
// optional copy to public/skills
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
@ -456,6 +529,30 @@ async function main(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
// Register authentic ring DC6 icons (invrin1..invrin5 per ItemTypes.txt VarInvGfx = 5)
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const ringInv = `invrin${i}`
|
||||
if (archives.has(`data/global/items/${ringInv}.dc6`)) {
|
||||
uniqueInvFiles.add(ringInv)
|
||||
}
|
||||
}
|
||||
|
||||
// Register authentic amulet DC6 icons (invamu1..invamu3 per ItemTypes.txt VarInvGfx = 3)
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const amuInv = `invamu${i}`
|
||||
if (archives.has(`data/global/items/${amuInv}.dc6`)) {
|
||||
uniqueInvFiles.add(amuInv)
|
||||
}
|
||||
}
|
||||
|
||||
// Register authentic jewel DC6 icons (invjw1..invjw6 per ItemTypes.txt VarInvGfx = 6)
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const jwInv = `invjw${i}`
|
||||
if (archives.has(`data/global/items/${jwInv}.dc6`)) {
|
||||
uniqueInvFiles.add(jwInv)
|
||||
}
|
||||
}
|
||||
|
||||
// Identity mapping for every invfile: codeToInvFile[inv] = inv
|
||||
for (const inv of uniqueInvFiles) {
|
||||
codeToInvFile[inv] = inv
|
||||
|
|
@ -661,7 +758,9 @@ async function main(): Promise<void> {
|
|||
levelBtn: '/ui/level-btn.png',
|
||||
levelSocket: '/ui/level-socket.png',
|
||||
skillPoints: '/ui/skillpoints.png',
|
||||
skillTabs: '/ui/skill-tree-tabs.png',
|
||||
genericSkills: '/ui/generic-skills.png',
|
||||
attackIcon: '/ui/icon_0.png',
|
||||
cursorHand: '/ui/cursor-hand.png',
|
||||
cursorAtlas: '/ui/cursor.png',
|
||||
questTabs: '/ui/quest-tabs.png',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||
import { fileSource } from '../src/mpq/file-source.ts'
|
||||
import { MountedArchives } from '../src/mpq/mount.ts'
|
||||
import { loadActTables } from '../src/game/acts.ts'
|
||||
import { SKILLS_CATALOG } from '../src/data/skills-catalog.ts'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
async function run() {
|
||||
console.log('Mounting MPQs to extract 1.13c ToHit and auraLenCalc...')
|
||||
const archives = new MountedArchives()
|
||||
for (const m of ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||
archives.add(m, await MpqArchive.open(await fileSource('samples/d2/' + m)))
|
||||
}
|
||||
const tables = await loadActTables(archives)
|
||||
const st = tables.skills!
|
||||
const toHitIdx = st.header.indexOf('ToHit')
|
||||
const levToHitIdx = st.header.indexOf('LevToHit')
|
||||
const toHitCalcIdx = st.header.indexOf('ToHitCalc')
|
||||
const auraLenCalcIdx = st.header.indexOf('auralencalc')
|
||||
const startmanaIdx = st.header.indexOf('startmana')
|
||||
|
||||
const updatedCatalog = SKILLS_CATALOG.map(skill => {
|
||||
const row = st.rows.find(r => Number(r[1]) === skill.id)
|
||||
const th = row ? (Number(row[toHitIdx]) || 0) : 0
|
||||
const lth = row ? (Number(row[levToHitIdx]) || 0) : 0
|
||||
const rawThc = row ? row[toHitCalcIdx] : undefined
|
||||
const thc = rawThc && rawThc.trim() !== '' ? rawThc.trim() : undefined
|
||||
const rawAlc = row ? row[auraLenCalcIdx] : undefined
|
||||
const alc = rawAlc && rawAlc.trim() !== '' ? rawAlc.trim() : undefined
|
||||
const sm = row ? (Number(row[startmanaIdx]) || 0) : 0
|
||||
|
||||
return {
|
||||
...skill,
|
||||
toHit: th,
|
||||
toHitLev: lth,
|
||||
...(thc ? { toHitCalc: thc } : {}),
|
||||
...(alc ? { auraLenCalc: alc } : {}),
|
||||
startmana: sm,
|
||||
}
|
||||
})
|
||||
|
||||
const tsContent = `// Auto-generated Diablo II 1.13c 210-Skill Catalog
|
||||
// Ground truth parsed from Skills.txt, SkillDesc.txt, string.tbl, and MPQs.
|
||||
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
|
||||
export interface SynergyBonusInfo {
|
||||
readonly providerId: number
|
||||
readonly providerName: string
|
||||
readonly percentPerLevel: number
|
||||
readonly type: 'damage' | 'elemental' | 'duration'
|
||||
}
|
||||
|
||||
export interface SynergyGivenInfo {
|
||||
readonly targetId: number
|
||||
readonly targetName: string
|
||||
readonly targetNameZh: string
|
||||
readonly percentPerLevel: number
|
||||
}
|
||||
|
||||
export interface DescLineDefinition {
|
||||
readonly lineType: number
|
||||
readonly textaKey?: string | undefined
|
||||
readonly textbKey?: string | undefined
|
||||
readonly textaEn?: string | undefined
|
||||
readonly textaZh?: string | undefined
|
||||
readonly textbEn?: string | undefined
|
||||
readonly textbZh?: string | undefined
|
||||
readonly calca?: string | undefined
|
||||
readonly calcb?: string | undefined
|
||||
}
|
||||
|
||||
export interface MinionBaseStats {
|
||||
readonly code: string
|
||||
readonly minHP: number
|
||||
readonly maxHP: number
|
||||
readonly minDam: number
|
||||
readonly maxDam: number
|
||||
}
|
||||
|
||||
export interface SkillCatalogEntry {
|
||||
readonly id: number
|
||||
readonly name: string
|
||||
readonly nameZh: string
|
||||
readonly classCode: CharacterClassCode
|
||||
readonly classNameZh: string
|
||||
readonly tabIndex: number
|
||||
readonly tabName: string
|
||||
readonly tabNameZh: string
|
||||
readonly row: number
|
||||
readonly col: number
|
||||
readonly reqlevel: number
|
||||
readonly reqskills: readonly number[]
|
||||
readonly dependents: readonly number[]
|
||||
readonly mana: number
|
||||
readonly lvlmana: number
|
||||
readonly minmana: number
|
||||
readonly manashift: number
|
||||
readonly delay: number
|
||||
readonly descdam?: string | undefined
|
||||
readonly hitshift: number
|
||||
readonly srcDamage: number
|
||||
readonly minDam: number
|
||||
readonly maxDam: number
|
||||
readonly minLevDam: readonly number[]
|
||||
readonly maxLevDam: readonly number[]
|
||||
readonly eType: string
|
||||
readonly eMin: number
|
||||
readonly eMax: number
|
||||
readonly eMinLev: readonly number[]
|
||||
readonly eMaxLev: readonly number[]
|
||||
readonly eLen: number
|
||||
readonly eLevLen: readonly number[]
|
||||
readonly toHit: number
|
||||
readonly toHitLev: number
|
||||
readonly toHitCalc?: string | undefined
|
||||
readonly auraLenCalc?: string | undefined
|
||||
readonly startmana: number
|
||||
readonly params: Readonly<Record<string, number>>
|
||||
readonly synergyFormulas: {
|
||||
readonly physical?: string | undefined
|
||||
readonly elemental?: string | undefined
|
||||
readonly duration?: string | undefined
|
||||
}
|
||||
readonly synergiesReceived: readonly SynergyBonusInfo[]
|
||||
readonly synergiesGiven: readonly SynergyGivenInfo[]
|
||||
readonly masteries: readonly number[]
|
||||
readonly descLines?: readonly DescLineDefinition[] | undefined
|
||||
readonly dsc2Lines?: readonly DescLineDefinition[] | undefined
|
||||
readonly minionStats?: MinionBaseStats | undefined
|
||||
readonly shortDesc: string
|
||||
readonly shortDescZh: string
|
||||
readonly longDesc: string
|
||||
readonly longDescZh: string
|
||||
readonly icon: string
|
||||
readonly iconActive: string
|
||||
}
|
||||
|
||||
export const SKILLS_CATALOG: readonly SkillCatalogEntry[] = Object.freeze(${JSON.stringify(updatedCatalog, null, 2)})
|
||||
|
||||
export const SKILLS_BY_ID: Readonly<Record<number, SkillCatalogEntry>> = Object.freeze(
|
||||
SKILLS_CATALOG.reduce<Record<number, SkillCatalogEntry>>((acc, cur) => {
|
||||
acc[cur.id] = cur
|
||||
return acc
|
||||
}, {})
|
||||
)
|
||||
|
||||
export const SKILLS_BY_CLASS: Readonly<Record<CharacterClassCode, readonly SkillCatalogEntry[]>> = Object.freeze({
|
||||
ama: SKILLS_CATALOG.filter(s => s.classCode === 'ama'),
|
||||
sor: SKILLS_CATALOG.filter(s => s.classCode === 'sor'),
|
||||
nec: SKILLS_CATALOG.filter(s => s.classCode === 'nec'),
|
||||
pal: SKILLS_CATALOG.filter(s => s.classCode === 'pal'),
|
||||
bar: SKILLS_CATALOG.filter(s => s.classCode === 'bar'),
|
||||
dru: SKILLS_CATALOG.filter(s => s.classCode === 'dru'),
|
||||
ass: SKILLS_CATALOG.filter(s => s.classCode === 'ass'),
|
||||
})
|
||||
`
|
||||
|
||||
const outPath = join(process.cwd(), 'src', 'data', 'skills-catalog.ts')
|
||||
writeFileSync(outPath, tsContent, 'utf-8')
|
||||
console.log(`Successfully updated ${outPath} with 1.13c ToHit and auraLenCalc!`)
|
||||
}
|
||||
|
||||
run().catch(err => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
*/
|
||||
|
||||
import type { CharacterClassCode } from './classes.ts'
|
||||
import { compute3BandDuration } from './engine/calc-ast.ts'
|
||||
import {
|
||||
SKILLS_CATALOG,
|
||||
SKILLS_BY_ID,
|
||||
|
|
@ -480,7 +481,13 @@ export function calculateDetailedDamage(
|
|||
baseMax = evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl)
|
||||
|
||||
if (skill.eLen > 0 || (skill.eLevLen && skill.eLevLen.length > 0)) {
|
||||
durationFrames = evaluateLevelBand(skill.eLen, skill.eLevLen, lvl)
|
||||
durationFrames = compute3BandDuration(
|
||||
lvl,
|
||||
skill.eLen,
|
||||
skill.eLevLen?.[0] ?? 0,
|
||||
skill.eLevLen?.[1] ?? 0,
|
||||
skill.eLevLen?.[2] ?? 0,
|
||||
)
|
||||
}
|
||||
} else if (skill.minDam > 0 || skill.maxDam > 0 || (skill.minLevDam && skill.minLevDam.some(x => x > 0))) {
|
||||
type = 'physical'
|
||||
|
|
@ -658,22 +665,42 @@ export function evaluateSkillEffects(
|
|||
const dm56 = evalDiminishing(p.par5 ?? 0, p.par6 ?? 0, lvl)
|
||||
const dm78 = evalDiminishing(p.par7 ?? 0, p.par8 ?? 0, lvl)
|
||||
|
||||
// Determine duration len
|
||||
let durationFrames = skill.eLen + (lvl - 1) * (skill.eLevLen?.[0] ?? 0)
|
||||
if (durationFrames === 0 && (skill.params.par1 ?? 0) > 0 && (skill.params.par2 ?? 0) > 0) {
|
||||
if (skill.name === 'Battle Orders' || skill.name === 'Shout' || skill.name === 'Battle Command') {
|
||||
// Determine duration len using authentic 1.13c 3-band duration scaling
|
||||
let durationFrames = compute3BandDuration(
|
||||
lvl,
|
||||
skill.eLen,
|
||||
skill.eLevLen?.[0] ?? 0,
|
||||
skill.eLevLen?.[1] ?? 0,
|
||||
skill.eLevLen?.[2] ?? 0,
|
||||
)
|
||||
if (durationFrames === 0) {
|
||||
if (
|
||||
skill.id === 138 ||
|
||||
skill.id === 149 ||
|
||||
skill.id === 155 ||
|
||||
skill.name === 'Battle Orders' ||
|
||||
skill.name === 'Shout' ||
|
||||
skill.name === 'Battle Command'
|
||||
) {
|
||||
const synShout = state.hardPoints[138] ?? 0
|
||||
const synBC = state.hardPoints[155] ?? 0
|
||||
const synBO = state.hardPoints[149] ?? 0
|
||||
let synBonus = 0
|
||||
if (skill.id === 149) synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
|
||||
else if (skill.id === 138) synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
|
||||
else if (skill.id === 155) synBonus = (synShout + synBO) * (skill.params.par8 ?? 125)
|
||||
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
|
||||
if (skill.id === 149 || skill.name === 'Battle Orders') {
|
||||
synBonus = (synShout + synBC) * (skill.params.par8 ?? 125)
|
||||
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
|
||||
} else if (skill.id === 138 || skill.name === 'Shout') {
|
||||
synBonus = (synBO + synBC) * (skill.params.par8 ?? 125)
|
||||
durationFrames = (skill.params.par3 ?? 0) + (lvl - 1) * (skill.params.par4 ?? 0) + synBonus
|
||||
} else if (skill.id === 155 || skill.name === 'Battle Command') {
|
||||
synBonus = (synShout + synBO) * (skill.params.par8 ?? 125)
|
||||
durationFrames = (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0) + synBonus
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ctx: SkillFormulaContext = {
|
||||
const shiftMultiplier = 2 ** ((skill.hitshift ?? 8) - 8)
|
||||
const baseCtx: SkillFormulaContext = {
|
||||
lvl,
|
||||
params: skill.params,
|
||||
ln12: (skill.params.par1 ?? 0) + (lvl - 1) * (skill.params.par2 ?? 0),
|
||||
|
|
@ -684,15 +711,24 @@ export function evaluateSkillEffects(
|
|||
dm34,
|
||||
dm56,
|
||||
dm78,
|
||||
toht: (skill.params.tohit ?? 40) + (lvl - 1) * 5,
|
||||
toht: 0,
|
||||
len: durationFrames,
|
||||
edmn: evaluateLevelBand(skill.eMin, skill.eMinLev, lvl),
|
||||
edmx: evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl),
|
||||
edmn: Math.round(evaluateLevelBand(skill.eMin, skill.eMinLev, lvl) * shiftMultiplier),
|
||||
edmx: Math.round(evaluateLevelBand(skill.eMax, skill.eMaxLev, lvl) * shiftMultiplier),
|
||||
mana: calculateManaCost(skill, lvl),
|
||||
hardPoints: state.hardPoints,
|
||||
effLevels,
|
||||
}
|
||||
|
||||
const toht = skill.toHitCalc
|
||||
? evalSkillDescFormula(skill.toHitCalc, baseCtx)
|
||||
: (skill.toHit ?? 0) + (lvl - 1) * (skill.toHitLev ?? 0)
|
||||
|
||||
const ctx: SkillFormulaContext = {
|
||||
...baseCtx,
|
||||
toht,
|
||||
}
|
||||
|
||||
const results: FormattedDescLine[] = []
|
||||
const lines = [...(skill.descLines || []), ...(skill.dsc2Lines || [])]
|
||||
for (const line of lines) {
|
||||
|
|
@ -861,7 +897,7 @@ export function buildTooltipViewModel(
|
|||
|
||||
const hard = state.hardPoints[skillId] ?? 0
|
||||
const eff = getEffectiveLevel(state, skillId)
|
||||
const isLearned = hard > 0
|
||||
const isLearned = eff > 0
|
||||
const levelMet = playerLevel >= skill.reqlevel
|
||||
const canAlloc = canAllocate(state, skillId)
|
||||
|
||||
|
|
@ -883,10 +919,10 @@ export function buildTooltipViewModel(
|
|||
const currentDamage = calculateDetailedDamage(state, skill, displayLevel)
|
||||
const currentEffects = evaluateSkillEffects(state, skill, displayLevel)
|
||||
|
||||
// Next Level stats
|
||||
// Next Level stats: only show when learned and not maxed
|
||||
let nextLevel: TooltipViewModel['nextLevel'] = null
|
||||
if (hard < 20) {
|
||||
const nextLvl = isLearned ? eff + 1 : 1
|
||||
if (isLearned && hard < 20) {
|
||||
const nextLvl = eff + 1
|
||||
nextLevel = {
|
||||
level: nextLvl,
|
||||
manaCost: calculateManaCost(skill, nextLvl),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
ORTHO_CELL_HEIGHT, ORTHO_CELL_WIDTH, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH,
|
||||
cellAt, cellCentre, findIsoSpawn, isBlockedAt, levelSeed,
|
||||
} from '../game/d2map.ts'
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
import type { CollisionGrid, IsoDraw } from '../game/d2map.ts'
|
||||
import {
|
||||
updateAnimatableTiles,
|
||||
|
|
@ -66,6 +67,7 @@ import { findMatchingVariant, getDefaultTownVariant, oppositeSide, type Side } f
|
|||
import { Minimap } from '../ui/minimap.ts'
|
||||
import type { MinimapLevel, MinimapMarker } from '../ui/minimap.ts'
|
||||
import { HudManager } from '../ui/hud-manager.ts'
|
||||
import type { InventoryPanel } from '../ui/inventory.ts'
|
||||
|
||||
/** Base locations tried in order when `?base=` is absent. */
|
||||
const DEFAULT_BASES = ['samples/d2', '/diablo2/data']
|
||||
|
|
@ -687,8 +689,214 @@ export function isPassiveSkill(skillId: number): boolean {
|
|||
return def?.type === 'passive'
|
||||
}
|
||||
|
||||
export function getSkillRange(skillId: number): number {
|
||||
if (skillId === 0) return 52 // playerReach melee
|
||||
export type AttackWeaponKind = 'unarmed' | 'melee' | 'ranged'
|
||||
export type AttackWeaponSubtype =
|
||||
| 'fist'
|
||||
| 'sword'
|
||||
| 'axe'
|
||||
| 'mace'
|
||||
| 'dagger'
|
||||
| 'staff'
|
||||
| 'polearm'
|
||||
| 'spear'
|
||||
| 'bow'
|
||||
| 'crossbow'
|
||||
| 'other'
|
||||
|
||||
export interface AttackWeaponInfo {
|
||||
readonly kind: AttackWeaponKind
|
||||
readonly subtype: AttackWeaponSubtype
|
||||
readonly isRanged: boolean
|
||||
readonly ammoType?: 'arrow' | 'bolt' | undefined
|
||||
readonly damageMin: number
|
||||
readonly damageMax: number
|
||||
readonly reach: number
|
||||
readonly cooldown: number
|
||||
readonly swingTicks: number
|
||||
}
|
||||
|
||||
const BOW_CODES = new Set([
|
||||
'sbw', 'hbw', 'lbw', 'cbw', 'sbb', 'lbb', 'swb', 'lwb',
|
||||
'am1', 'am2', 'am6', 'am7', 'amb',
|
||||
'8sb', '8hb', '8lb', '8cb', '8s8', '8l8', '8sw', '8lw',
|
||||
'6sb', '6hb', '6lb', '6cb', '6s7', '6l7', '6sw', '6lw',
|
||||
'amc', 'amd', 'ame', 'amj', 'amk',
|
||||
])
|
||||
|
||||
const XBOW_CODES = new Set([
|
||||
'lxb', 'mxb', 'hxb', 'rxb',
|
||||
'8lx', '8mx', '8hx', '8rx',
|
||||
'6lx', '6mx', '6hx', '6rx',
|
||||
])
|
||||
|
||||
export interface AttackWeaponInputItem {
|
||||
code?: string | undefined
|
||||
type?: string | undefined
|
||||
wclass?: string | undefined
|
||||
name?: string | undefined
|
||||
nameZh?: string | undefined
|
||||
damage?: string | undefined
|
||||
}
|
||||
|
||||
export function getAttackWeaponInfo(item?: AttackWeaponInputItem | null | undefined): AttackWeaponInfo {
|
||||
if (!item || !item.code) {
|
||||
return {
|
||||
kind: 'unarmed',
|
||||
subtype: 'fist',
|
||||
isRanged: false,
|
||||
damageMin: 1,
|
||||
damageMax: 2,
|
||||
reach: 52,
|
||||
cooldown: 12,
|
||||
swingTicks: 6,
|
||||
}
|
||||
}
|
||||
|
||||
const code = item.code.trim().toLowerCase()
|
||||
const type = (item.type ?? '').trim().toLowerCase()
|
||||
const wclass = (item.wclass ?? '').trim().toLowerCase()
|
||||
const name = (item.name ?? '').toLowerCase()
|
||||
const nameZh = (item.nameZh ?? '').toLowerCase()
|
||||
|
||||
let dMin = 3
|
||||
let dMax = 8
|
||||
if (item.damage) {
|
||||
const m = item.damage.match(/(\d+)\s*(?:to|-)\s*(\d+)/)
|
||||
if (m) {
|
||||
dMin = parseInt(m[1], 10)
|
||||
dMax = parseInt(m[2], 10)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Crossbow (checked before bow because 'crossbow' contains 'bow')
|
||||
if (type === 'xbow' || wclass === 'xbw' || XBOW_CODES.has(code) || /crossbow|xbow|十字弓|弩/i.test(name) || /十字弓|弩/i.test(nameZh)) {
|
||||
return {
|
||||
kind: 'ranged',
|
||||
subtype: 'crossbow',
|
||||
isRanged: true,
|
||||
ammoType: 'bolt',
|
||||
damageMin: dMin,
|
||||
damageMax: dMax,
|
||||
reach: 450,
|
||||
cooldown: 16,
|
||||
swingTicks: 8,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Bow
|
||||
if (type === 'bow' || type === 'abow' || wclass === 'bow' || BOW_CODES.has(code) || /\bbow\b|弓/i.test(name) || /弓/i.test(nameZh)) {
|
||||
return {
|
||||
kind: 'ranged',
|
||||
subtype: 'bow',
|
||||
isRanged: true,
|
||||
ammoType: 'arrow',
|
||||
damageMin: dMin,
|
||||
damageMax: dMax,
|
||||
reach: 450,
|
||||
cooldown: 14,
|
||||
swingTicks: 7,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Melee Weapon Subtypes: sword, axe, mace, dagger, staff, polearm, spear
|
||||
let subtype: AttackWeaponSubtype | null = null
|
||||
let reach = 52
|
||||
|
||||
if (type === 'swor' || /sword|blade|scimitar|saber|falchion|claymore|broadsword|crystal sword|剑/i.test(name) || /剑/i.test(nameZh)) {
|
||||
subtype = 'sword'
|
||||
reach = 52
|
||||
} else if (type === 'axe' || type === 'taxe' || /axe|hache|cleaver|斧/i.test(name) || /斧/i.test(nameZh)) {
|
||||
subtype = 'axe'
|
||||
reach = 52
|
||||
} else if (type === 'mace' || type === 'club' || type === 'hamm' || type === 'scep' || /mace|club|hammer|scepter|flail|morning star|maul|锤|棍|杖|锏/i.test(name) || /锤|锏/i.test(nameZh)) {
|
||||
subtype = 'mace'
|
||||
reach = 52
|
||||
} else if (type === 'knif' || type === 'tkni' || /dagger|dirk|kris|blade|knife|匕首|短刀/i.test(name) || /匕首|短刀/i.test(nameZh)) {
|
||||
subtype = 'dagger'
|
||||
reach = 48
|
||||
} else if (type === 'staf' || /staff|长杖|法杖/i.test(name) || /长杖|法杖/i.test(nameZh)) {
|
||||
subtype = 'staff'
|
||||
reach = 52
|
||||
} else if (type === 'pole' || /polearm|bardiche|voulge|scythe|halberd|war scythe|长柄|长镰/i.test(name) || /长柄|长镰/i.test(nameZh)) {
|
||||
subtype = 'polearm'
|
||||
reach = 60
|
||||
} else if (type === 'spea' || type === 'aspe' || type === 'jave' || type === 'ajav' || /spear|trident|brandistock|spetum|pike|javelin|长矛|标枪/i.test(name) || /长矛|标枪/i.test(nameZh)) {
|
||||
subtype = 'spear'
|
||||
reach = 60
|
||||
}
|
||||
|
||||
if (subtype !== null) {
|
||||
return {
|
||||
kind: 'melee',
|
||||
subtype,
|
||||
isRanged: false,
|
||||
damageMin: dMin,
|
||||
damageMax: dMax,
|
||||
reach,
|
||||
cooldown: 14,
|
||||
swingTicks: 7,
|
||||
}
|
||||
}
|
||||
|
||||
// Non-melee item (e.g. orb, wand) or unarmed fallback timing: cooldown 12, swingTicks 6
|
||||
return {
|
||||
kind: 'unarmed',
|
||||
subtype: 'fist',
|
||||
isRanged: false,
|
||||
damageMin: dMin > 3 ? dMin : 1,
|
||||
damageMax: dMax > 8 ? dMax : 2,
|
||||
reach: 52,
|
||||
cooldown: 12,
|
||||
swingTicks: 6,
|
||||
}
|
||||
}
|
||||
|
||||
export function checkAmmunition(
|
||||
ammoType: 'arrow' | 'bolt',
|
||||
offhandItem?: { code?: string | undefined; name?: string | undefined; nameZh?: string | undefined } | null | undefined,
|
||||
inventory?: { items?: readonly any[] | undefined; grid?: readonly any[] | undefined; gridItems?: readonly any[] | undefined } | InventoryPanel | null | undefined,
|
||||
): boolean {
|
||||
// 1. Check offhand equipment (weapon2)
|
||||
if (offhandItem && offhandItem.code) {
|
||||
const code = offhandItem.code.toLowerCase().trim()
|
||||
const name = (offhandItem.name ?? '').toLowerCase()
|
||||
const nameZh = (offhandItem.nameZh ?? '').toLowerCase()
|
||||
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
|
||||
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
|
||||
}
|
||||
|
||||
// 2. Check inventory grid / items
|
||||
if (inventory) {
|
||||
const invAny = inventory as any
|
||||
const list: any[] = invAny.gridItems
|
||||
? invAny.gridItems.map((g: any) => g.item ?? g)
|
||||
: invAny.grid
|
||||
? invAny.grid.map((g: any) => g.item ?? g)
|
||||
: (invAny.items ?? [])
|
||||
for (const it of list) {
|
||||
if (!it || !it.code) continue
|
||||
const code = String(it.code).toLowerCase().trim()
|
||||
const name = String(it.name ?? '').toLowerCase()
|
||||
const nameZh = String(it.nameZh ?? '').toLowerCase()
|
||||
if (ammoType === 'arrow' && (code === 'aqv' || code === '0qv' || /arrow/i.test(name) || /箭矢|箭/i.test(nameZh))) return true
|
||||
if (ammoType === 'bolt' && (code === 'cqv' || code === '1qv' || /bolt/i.test(name) || /十字弓弹|弩箭/i.test(nameZh))) return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function getSkillRange(
|
||||
skillId: number,
|
||||
hudManager?: HudManager | null | undefined,
|
||||
equippedWeapon?: AttackWeaponInputItem | null | undefined,
|
||||
): number {
|
||||
if (skillId === 0) {
|
||||
const hud = hudManager ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance : null)
|
||||
const weapon = equippedWeapon ?? hud?.inventory?.equipped?.weapon1
|
||||
const info = getAttackWeaponInfo(weapon)
|
||||
return info.reach
|
||||
}
|
||||
const def = getBatch1SkillDef(skillId)
|
||||
if (def !== undefined) {
|
||||
if (def.type === 'melee') return 52
|
||||
|
|
@ -739,6 +947,9 @@ export function castSkill(
|
|||
status: HTMLElement
|
||||
playerAnimator?: ActorAnimator | undefined
|
||||
character?: LoadedCharacter | null | undefined
|
||||
equippedWeapon?: any | null | undefined
|
||||
equippedAmmo?: any | null | undefined
|
||||
hasAmmo?: boolean | undefined
|
||||
},
|
||||
): boolean {
|
||||
const { engine, runtime, hudManager, status, playerAnimator, character } = context
|
||||
|
|
@ -783,16 +994,58 @@ export function castSkill(
|
|||
|
||||
// 5. Dispatch skill
|
||||
if (skillId === 0) {
|
||||
// Normal Melee Attack
|
||||
player.cooldown = 12
|
||||
player.swingTicks = 6
|
||||
const weapon = context.equippedWeapon ?? hudManager?.inventory?.equipped?.weapon1 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon1 : null)
|
||||
const weaponInfo = getAttackWeaponInfo(weapon)
|
||||
|
||||
if (weaponInfo.isRanged) {
|
||||
// Ranged Attack (Bow / Crossbow)
|
||||
const ammo = context.equippedAmmo ?? hudManager?.inventory?.equipped?.weapon2 ?? (typeof window !== 'undefined' ? (window as any).__d2webHudInstance?.inventory?.equipped?.weapon2 : null)
|
||||
const hasAmmo = context.hasAmmo ?? checkAmmunition(weaponInfo.ammoType!, ammo, hudManager?.inventory)
|
||||
if (!hasAmmo) {
|
||||
status.textContent = weaponInfo.ammoType === 'arrow' ? '缺少箭矢!' : '缺少十字弓弹!'
|
||||
return false
|
||||
}
|
||||
|
||||
player.cooldown = weaponInfo.cooldown
|
||||
player.swingTicks = weaponInfo.swingTicks
|
||||
|
||||
const dx = targetX - player.x
|
||||
const dy = targetY - player.y
|
||||
const len = Math.hypot(dx, dy)
|
||||
const speed = 500
|
||||
const range = weaponInfo.reach
|
||||
const perTick = speed / 25
|
||||
const ttl = Math.max(1, Math.round(range / perTick))
|
||||
|
||||
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
|
||||
|
||||
const projectile: Projectile = {
|
||||
skillId: '0',
|
||||
x: player.x,
|
||||
y: player.y,
|
||||
vx: len > 0 ? (dx / len) * perTick : perTick,
|
||||
vy: len > 0 ? (dy / len) * perTick : 0,
|
||||
damage,
|
||||
ttl,
|
||||
fromPlayer: true,
|
||||
}
|
||||
|
||||
engine.projectiles.push(projectile)
|
||||
engine.metrics.casts += 1
|
||||
status.textContent = weaponInfo.subtype === 'bow' ? '普通攻击:发射箭矢' : '普通攻击:发射弩箭'
|
||||
return true
|
||||
}
|
||||
|
||||
// Unarmed or Melee Attack
|
||||
player.cooldown = weaponInfo.cooldown
|
||||
player.swingTicks = weaponInfo.swingTicks
|
||||
|
||||
let targetMonster: Monster | null = null
|
||||
// Prioritize monster near target cursor within melee reach of player
|
||||
let bestTargetDist = 36
|
||||
for (const monster of engine.world.monsters) {
|
||||
if (monster.state === 'dead' || monster.hp <= 0) continue
|
||||
const distToPlayer = Math.hypot(monster.x - player.x, monster.y - player.y)
|
||||
if (distToPlayer <= 52) {
|
||||
if (distToPlayer <= weaponInfo.reach) {
|
||||
const distFeet = Math.hypot(monster.x - targetX, monster.y - targetY)
|
||||
const distBody = Math.hypot(monster.x - targetX, (monster.y - 16) - targetY)
|
||||
const minD = Math.min(distFeet, distBody)
|
||||
|
|
@ -802,9 +1055,8 @@ export function castSkill(
|
|||
}
|
||||
}
|
||||
}
|
||||
// Fallback: closest monster to player within melee reach
|
||||
if (targetMonster === null) {
|
||||
let bestDist = 52
|
||||
let bestDist = weaponInfo.reach
|
||||
for (const monster of engine.world.monsters) {
|
||||
if (monster.state === 'dead' || monster.hp <= 0) continue
|
||||
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
|
||||
|
|
@ -814,10 +1066,13 @@ export function castSkill(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
const damage = Math.round((weaponInfo.damageMin + weaponInfo.damageMax) / 2)
|
||||
if (targetMonster !== null) {
|
||||
damageMonster(engine.world, targetMonster.index, 7)
|
||||
damageMonster(engine.world, targetMonster.index, damage)
|
||||
engine.metrics.playerHits += 1
|
||||
status.textContent = `普通攻击命中:${targetMonster.stats.name || targetMonster.stats.id}`
|
||||
const attackLabel = weaponInfo.kind === 'unarmed' ? '普通攻击(空手拳击)' : '普通攻击'
|
||||
status.textContent = `${attackLabel}命中:${targetMonster.stats.name || targetMonster.stats.id}`
|
||||
} else {
|
||||
engine.world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' })
|
||||
}
|
||||
|
|
@ -985,7 +1240,7 @@ export type PendingInteraction =
|
|||
| { readonly kind: 'npc'; readonly npc: NpcEntity }
|
||||
| { readonly kind: 'waypoint'; readonly waypoint: SceneWaypoint; readonly worldPos: { readonly x: number; readonly y: number } }
|
||||
| { readonly kind: 'stash'; readonly stash: TownStashEntity }
|
||||
| { readonly kind: 'monster'; readonly monster: Monster }
|
||||
| { readonly kind: 'monster'; readonly monster: Monster; readonly skillId?: number }
|
||||
|
||||
declare global {
|
||||
interface HTMLCanvasElement {
|
||||
|
|
@ -1125,7 +1380,17 @@ export class SceneMouseController {
|
|||
}
|
||||
} else if (e.button === 2) {
|
||||
this.isRightMouseDown = true
|
||||
this.handleRightClickWorld(worldX, worldY)
|
||||
if (this.input.shiftHeld) {
|
||||
this.activeClickType = 'none'
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
const player = this.engine.world.player
|
||||
player.facing = facingOf(worldX - player.x, worldY - player.y)
|
||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||
this.castSkill(rightSkillId, worldX, worldY)
|
||||
} else {
|
||||
this.handleRightClickWorld(worldX, worldY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1144,7 +1409,7 @@ export class SceneMouseController {
|
|||
this.navTarget = { x: worldX, y: worldY }
|
||||
} else if (this.isRightMouseDown) {
|
||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
|
||||
if (isPassiveSkill(rightSkillId) || (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground')) {
|
||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(e.clientX, e.clientY)
|
||||
this.navTarget = { x: worldX, y: worldY }
|
||||
}
|
||||
|
|
@ -1166,14 +1431,63 @@ export class SceneMouseController {
|
|||
|
||||
handleRightClickWorld(worldX: number, worldY: number): void {
|
||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
|
||||
if (isPassiveSkill(rightSkillId)) {
|
||||
// Walk/Run fallback
|
||||
this.pendingInteraction = null
|
||||
this.navTarget = { x: worldX, y: worldY }
|
||||
this.lastDist = Math.hypot(worldX - this.engine.world.player.x, worldY - this.engine.world.player.y)
|
||||
this.stuckTicks = 0
|
||||
return
|
||||
}
|
||||
|
||||
const player = this.engine.world.player
|
||||
const range = getSkillRange(rightSkillId, this.hudManager)
|
||||
|
||||
// 1. Check Monster: living monster within ~36 px
|
||||
let clickedMonster: Monster | null = null
|
||||
let bestMonDist = 36
|
||||
for (const m of this.engine.world.monsters) {
|
||||
if (m.state === 'dead' || m.hp <= 0) continue
|
||||
const distFeet = Math.hypot(m.x - worldX, m.y - worldY)
|
||||
const distBody = Math.hypot(m.x - worldX, (m.y - 16) - worldY)
|
||||
const minD = Math.min(distFeet, distBody)
|
||||
if (minD <= bestMonDist) {
|
||||
bestMonDist = minD
|
||||
clickedMonster = m
|
||||
}
|
||||
}
|
||||
|
||||
if (clickedMonster !== null) {
|
||||
this.activeClickType = 'monster'
|
||||
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
||||
if (distToPlayer <= range) {
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
||||
this.castSkill(rightSkillId, clickedMonster.x, clickedMonster.y)
|
||||
} else {
|
||||
this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: rightSkillId }
|
||||
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Ground clicked with active right skill
|
||||
if (rightSkillId === 0) {
|
||||
const weapon = this.hudManager?.inventory?.equipped?.weapon1
|
||||
const weaponInfo = getAttackWeaponInfo(weapon)
|
||||
if (weaponInfo.isRanged) {
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
this.castSkill(rightSkillId, worldX, worldY)
|
||||
} else {
|
||||
this.activeClickType = 'ground'
|
||||
this.pendingInteraction = null
|
||||
this.navTarget = { x: worldX, y: worldY }
|
||||
this.lastDist = Math.hypot(worldX - player.x, worldY - player.y)
|
||||
this.stuckTicks = 0
|
||||
}
|
||||
} else {
|
||||
// Active skill execution: halt movement and cancel pending interactions
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
this.castSkill(rightSkillId, worldX, worldY)
|
||||
|
|
@ -1280,7 +1594,7 @@ export class SceneMouseController {
|
|||
if (clickedMonster !== null) {
|
||||
this.activeClickType = 'monster'
|
||||
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||
const range = getSkillRange(leftSkillId)
|
||||
const range = getSkillRange(leftSkillId, this.hudManager)
|
||||
const distToPlayer = Math.hypot(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
||||
if (distToPlayer <= range) {
|
||||
this.navTarget = null
|
||||
|
|
@ -1288,7 +1602,7 @@ export class SceneMouseController {
|
|||
player.facing = facingOf(clickedMonster.x - player.x, clickedMonster.y - player.y)
|
||||
this.castSkill(leftSkillId, clickedMonster.x, clickedMonster.y)
|
||||
} else {
|
||||
this.pendingInteraction = { kind: 'monster', monster: clickedMonster }
|
||||
this.pendingInteraction = { kind: 'monster', monster: clickedMonster, skillId: leftSkillId }
|
||||
this.navTarget = { x: clickedMonster.x, y: clickedMonster.y }
|
||||
}
|
||||
return
|
||||
|
|
@ -1318,7 +1632,16 @@ export class SceneMouseController {
|
|||
})
|
||||
}
|
||||
|
||||
castSkill(skillId: number, targetX: number, targetY: number): boolean {
|
||||
castSkill(
|
||||
skillId: number,
|
||||
targetX: number,
|
||||
targetY: number,
|
||||
options?: {
|
||||
equippedWeapon?: any
|
||||
equippedAmmo?: any
|
||||
hasAmmo?: boolean
|
||||
}
|
||||
): boolean {
|
||||
return castSkill(skillId, targetX, targetY, {
|
||||
engine: this.engine,
|
||||
runtime: this.getRuntime(),
|
||||
|
|
@ -1326,6 +1649,9 @@ export class SceneMouseController {
|
|||
status: this.status,
|
||||
playerAnimator: this.playerAnimator,
|
||||
character: this.getCharacter ? this.getCharacter() : undefined,
|
||||
equippedWeapon: options?.equippedWeapon,
|
||||
equippedAmmo: options?.equippedAmmo,
|
||||
hasAmmo: options?.hasAmmo,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -1379,7 +1705,10 @@ export class SceneMouseController {
|
|||
}
|
||||
} else if (this.isRightMouseDown && this.lastClientPos !== null) {
|
||||
const rightSkillId = this.hudManager ? this.hudManager.hotkeys.rightSkillId : 64
|
||||
if (rightSkillId === 0 || isPassiveSkill(rightSkillId)) {
|
||||
if (isPassiveSkill(rightSkillId)) {
|
||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
|
||||
this.navTarget = { x: worldX, y: worldY }
|
||||
} else if (rightSkillId === 0 && !this.input.shiftHeld && this.activeClickType === 'ground') {
|
||||
const { x: worldX, y: worldY } = this.getPointerWorldCoords(this.lastClientPos.x, this.lastClientPos.y)
|
||||
this.navTarget = { x: worldX, y: worldY }
|
||||
} else if (player.cooldown === 0) {
|
||||
|
|
@ -1437,14 +1766,14 @@ export class SceneMouseController {
|
|||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
} else {
|
||||
const leftSkillId = this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47
|
||||
const range = getSkillRange(leftSkillId)
|
||||
const skillId = this.pendingInteraction.skillId ?? (this.hudManager ? this.hudManager.hotkeys.leftSkillId : 47)
|
||||
const range = getSkillRange(skillId, this.hudManager)
|
||||
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
|
||||
if (dist <= range) {
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
player.facing = facingOf(monster.x - player.x, monster.y - player.y)
|
||||
this.castSkill(leftSkillId, monster.x, monster.y)
|
||||
this.castSkill(skillId, monster.x, monster.y)
|
||||
} else {
|
||||
this.navTarget = { x: monster.x, y: monster.y }
|
||||
}
|
||||
|
|
@ -2653,24 +2982,7 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
|||
state.character = true
|
||||
state.characterMembers = nextChar.members
|
||||
if (hudManager !== null) {
|
||||
if (hudManager.charSheet) {
|
||||
hudManager.charSheet.attrs.name = nextInfo.charIdentity.name
|
||||
hudManager.charSheet.attrs.classNameZh = nextInfo.charIdentity.classNameZh
|
||||
hudManager.charSheet.attrs.classNameEn = nextInfo.charIdentity.classNameEn
|
||||
hudManager.charSheet.attrs.str = nextInfo.charIdentity.str
|
||||
hudManager.charSheet.attrs.dex = nextInfo.charIdentity.dex
|
||||
hudManager.charSheet.attrs.vit = nextInfo.charIdentity.vit
|
||||
hudManager.charSheet.attrs.ene = nextInfo.charIdentity.ene
|
||||
hudManager.charSheet.attrs.baseMaxHp = nextInfo.charIdentity.baseMaxHp
|
||||
hudManager.charSheet.attrs.baseMaxMana = nextInfo.charIdentity.baseMaxMana
|
||||
}
|
||||
hudManager.maxHp = nextInfo.charIdentity.baseMaxHp
|
||||
hudManager.hp = nextInfo.charIdentity.baseMaxHp
|
||||
hudManager.maxMana = nextInfo.charIdentity.baseMaxMana
|
||||
hudManager.mana = nextInfo.charIdentity.baseMaxMana
|
||||
hudManager.hotkeys.leftSkillId = nextInfo.defaultLeftSkill
|
||||
hudManager.hotkeys.rightSkillId = nextInfo.defaultRightSkill
|
||||
hudManager.syncPublishedState()
|
||||
hudManager.switchClass(nextInfo.code as CharacterClassCode)
|
||||
}
|
||||
if (heroSelect !== null && heroSelect.value !== nextToken) {
|
||||
heroSelect.value = nextToken
|
||||
|
|
@ -2871,6 +3183,8 @@ if (typeof window !== 'undefined') {
|
|||
if (hudManager !== null) {
|
||||
try {
|
||||
await hudManager.init('/ui')
|
||||
const initialHeroInfo = HERO_CLASSES[currentHeroToken] ?? HERO_CLASSES.so
|
||||
hudManager.switchClass(initialHeroInfo.code as CharacterClassCode)
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize HudManager:', err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,11 @@ import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
|
|||
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
|
||||
import { WorldPanelsHud, type LeftDockPanelKind } from './world-panels.ts'
|
||||
import { CursorController, type CursorState } from './cursor.ts'
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
import {
|
||||
createStarterProfileForClass,
|
||||
type ClassProfileState,
|
||||
} from '../game/class-starter-profiles.ts'
|
||||
|
||||
export interface PublishedHudState {
|
||||
ready: boolean
|
||||
|
|
@ -63,6 +68,7 @@ export interface PublishedHudState {
|
|||
maxMana: number
|
||||
stamina: number
|
||||
maxStamina: number
|
||||
docking: DockingLayout
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
|
@ -90,6 +96,17 @@ export interface HudLayout {
|
|||
readonly physicalOffsetY: number
|
||||
}
|
||||
|
||||
export interface DockingLayout extends HudLayout {
|
||||
marginW: number
|
||||
leftDockX: number
|
||||
rightDockX: number
|
||||
channelLeft: number
|
||||
channelRight: number
|
||||
channelWidthLogical: number
|
||||
channelWidthCss: number
|
||||
isWidescreen: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes 1:1 uniform aspect ratio scaling and horizontal centering margins
|
||||
* for the canonical 800x600 Diablo II HUD viewport onto an arbitrary window/canvas size.
|
||||
|
|
@ -113,6 +130,38 @@ export function computeHudLayout(cssW: number, cssH: number, dpr: number = 1): H
|
|||
}
|
||||
}
|
||||
|
||||
export { CHAR_PANEL_ORIGIN } from './character-sheet.ts'
|
||||
export { INV_PANEL_ORIGIN, INV_GRID_ORIGIN } from './inventory.ts'
|
||||
export { SKILL_PANEL_ORIGIN } from './skill-tree-panel.ts'
|
||||
export { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN } from './world-panels.ts'
|
||||
|
||||
/**
|
||||
* Computes widescreen panel docking layout metrics, margins, and central vision corridor.
|
||||
*/
|
||||
export function computeDockingLayout(cssW: number, cssH: number, dpr: number = 1): DockingLayout {
|
||||
const layout = computeHudLayout(cssW, cssH, dpr)
|
||||
const marginW = layout.uiScale > 0 ? layout.offsetX / layout.uiScale : 0
|
||||
const leftDockX = -marginW || 0
|
||||
const rightDockX = 400 + marginW
|
||||
const channelLeft = 400 - marginW
|
||||
const channelRight = 400 + marginW
|
||||
const channelWidthLogical = 2 * marginW
|
||||
const channelWidthCss = 2 * layout.offsetX
|
||||
const isWidescreen = marginW > 0
|
||||
|
||||
return {
|
||||
...layout,
|
||||
marginW,
|
||||
leftDockX,
|
||||
rightDockX,
|
||||
channelLeft,
|
||||
channelRight,
|
||||
channelWidthLogical,
|
||||
channelWidthCss,
|
||||
isWidescreen,
|
||||
}
|
||||
}
|
||||
|
||||
export function clientToLogical(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
|
|
@ -227,8 +276,12 @@ export class HudManager {
|
|||
maxMana: 940,
|
||||
stamina: 465,
|
||||
maxStamina: 465,
|
||||
docking: computeDockingLayout(800, 600, 1),
|
||||
}
|
||||
|
||||
currentClass: CharacterClassCode | null = null
|
||||
private classProfiles = new Map<CharacterClassCode, ClassProfileState>()
|
||||
|
||||
constructor(
|
||||
private readonly hudCanvas: HTMLCanvasElement,
|
||||
private readonly callbacks: {
|
||||
|
|
@ -238,6 +291,7 @@ export class HudManager {
|
|||
onDropItemToGround?: (name: string) => void
|
||||
},
|
||||
) {
|
||||
this.hotkeys.tooltipResolver = (skillId: number) => this.skillTree.getTooltipViewModel(skillId)
|
||||
if (this.hudCanvas) {
|
||||
this.hudCanvas.style.cursor = 'none'
|
||||
}
|
||||
|
|
@ -247,6 +301,84 @@ export class HudManager {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches active character class with complete isolation and persistence across
|
||||
* inventory, exclusive signature sets on body ONLY, stash treasures (runes/gems/jewels/gold),
|
||||
* attributes, 30-skill trees, and hotkeys (Parity with Issue #379).
|
||||
*/
|
||||
switchClass(classCode: CharacterClassCode): void {
|
||||
// 1. Snapshot current class state if currentClass is already initialized
|
||||
if (this.currentClass && this.classProfiles.has(this.currentClass)) {
|
||||
const currentProfile: ClassProfileState = {
|
||||
classCode: this.currentClass,
|
||||
equipped: { ...this.inventory.equipped },
|
||||
bagItems: this.inventory.gridItems.map(gp => ({ ...gp, item: { ...gp.item } })),
|
||||
stashItems: this.worldPanels.stashItems.map(gp => ({ ...gp, item: { ...gp.item } })),
|
||||
stashGold: this.worldPanels.stashGold,
|
||||
attrs: { ...this.charSheet.attrs },
|
||||
hardPoints: new Map(this.skillTree.hardPoints),
|
||||
unspentSkillPoints: this.skillTree.unspentSkillPoints,
|
||||
activeSkillTab: this.skillTree.activeTab,
|
||||
defaultLeftSkill: this.hotkeys.leftSkillId,
|
||||
defaultRightSkill: this.hotkeys.rightSkillId,
|
||||
availableSkills: [...this.hotkeys.availableSkills],
|
||||
hp: this.hp,
|
||||
mana: this.mana,
|
||||
}
|
||||
this.classProfiles.set(this.currentClass, currentProfile)
|
||||
}
|
||||
|
||||
// 2. Fetch target profile or create fresh starter profile
|
||||
let targetProfile = this.classProfiles.get(classCode)
|
||||
if (!targetProfile) {
|
||||
targetProfile = createStarterProfileForClass(classCode)
|
||||
this.classProfiles.set(classCode, targetProfile)
|
||||
}
|
||||
|
||||
// 3. Apply profile to current systems
|
||||
this.currentClass = classCode
|
||||
|
||||
// 3.1 Inventory & Equipment: Body has ONLY exclusive set items
|
||||
for (const key of Object.keys(this.inventory.equipped) as EquipSlotId[]) {
|
||||
delete this.inventory.equipped[key]
|
||||
}
|
||||
Object.assign(this.inventory.equipped, targetProfile.equipped)
|
||||
this.inventory.gridItems = targetProfile.bagItems.map(gp => ({ ...gp, item: { ...gp.item } }))
|
||||
|
||||
// 3.2 Stash: gems, runes, jewels, and gold
|
||||
this.worldPanels.stashItems = targetProfile.stashItems.map(gp => ({ ...gp, item: { ...gp.item } }))
|
||||
this.worldPanels.stashGold = targetProfile.stashGold
|
||||
|
||||
// 3.3 Character Sheet
|
||||
this.charSheet.attrs = { ...targetProfile.attrs }
|
||||
|
||||
// 3.4 Skill Tree
|
||||
this.skillTree.loadProfile(
|
||||
targetProfile.classCode,
|
||||
targetProfile.hardPoints,
|
||||
targetProfile.unspentSkillPoints,
|
||||
targetProfile.activeSkillTab,
|
||||
)
|
||||
|
||||
// 3.5 Hotkeys
|
||||
this.hotkeys.setAvailableSkills(
|
||||
targetProfile.availableSkills,
|
||||
targetProfile.defaultLeftSkill,
|
||||
targetProfile.defaultRightSkill,
|
||||
)
|
||||
this.hotkeys.hasEquippedWeapon = Boolean(this.inventory.equipped.weapon1 || this.inventory.equipped.weapon2)
|
||||
|
||||
// 3.6 Published vitals
|
||||
this.maxHp = targetProfile.attrs.baseMaxHp
|
||||
this.hp = Math.min(targetProfile.hp, this.maxHp)
|
||||
this.maxMana = targetProfile.attrs.baseMaxMana
|
||||
this.mana = Math.min(targetProfile.mana, this.maxMana)
|
||||
this.stamina = targetProfile.attrs.baseMaxStamina
|
||||
this.maxStamina = targetProfile.attrs.baseMaxStamina
|
||||
|
||||
this.syncPublishedState()
|
||||
}
|
||||
|
||||
async init(baseUrl = '/ui'): Promise<void> {
|
||||
this.hotkeys.preloadSkillIcons()
|
||||
this.skillTree.preloadAssets()
|
||||
|
|
@ -292,6 +424,10 @@ export class HudManager {
|
|||
levelBtn: `${baseUrl}/level-btn.png`,
|
||||
levelSocket: `${baseUrl}/level-socket.png`,
|
||||
skillPoints: `${baseUrl}/skillpoints.png`,
|
||||
skillTabs: `${baseUrl}/skill-tree-tabs.png`,
|
||||
waypointTabs: `${baseUrl}/waypoint-tabs.png`,
|
||||
waypointIcons: `${baseUrl}/waypoint-icons.png`,
|
||||
questTabs: `${baseUrl}/quest-tabs.png`,
|
||||
genericSkills: `${baseUrl}/generic-skills.png`,
|
||||
cursorHand: `${baseUrl}/cursor-hand.png`,
|
||||
cursorAtlas: `${baseUrl}/cursor.png`,
|
||||
|
|
@ -386,24 +522,42 @@ export class HudManager {
|
|||
return logicalToClient(logicalX, logicalY, this.hudCanvas.getBoundingClientRect())
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current widescreen panel docking layout metrics.
|
||||
*/
|
||||
getDockingLayout(): DockingLayout {
|
||||
const hasWindow = typeof window !== 'undefined'
|
||||
const dpr = Math.min(hasWindow && window.devicePixelRatio ? window.devicePixelRatio : 1, 2)
|
||||
const rect = this.hudCanvas?.getBoundingClientRect?.()
|
||||
const canvasW = this.hudCanvas?.clientWidth || rect?.width || (this.hudCanvas?.width ?? 0)
|
||||
const canvasH = this.hudCanvas?.clientHeight || rect?.height || (this.hudCanvas?.height ?? 0)
|
||||
const winW = hasWindow && window.innerWidth ? window.innerWidth : 800
|
||||
const winH = hasWindow && window.innerHeight ? window.innerHeight : 600
|
||||
const cssW = Math.max(320, canvasW || winW)
|
||||
const cssH = Math.max(240, canvasH || winH)
|
||||
return computeDockingLayout(cssW, cssH, dpr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a logical `(x, y)` point hits an active HUD panel or control bar,
|
||||
* so scene clicks/walks underneath are suppressed.
|
||||
*
|
||||
* Out-of-bounds coordinates (< 0 or > 800 / 600) fall in the letterbox/pillarbox
|
||||
* widescreen margins and must return false so scene navigation is not blocked.
|
||||
* Out-of-bounds coordinates (< -marginW or > 800 + marginW / 600) fall outside the canvas
|
||||
* and must return false so scene navigation is not blocked.
|
||||
* Central vision corridor (400 - marginW < logicalX < 400 + marginW) remains open and returns false.
|
||||
*/
|
||||
isPointInterceptedByHud(logicalX: number, logicalY: number): boolean {
|
||||
if (logicalX < 0 || logicalX > 800 || logicalY < 0 || logicalY > 600) return false
|
||||
if (logicalY >= 540) return true
|
||||
if (logicalX <= 117 && logicalY >= 496) return true
|
||||
if (logicalX >= 683 && logicalY >= 496) return true
|
||||
const docking = this.getDockingLayout()
|
||||
const { marginW } = docking
|
||||
if (logicalX < -marginW || logicalX > 800 + marginW || logicalY < 0 || logicalY > 600) return false
|
||||
if (logicalY >= 540 && logicalX >= 0 && logicalX <= 800) return true
|
||||
if (logicalX >= 0 && logicalX <= 117 && logicalY >= 496) return true
|
||||
if (logicalX >= 683 && logicalX <= 800 && logicalY >= 496) return true
|
||||
if (this.controlBar.miniPanelOpen && logicalX >= 312 && logicalX <= 488 && logicalY >= 522) return true
|
||||
if (this.belt.isExpanded && logicalX >= 420 && logicalX <= 550 && logicalY >= 460) return true
|
||||
if (this.hotkeys.openPopup !== null) return true
|
||||
if (this.leftPanel !== 'none' && logicalX <= 400 && logicalY <= 553) return true
|
||||
if (this.rightPanel !== 'none' && logicalX >= 400 && logicalY <= 553) return true
|
||||
if (this.leftPanel !== 'none' && logicalX >= -marginW && logicalX <= 400 - marginW && logicalY <= 553) return true
|
||||
if (this.rightPanel !== 'none' && logicalX >= 400 + marginW && logicalX <= 800 + marginW && logicalY <= 553) return true
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -481,12 +635,23 @@ export class HudManager {
|
|||
this.belt.handleMouseMove(pt.x, pt.y)
|
||||
this.hotkeys.handleMouseMove(pt.x, pt.y)
|
||||
this.controlBar.handleMouseMove(pt.x, pt.y)
|
||||
this.inventory.handleMouseMove(pt.x, pt.y)
|
||||
this.skillTree.handleMouseMove(pt.x, pt.y)
|
||||
|
||||
const docking = this.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
this.inventory.handleMouseMove(pt.x - deltaRight, pt.y)
|
||||
if (this.inventory.hoveredItem) {
|
||||
this.inventory.hoveredItem.x += deltaRight
|
||||
}
|
||||
this.skillTree.handleMouseMove(pt.x - deltaRight, pt.y)
|
||||
if (this.leftPanel === 'stash') {
|
||||
this.worldPanels.handleMouseMove(pt.x, pt.y)
|
||||
this.worldPanels.handleMouseMove(pt.x - deltaLeft, pt.y)
|
||||
if (this.worldPanels.hoveredStashItem) {
|
||||
this.inventory.hoveredItem = this.worldPanels.hoveredStashItem
|
||||
this.inventory.hoveredItem = {
|
||||
...this.worldPanels.hoveredStashItem,
|
||||
x: this.worldPanels.hoveredStashItem.x + deltaLeft,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -559,10 +724,15 @@ export class HudManager {
|
|||
return
|
||||
}
|
||||
|
||||
// Left-Half Dock Click (`0..400`)
|
||||
if (pt.x <= 400) {
|
||||
const docking = this.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
// Left-Half Dock Click
|
||||
if (pt.x <= 400 + deltaLeft) {
|
||||
const leftX = pt.x - deltaLeft
|
||||
if (this.leftPanel === 'char') {
|
||||
this.charSheet.handleClick(pt.x, pt.y, (key: BaseStatKey) => {
|
||||
this.charSheet.handleClick(leftX, pt.y, (key: BaseStatKey) => {
|
||||
if (key === 'vit') {
|
||||
this.maxHp = this.charSheet.attrs.baseMaxHp
|
||||
this.hp = this.maxHp
|
||||
|
|
@ -577,7 +747,7 @@ export class HudManager {
|
|||
this.syncPublishedState()
|
||||
return
|
||||
} else if (this.leftPanel !== 'none') {
|
||||
this.worldPanels.handleLeftDockClick(this.leftPanel, pt.x, pt.y, {
|
||||
this.worldPanels.handleLeftDockClick(this.leftPanel, leftX, pt.y, {
|
||||
onClose: () => {
|
||||
this.leftPanel = 'none'
|
||||
},
|
||||
|
|
@ -593,17 +763,18 @@ export class HudManager {
|
|||
}
|
||||
}
|
||||
|
||||
// Right-Half Dock Click (`400..800`)
|
||||
if (pt.x >= 400) {
|
||||
// Right-Half Dock Click
|
||||
if (pt.x >= 400 + deltaRight) {
|
||||
const rightX = pt.x - deltaRight
|
||||
if (this.rightPanel === 'inv') {
|
||||
if (this.leftPanel === 'stash' && (e.shiftKey || e.button === 2)) {
|
||||
if (
|
||||
pt.x >= INV_GRID_ORIGIN.x &&
|
||||
pt.x < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||
rightX >= INV_GRID_ORIGIN.x &&
|
||||
rightX < INV_GRID_ORIGIN.x + INV_GRID_ORIGIN.cols * INV_GRID_ORIGIN.cellPx &&
|
||||
pt.y >= INV_GRID_ORIGIN.y &&
|
||||
pt.y < INV_GRID_ORIGIN.y + INV_GRID_ORIGIN.rows * INV_GRID_ORIGIN.cellPx
|
||||
) {
|
||||
const col = Math.floor((pt.x - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||
const col = Math.floor((rightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||
const row = Math.floor((pt.y - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||
const hit = this.inventory.gridItems.find(
|
||||
p => col >= p.col && col < p.col + p.item.invWidth && row >= p.row && row < p.row + p.item.invHeight,
|
||||
|
|
@ -615,7 +786,7 @@ export class HudManager {
|
|||
}
|
||||
}
|
||||
}
|
||||
this.inventory.handleClick(pt.x, pt.y, {
|
||||
this.inventory.handleClick(rightX, pt.y, {
|
||||
onOpenCube: () => this.toggleLeftPanel('cube'),
|
||||
onCastTownPortal: () => this.callbacks.onCastTownPortal?.(),
|
||||
})
|
||||
|
|
@ -623,7 +794,7 @@ export class HudManager {
|
|||
this.syncPublishedState()
|
||||
return
|
||||
} else if (this.rightPanel === 'skill') {
|
||||
this.skillTree.handleClick(pt.x, pt.y)
|
||||
this.skillTree.handleClick(rightX, pt.y)
|
||||
if (!this.skillTree.visible) this.rightPanel = 'none'
|
||||
this.syncPublishedState()
|
||||
return
|
||||
|
|
@ -683,6 +854,7 @@ export class HudManager {
|
|||
this.state.maxMana = Math.round(this.maxMana)
|
||||
this.state.stamina = Math.round(this.stamina)
|
||||
this.state.maxStamina = Math.round(this.maxStamina)
|
||||
this.state.docking = this.getDockingLayout()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -709,7 +881,10 @@ export class HudManager {
|
|||
this.hudCanvas.height = targetH
|
||||
}
|
||||
|
||||
const { uiScale, offsetX, offsetY } = computeHudLayout(cssW, cssH)
|
||||
const docking = computeDockingLayout(cssW, cssH, dpr)
|
||||
const { uiScale, offsetX, offsetY, marginW } = docking
|
||||
const deltaLeft = -marginW
|
||||
const deltaRight = marginW
|
||||
|
||||
// Enable pointer events on HUD canvas only when hovering HUD/Panels or holding cursorItem
|
||||
const intercept =
|
||||
|
|
@ -730,6 +905,8 @@ export class HudManager {
|
|||
const rightNode = SORCERESS_SKILL_TREE.find(n => n.skillId === this.hotkeys.rightSkillId)
|
||||
const lEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.leftSkillId))
|
||||
const rEff = Math.max(1, this.skillTree.getEffectiveLevel(this.hotkeys.rightSkillId))
|
||||
ctx.save()
|
||||
ctx.translate(deltaLeft, 0)
|
||||
this.charSheet.draw(
|
||||
ctx,
|
||||
{
|
||||
|
|
@ -754,7 +931,10 @@ export class HudManager {
|
|||
},
|
||||
this.font,
|
||||
)
|
||||
ctx.restore()
|
||||
} else if (this.leftPanel !== 'none') {
|
||||
ctx.save()
|
||||
ctx.translate(deltaLeft, 0)
|
||||
this.worldPanels.drawLeftDockPanel(
|
||||
ctx,
|
||||
this.leftPanel,
|
||||
|
|
@ -768,13 +948,19 @@ export class HudManager {
|
|||
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
||||
questsAtlasImg: this.images.get('questsAtlas') ?? null,
|
||||
itemsAtlasImg: this.images.get('itemsAtlas') ?? null,
|
||||
waypointTabsImg: this.images.get('waypointTabs') ?? null,
|
||||
waypointIconsImg: this.images.get('waypointIcons') ?? null,
|
||||
questTabsImg: this.images.get('questTabs') ?? null,
|
||||
},
|
||||
this.font,
|
||||
)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 2. Right-Half Dock Panel (`400..800, 0..553`)
|
||||
if (this.rightPanel === 'inv') {
|
||||
ctx.save()
|
||||
ctx.translate(deltaRight, 0)
|
||||
this.inventory.draw(
|
||||
ctx,
|
||||
{
|
||||
|
|
@ -788,16 +974,21 @@ export class HudManager {
|
|||
},
|
||||
this.font,
|
||||
)
|
||||
ctx.restore()
|
||||
} else if (this.rightPanel === 'skill') {
|
||||
ctx.save()
|
||||
ctx.translate(deltaRight, 0)
|
||||
this.skillTree.draw(
|
||||
ctx,
|
||||
{
|
||||
borderRightImg: this.images.get('borderRight') ?? null,
|
||||
skillPointsImg: this.images.get('skillPoints') ?? null,
|
||||
skillTabsImg: this.images.get('skillTabs') ?? null,
|
||||
buySellBtnImg: this.images.get('buySellBtn') ?? null,
|
||||
},
|
||||
this.font,
|
||||
)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// 3. Bottom 800px Control Panel (`800CtrlPnl7.dc6`)
|
||||
|
|
@ -836,7 +1027,12 @@ export class HudManager {
|
|||
)
|
||||
|
||||
// 5. Left & Right Active Skill Slots & Speedbar Popup (`spellsel.cpp`)
|
||||
this.hotkeys.draw(ctx, this.images.get('genericSkills') ?? null, this.font)
|
||||
this.hotkeys.draw(
|
||||
ctx,
|
||||
this.images.get('genericSkills') ?? null,
|
||||
this.font,
|
||||
Boolean(this.inventory.equipped.weapon1),
|
||||
)
|
||||
|
||||
// 6. 4x4 Belt & Pop-up Belt (`ctrlpnl_popbelt.dc6`)
|
||||
this.belt.draw(
|
||||
|
|
|
|||
|
|
@ -103,6 +103,8 @@ export interface ResolvableItem {
|
|||
readonly invFile?: string | undefined
|
||||
readonly invtransform?: string | undefined
|
||||
readonly chrtransform?: string | undefined
|
||||
readonly variant?: number | undefined
|
||||
readonly id?: string | undefined
|
||||
readonly invWidth?: number | undefined
|
||||
readonly invHeight?: number | undefined
|
||||
readonly width?: number | undefined
|
||||
|
|
@ -125,6 +127,10 @@ export function resolveItemSpriteRect(
|
|||
// Tier 0.5: Authentic Diablo II 1.13c Charm Resolution (cm1, cm2, cm3, Annihilus, Torch, Gheed's)
|
||||
if (item.code === 'cm1' || item.code === 'anni' || item.name?.includes('Annihilus')) {
|
||||
const validSmall = ['invch1', 'invch4', 'invch7', 'invmss']
|
||||
if (item.variant !== undefined && !item.name?.includes('Annihilus') && item.code !== 'anni') {
|
||||
const v = ['invch1', 'invch4', 'invch7'][Math.abs(Math.floor(item.variant)) % 3]
|
||||
if (itemRects[v]) return itemRects[v]
|
||||
}
|
||||
const file = item.invFile?.toLowerCase()
|
||||
const direct = file && validSmall.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
|
||||
if (item.code === 'anni' || item.name?.includes('Annihilus')) {
|
||||
|
|
@ -134,6 +140,10 @@ export function resolveItemSpriteRect(
|
|||
}
|
||||
if (item.code === 'cm2' || item.code === 'torch' || item.name?.includes('Torch')) {
|
||||
const validLarge = ['invch2', 'invch5', 'invch8', 'invtrch']
|
||||
if (item.variant !== undefined && !item.name?.includes('Torch') && item.code !== 'torch') {
|
||||
const v = ['invch2', 'invch5', 'invch8'][Math.abs(Math.floor(item.variant)) % 3]
|
||||
if (itemRects[v]) return itemRects[v]
|
||||
}
|
||||
const file = item.invFile?.toLowerCase()
|
||||
const direct = file && validLarge.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
|
||||
if (item.code === 'torch' || item.name?.includes('Torch')) {
|
||||
|
|
@ -143,11 +153,85 @@ export function resolveItemSpriteRect(
|
|||
}
|
||||
if (item.code === 'cm3' || item.code === 'gheeds' || item.name?.includes('Gheed')) {
|
||||
const validGrand = ['invch3', 'invch6', 'invch9']
|
||||
if (item.variant !== undefined) {
|
||||
const v = validGrand[Math.abs(Math.floor(item.variant)) % validGrand.length]
|
||||
if (itemRects[v]) return itemRects[v]
|
||||
}
|
||||
const file = item.invFile?.toLowerCase()
|
||||
const direct = file && validGrand.includes(file) ? (itemRects[item.invFile!] ?? itemRects[file]) : undefined
|
||||
return direct ?? itemRects['invch3'] ?? itemRects['invch6'] ?? itemRects['invch9'] ?? null
|
||||
}
|
||||
|
||||
// Tier 0.6: Authentic Diablo II 1.13c ItemTypes.txt VarInvGfx (Ring: 5, Amulet: 3, Jewel: 6)
|
||||
if (item.code === 'rin' || item.code === 'ring' || (item.invFile && /^invrin\d?$/i.test(item.invFile))) {
|
||||
const ringVariants = ['invrin1', 'invrin2', 'invrin3', 'invrin4', 'invrin5']
|
||||
if (item.variant !== undefined) {
|
||||
const idx = Math.abs(Math.floor(item.variant)) % ringVariants.length
|
||||
const key = ringVariants[idx]
|
||||
if (itemRects[key]) return itemRects[key]
|
||||
}
|
||||
if (item.invFile && item.invFile.toLowerCase() !== 'invrin' && itemRects[item.invFile]) {
|
||||
return itemRects[item.invFile]
|
||||
}
|
||||
if (item.id) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < item.id.length; i++) {
|
||||
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
|
||||
}
|
||||
const idx = Math.abs(hash) % ringVariants.length
|
||||
const key = ringVariants[idx]
|
||||
if (itemRects[key]) return itemRects[key]
|
||||
}
|
||||
return itemRects['invrin1'] ?? itemRects['invrin'] ?? null
|
||||
}
|
||||
|
||||
if (item.code === 'amu' || item.code === 'amul' || (item.invFile && /^invamu\d?$/i.test(item.invFile))) {
|
||||
const amuVariants = ['invamu1', 'invamu2', 'invamu3']
|
||||
if (item.variant !== undefined) {
|
||||
const idx = Math.abs(Math.floor(item.variant)) % amuVariants.length
|
||||
const key = amuVariants[idx]
|
||||
if (itemRects[key]) return itemRects[key]
|
||||
}
|
||||
if (item.invFile && item.invFile.toLowerCase() !== 'invamu' && itemRects[item.invFile]) {
|
||||
return itemRects[item.invFile]
|
||||
}
|
||||
if (item.name && BAKED_UI_MANIFEST.codeToInvFile[item.name] && itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]) {
|
||||
return itemRects[BAKED_UI_MANIFEST.codeToInvFile[item.name]]
|
||||
}
|
||||
if (item.id) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < item.id.length; i++) {
|
||||
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
|
||||
}
|
||||
const idx = Math.abs(hash) % amuVariants.length
|
||||
const key = amuVariants[idx]
|
||||
if (itemRects[key]) return itemRects[key]
|
||||
}
|
||||
return itemRects['invamu1'] ?? itemRects['invamu'] ?? null
|
||||
}
|
||||
|
||||
if (item.code === 'jew' || item.code === 'jewl' || (item.invFile && /^(invjw|invjew)\d?$/i.test(item.invFile))) {
|
||||
const jewelVariants = ['invjw1', 'invjw2', 'invjw3', 'invjw4', 'invjw5', 'invjw6']
|
||||
if (item.variant !== undefined) {
|
||||
const idx = Math.abs(Math.floor(item.variant)) % jewelVariants.length
|
||||
const key = jewelVariants[idx]
|
||||
if (itemRects[key]) return itemRects[key]
|
||||
}
|
||||
if (item.invFile && itemRects[item.invFile]) {
|
||||
return itemRects[item.invFile]
|
||||
}
|
||||
if (item.id) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < item.id.length; i++) {
|
||||
hash = ((hash << 5) - hash + item.id.charCodeAt(i)) | 0
|
||||
}
|
||||
const idx = Math.abs(hash) % jewelVariants.length
|
||||
const key = jewelVariants[idx]
|
||||
if (itemRects[key]) return itemRects[key]
|
||||
}
|
||||
return itemRects['invjw1'] ?? null
|
||||
}
|
||||
|
||||
// Tier 1: Direct invFile (ignore misc.txt placeholder invfiles for charms)
|
||||
const isCharmPlaceholder =
|
||||
(item.code === 'cm3' && item.invFile?.toLowerCase() === 'invsst') ||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,21 @@
|
|||
* point allocation, prerequisite checks, and rich hover tooltips.
|
||||
*/
|
||||
|
||||
import type { D2FontRenderer } from './font.ts'
|
||||
import type { D2FontRenderer, D2ColorCode } from './font.ts'
|
||||
import type { CharacterClassCode } from '../game/classes.ts'
|
||||
import { SKILLS_CATALOG } from '../data/skills-catalog.ts'
|
||||
import {
|
||||
buildTooltipViewModel,
|
||||
type TooltipViewModel,
|
||||
type SkillCalcState,
|
||||
} from '../game/skill-calc-engine.ts'
|
||||
|
||||
export interface FormattedTooltipLine {
|
||||
readonly text: string
|
||||
readonly color: D2ColorCode
|
||||
readonly font: 'fontexocet10' | 'font8'
|
||||
readonly align?: 'left' | 'center'
|
||||
}
|
||||
|
||||
export interface SkillNodeDef {
|
||||
readonly skillId: number
|
||||
|
|
@ -29,6 +43,10 @@ export interface SkillNodeDef {
|
|||
export const SKILL_PANEL_ORIGIN = { x: 400, y: 60, width: 320, height: 432 } as const
|
||||
export const SKILL_CLOSE_BTN_BOUNDS = { x: 400 + 272, y: 60 + 388, w: 32, h: 32 } as const
|
||||
|
||||
/** Authentic D2Client.dll 1.13c ground truth grid offsets inside panel (ox=400, oy=60). */
|
||||
export const SKILL_TREE_COL_OFFSETS_X = [15, 84, 153] as const
|
||||
export const SKILL_TREE_ROW_OFFSETS_Y = [14, 82, 150, 218, 287, 355] as const
|
||||
|
||||
/** Authentic Sorceress 30 skills (`Skills.txt` / `SkillDesc.txt` row & column coordinates). */
|
||||
export const SORCERESS_SKILL_TREE: readonly SkillNodeDef[] = [
|
||||
// Tab 2: Cold Spells (Top vertical tab in D2 UI)
|
||||
|
|
@ -68,12 +86,93 @@ export const SORCERESS_SKILL_TREE: readonly SkillNodeDef[] = [
|
|||
{ skillId: 62, tab: 0, row: 6, col: 3, reqLevel: 30, prereqs: [52], nameEn: 'Hydra', nameZh: '九头海蛇', descZh: '召唤多头喷火巨蛇攻击周围敌人', baseMana: 20, baseDmgMin: 24, baseDmgMax: 32, dmgPerLevel: 18 },
|
||||
]
|
||||
|
||||
export interface SkillTreeTabInfo {
|
||||
readonly tab: 0 | 1 | 2
|
||||
readonly titleZh: string
|
||||
readonly subZh: string
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
export const CLASS_VERTICAL_TABS: Record<CharacterClassCode, readonly SkillTreeTabInfo[]> = {
|
||||
ama: [
|
||||
{ tab: 0, titleZh: '弓和', subZh: '十字弓', y: 95 },
|
||||
{ tab: 1, titleZh: '被动和', subZh: '魔法', y: 195 },
|
||||
{ tab: 2, titleZh: '标枪和', subZh: '长矛', y: 295 },
|
||||
],
|
||||
sor: [
|
||||
{ tab: 2, titleZh: '冰冷', subZh: '法术', y: 95 },
|
||||
{ tab: 1, titleZh: '闪电', subZh: '法术', y: 195 },
|
||||
{ tab: 0, titleZh: '火焰', subZh: '法术', y: 295 },
|
||||
],
|
||||
nec: [
|
||||
{ tab: 2, titleZh: '召唤', subZh: '法术', y: 95 },
|
||||
{ tab: 1, titleZh: '毒素和', subZh: '白骨', y: 195 },
|
||||
{ tab: 0, titleZh: '诅咒', subZh: '法术', y: 295 },
|
||||
],
|
||||
pal: [
|
||||
{ tab: 0, titleZh: '作战', subZh: '技能', y: 95 },
|
||||
{ tab: 1, titleZh: '攻击', subZh: '灵气', y: 195 },
|
||||
{ tab: 2, titleZh: '防御', subZh: '灵气', y: 295 },
|
||||
],
|
||||
bar: [
|
||||
{ tab: 0, titleZh: '战斗', subZh: '技能', y: 95 },
|
||||
{ tab: 1, titleZh: '战斗', subZh: '专家', y: 195 },
|
||||
{ tab: 2, titleZh: '呐喊', subZh: '技能', y: 295 },
|
||||
],
|
||||
dru: [
|
||||
{ tab: 2, titleZh: '元素', subZh: '技能', y: 95 },
|
||||
{ tab: 1, titleZh: '变形', subZh: '技能', y: 195 },
|
||||
{ tab: 0, titleZh: '召唤', subZh: '技能', y: 295 },
|
||||
],
|
||||
ass: [
|
||||
{ tab: 2, titleZh: '武学', subZh: '艺术', y: 95 },
|
||||
{ tab: 1, titleZh: '暗影', subZh: '武艺', y: 195 },
|
||||
{ tab: 0, titleZh: '陷阱', subZh: '技能', y: 295 },
|
||||
],
|
||||
}
|
||||
|
||||
function buildClassSkillTrees(): Record<CharacterClassCode, readonly SkillNodeDef[]> {
|
||||
const result: Record<CharacterClassCode, SkillNodeDef[]> = {
|
||||
ama: [],
|
||||
sor: [],
|
||||
nec: [],
|
||||
pal: [],
|
||||
bar: [],
|
||||
dru: [],
|
||||
ass: [],
|
||||
}
|
||||
for (const s of SKILLS_CATALOG) {
|
||||
const list = result[s.classCode]
|
||||
if (!list) continue
|
||||
list.push({
|
||||
skillId: s.id,
|
||||
tab: s.tabIndex as 0 | 1 | 2,
|
||||
row: s.row as 1 | 2 | 3 | 4 | 5 | 6,
|
||||
col: s.col as 1 | 2 | 3,
|
||||
reqLevel: s.reqlevel,
|
||||
prereqs: s.reqskills,
|
||||
nameEn: s.name,
|
||||
nameZh: s.nameZh,
|
||||
descZh: s.shortDescZh || s.longDescZh || '',
|
||||
baseMana: s.mana,
|
||||
baseDmgMin: s.minDam,
|
||||
baseDmgMax: s.maxDam,
|
||||
dmgPerLevel: 5,
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const CLASS_SKILL_TREES: Record<CharacterClassCode, readonly SkillNodeDef[]> = buildClassSkillTrees()
|
||||
|
||||
export class SkillTreePanel {
|
||||
visible = false
|
||||
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom). */
|
||||
/** Active skill tab: 2 = Cold (Top), 1 = Lightning (Middle), 0 = Fire (Bottom) for Sorceress. */
|
||||
activeTab: 0 | 1 | 2 = 2
|
||||
unspentSkillPoints = 12
|
||||
bonusAllSkills = 8
|
||||
characterClass: CharacterClassCode = 'sor'
|
||||
characterLevel = 85
|
||||
readonly hardPoints = new Map<number, number>([
|
||||
[39, 1], [40, 1], [44, 1], [45, 5], [55, 10], [59, 20], [64, 20], [65, 10],
|
||||
[38, 1], [42, 1], [43, 1], [49, 5], [54, 1],
|
||||
|
|
@ -83,21 +182,59 @@ export class SkillTreePanel {
|
|||
|
||||
private tabBgs = new Map<number, HTMLImageElement>()
|
||||
private skillIcons = new Map<number, HTMLImageElement>()
|
||||
private tabBgClass: CharacterClassCode = 'sor'
|
||||
|
||||
getSkillTree(): readonly SkillNodeDef[] {
|
||||
return CLASS_SKILL_TREES[this.characterClass] ?? SORCERESS_SKILL_TREE
|
||||
}
|
||||
|
||||
getVerticalTabs(): readonly SkillTreeTabInfo[] {
|
||||
return CLASS_VERTICAL_TABS[this.characterClass] ?? CLASS_VERTICAL_TABS.sor
|
||||
}
|
||||
|
||||
preloadAssets(): void {
|
||||
if (typeof Image === 'undefined') return
|
||||
for (const tab of [0, 1, 2]) {
|
||||
const bg = new Image()
|
||||
bg.src = `/skills/bg_sor_${tab}.png`
|
||||
this.tabBgs.set(tab, bg)
|
||||
if (!this.tabBgs.has(tab) || this.tabBgClass !== this.characterClass) {
|
||||
const bg = new Image()
|
||||
bg.src = `/skills/bg_${this.characterClass}_${tab}.png`
|
||||
this.tabBgs.set(tab, bg)
|
||||
}
|
||||
}
|
||||
for (const node of SORCERESS_SKILL_TREE) {
|
||||
const icon = new Image()
|
||||
icon.src = `/skills/icon_${node.skillId}.png`
|
||||
this.skillIcons.set(node.skillId, icon)
|
||||
this.tabBgClass = this.characterClass
|
||||
for (const node of this.getSkillTree()) {
|
||||
if (!this.skillIcons.has(node.skillId)) {
|
||||
const icon = new Image()
|
||||
icon.src = `/skills/icon_${node.skillId}.png`
|
||||
this.skillIcons.set(node.skillId, icon)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setClass(classCode: CharacterClassCode): void {
|
||||
this.characterClass = classCode
|
||||
const vTabs = this.getVerticalTabs()
|
||||
this.activeTab = vTabs[0]?.tab ?? 0
|
||||
this.preloadAssets()
|
||||
}
|
||||
|
||||
loadProfile(
|
||||
classCode: CharacterClassCode,
|
||||
hardPoints: Map<number, number>,
|
||||
unspent: number,
|
||||
activeTab?: 0 | 1 | 2,
|
||||
): void {
|
||||
this.characterClass = classCode
|
||||
this.hardPoints.clear()
|
||||
for (const [id, pts] of hardPoints.entries()) {
|
||||
this.hardPoints.set(id, pts)
|
||||
}
|
||||
this.unspentSkillPoints = unspent
|
||||
const vTabs = this.getVerticalTabs()
|
||||
this.activeTab = activeTab !== undefined ? activeTab : (vTabs[0]?.tab ?? 0)
|
||||
this.preloadAssets()
|
||||
}
|
||||
|
||||
getHardPoints(skillId: number): number {
|
||||
return this.hardPoints.get(skillId) ?? 0
|
||||
}
|
||||
|
|
@ -107,9 +244,27 @@ export class SkillTreePanel {
|
|||
return hard > 0 ? hard + this.bonusAllSkills : 0
|
||||
}
|
||||
|
||||
getSkillCalcState(): SkillCalcState {
|
||||
const hardPoints: Record<number, number> = {}
|
||||
for (const [id, pts] of this.hardPoints.entries()) {
|
||||
if (pts > 0) hardPoints[id] = pts
|
||||
}
|
||||
return {
|
||||
classCode: this.characterClass,
|
||||
hardPoints,
|
||||
tabSoftPoints: { 0: 0, 1: 0, 2: 0 },
|
||||
allSkillsSoftPoints: this.bonusAllSkills,
|
||||
specificSoftPoints: {},
|
||||
}
|
||||
}
|
||||
|
||||
getTooltipViewModel(skillId: number): TooltipViewModel | null {
|
||||
return buildTooltipViewModel(this.getSkillCalcState(), skillId, this.characterLevel)
|
||||
}
|
||||
|
||||
canAllocate(skillId: number, charLevel = 85): boolean {
|
||||
if (this.unspentSkillPoints <= 0) return false
|
||||
const node = SORCERESS_SKILL_TREE.find(n => n.skillId === skillId)
|
||||
const node = this.getSkillTree().find(n => n.skillId === skillId)
|
||||
if (!node) return false
|
||||
const cur = this.getHardPoints(skillId)
|
||||
if (cur >= 20) return false
|
||||
|
|
@ -125,15 +280,17 @@ export class SkillTreePanel {
|
|||
}
|
||||
|
||||
static nodeScreenPos(node: SkillNodeDef): { x: number; y: number; w: number; h: number } {
|
||||
const x = SKILL_PANEL_ORIGIN.x + 21 + (node.col - 1) * 69
|
||||
const y = SKILL_PANEL_ORIGIN.y + 18 + (node.row - 1) * 68
|
||||
const colIdx = Math.max(0, Math.min(2, node.col - 1))
|
||||
const rowIdx = Math.max(0, Math.min(5, node.row - 1))
|
||||
const x = SKILL_PANEL_ORIGIN.x + (SKILL_TREE_COL_OFFSETS_X[colIdx] ?? (15 + colIdx * 69))
|
||||
const y = SKILL_PANEL_ORIGIN.y + (SKILL_TREE_ROW_OFFSETS_Y[rowIdx] ?? (14 + rowIdx * 68))
|
||||
return { x, y, w: 48, h: 48 }
|
||||
}
|
||||
|
||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||
this.hoveredSkill = null
|
||||
if (!this.visible) return
|
||||
const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab)
|
||||
const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab)
|
||||
for (const node of tabNodes) {
|
||||
const r = SkillTreePanel.nodeScreenPos(node)
|
||||
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
|
||||
|
|
@ -160,22 +317,23 @@ export class SkillTreePanel {
|
|||
|
||||
// Right vertical tabs (`x = 656..720`)
|
||||
if (logicalX >= 656 && logicalX <= 720) {
|
||||
if (logicalY >= 60 + 95 && logicalY < 60 + 195) {
|
||||
this.activeTab = 2 // Cold
|
||||
const vTabs = this.getVerticalTabs()
|
||||
if (logicalY >= 60 + 95 && logicalY < 60 + 195 && vTabs[0]) {
|
||||
this.activeTab = vTabs[0].tab
|
||||
return true
|
||||
}
|
||||
if (logicalY >= 60 + 195 && logicalY < 60 + 295) {
|
||||
this.activeTab = 1 // Lightning
|
||||
if (logicalY >= 60 + 195 && logicalY < 60 + 295 && vTabs[1]) {
|
||||
this.activeTab = vTabs[1].tab
|
||||
return true
|
||||
}
|
||||
if (logicalY >= 60 + 295 && logicalY < 60 + 385) {
|
||||
this.activeTab = 0 // Fire
|
||||
if (logicalY >= 60 + 295 && logicalY < 60 + 385 && vTabs[2]) {
|
||||
this.activeTab = vTabs[2].tab
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Skill nodes
|
||||
const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab)
|
||||
const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab)
|
||||
for (const node of tabNodes) {
|
||||
const r = SkillTreePanel.nodeScreenPos(node)
|
||||
if (logicalX >= r.x && logicalX <= r.x + r.w && logicalY >= r.y && logicalY <= r.y + r.h) {
|
||||
|
|
@ -195,6 +353,7 @@ export class SkillTreePanel {
|
|||
borderRightImg: HTMLImageElement | null
|
||||
skillPointsImg: HTMLImageElement | null
|
||||
buySellBtnImg: HTMLImageElement | null
|
||||
skillTabsImg?: HTMLImageElement | null
|
||||
},
|
||||
font: D2FontRenderer,
|
||||
): void {
|
||||
|
|
@ -218,10 +377,18 @@ export class SkillTreePanel {
|
|||
}
|
||||
|
||||
// 3. Draw Right 64x432 Vertical Tab Column (`x = 656..720`)
|
||||
ctx.fillStyle = '#14110d'
|
||||
ctx.fillRect(ox + 256, oy, 64, 432)
|
||||
ctx.strokeStyle = '#5a4a32'
|
||||
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
|
||||
const verticalTabs = this.getVerticalTabs()
|
||||
const tabColIdx = verticalTabs.findIndex(vt => vt.tab === this.activeTab)
|
||||
const safeColIdx = tabColIdx >= 0 ? tabColIdx : 0
|
||||
|
||||
if (assets.skillTabsImg && assets.skillTabsImg.complete && assets.skillTabsImg.naturalWidth > 0) {
|
||||
ctx.drawImage(assets.skillTabsImg, safeColIdx * 64, 0, 64, 432, ox + 256, oy, 64, 432)
|
||||
} else {
|
||||
ctx.fillStyle = '#14110d'
|
||||
ctx.fillRect(ox + 256, oy, 64, 432)
|
||||
ctx.strokeStyle = '#5a4a32'
|
||||
ctx.strokeRect(ox + 256.5, oy + 0.5, 63, 431)
|
||||
}
|
||||
|
||||
// Remaining Skill Points Header Box
|
||||
font.drawText(ctx, '剩余技能点', ox + 288, oy + 26, {
|
||||
|
|
@ -239,19 +406,29 @@ export class SkillTreePanel {
|
|||
align: 'center',
|
||||
})
|
||||
|
||||
// 3 Vertical Tabs: Cold (2), Lightning (1), Fire (0)
|
||||
const verticalTabs: readonly { tab: 0 | 1 | 2; titleZh: string; subZh: string; y: number }[] = [
|
||||
{ tab: 2, titleZh: '冰冷', subZh: '法术', y: 95 },
|
||||
{ tab: 1, titleZh: '闪电', subZh: '法术', y: 195 },
|
||||
{ tab: 0, titleZh: '火焰', subZh: '法术', y: 295 },
|
||||
]
|
||||
// Remaining Skill Points Banner (Authentic D2 skillpoints.dc6 banner at bottom of tree)
|
||||
if (this.unspentSkillPoints > 0 && assets.skillPointsImg && assets.skillPointsImg.complete && assets.skillPointsImg.naturalWidth > 0) {
|
||||
const spX = ox + Math.round((256 - 135) / 2)
|
||||
const spY = oy + 395
|
||||
ctx.drawImage(assets.skillPointsImg, spX, spY, 135, 23)
|
||||
font.drawText(ctx, String(this.unspentSkillPoints), spX + 112, spY + 16, {
|
||||
font: 'font16',
|
||||
color: 'red',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// 3 Vertical Tabs for Active Class
|
||||
for (const vt of verticalTabs) {
|
||||
const active = this.activeTab === vt.tab
|
||||
ctx.fillStyle = active ? '#32281c' : '#1b1610'
|
||||
ctx.fillRect(ox + 258, oy + vt.y, 60, 96)
|
||||
ctx.strokeStyle = active ? '#e8c26b' : '#4a3c28'
|
||||
ctx.lineWidth = active ? 2 : 1
|
||||
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
|
||||
if (!assets.skillTabsImg || !assets.skillTabsImg.complete || assets.skillTabsImg.naturalWidth === 0) {
|
||||
// Authentic stone-beveled fallback when texture atlas is unavailable
|
||||
ctx.fillStyle = active ? '#382e22' : '#1b1610'
|
||||
ctx.fillRect(ox + 258, oy + vt.y, 60, 96)
|
||||
ctx.strokeStyle = active ? '#c8a15a' : '#4a3c28'
|
||||
ctx.lineWidth = active ? 2 : 1
|
||||
ctx.strokeRect(ox + 258.5, oy + vt.y + 0.5, 59, 95)
|
||||
}
|
||||
font.drawText(ctx, vt.titleZh, ox + 288, oy + vt.y + 42, {
|
||||
font: 'fontexocet10',
|
||||
color: active ? 'gold' : 'tan',
|
||||
|
|
@ -265,7 +442,7 @@ export class SkillTreePanel {
|
|||
}
|
||||
|
||||
// 4. Draw Skill Nodes & Level Sockets on Active Tab
|
||||
const tabNodes = SORCERESS_SKILL_TREE.filter(n => n.tab === this.activeTab)
|
||||
const tabNodes = this.getSkillTree().filter(n => n.tab === this.activeTab)
|
||||
for (const node of tabNodes) {
|
||||
const pos = SkillTreePanel.nodeScreenPos(node)
|
||||
const hard = this.getHardPoints(node.skillId)
|
||||
|
|
@ -317,51 +494,259 @@ export class SkillTreePanel {
|
|||
|
||||
// 6. Hover Tooltip for Skill Node
|
||||
if (this.hoveredSkill) {
|
||||
const s = this.hoveredSkill
|
||||
const pos = SkillTreePanel.nodeScreenPos(s)
|
||||
const eff = Math.max(1, this.getEffectiveLevel(s.skillId))
|
||||
const hard = this.getHardPoints(s.skillId)
|
||||
const minDmg = s.baseDmgMin + (eff - 1) * s.dmgPerLevel
|
||||
const maxDmg = s.baseDmgMax + (eff - 1) * Math.round(s.dmgPerLevel * 1.25)
|
||||
const tw = 240
|
||||
const th = 112
|
||||
const tx = Math.max(404, Math.min(800 - tw - 8, pos.x - 80))
|
||||
const ty = Math.max(12, pos.y - th - 6)
|
||||
this.drawSkillTooltip(ctx, font)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(6, 6, 6, 0.94)'
|
||||
ctx.fillRect(tx, ty, tw, th)
|
||||
ctx.strokeStyle = '#c8a15a'
|
||||
ctx.strokeRect(tx + 0.5, ty + 0.5, tw - 1, th - 1)
|
||||
static formatSkillTooltipLines(vm: TooltipViewModel): FormattedTooltipLine[] {
|
||||
const lines: FormattedTooltipLine[] = []
|
||||
|
||||
font.drawText(ctx, `${s.nameZh} (${s.nameEn})`, tx + tw / 2, ty + 20, {
|
||||
font: 'fontexocet10',
|
||||
color: 'gold',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, s.descZh, tx + tw / 2, ty + 38, {
|
||||
// 1. Title: Skill Name in Gold (#c8a15a, fontexocet10, center)
|
||||
lines.push({
|
||||
text: `${vm.skill.nameZh} (${vm.skill.name})`,
|
||||
color: 'gold',
|
||||
font: 'fontexocet10',
|
||||
align: 'center',
|
||||
})
|
||||
|
||||
// 2. Requirements: Red (#ff4d4d) if player level < reqlevel or unmet prereqs
|
||||
if (!vm.levelMet) {
|
||||
lines.push({
|
||||
text: `需要角色等级: ${vm.reqlevel}`,
|
||||
color: 'red',
|
||||
font: 'font8',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
font.drawText(ctx, `当前等级: ${eff} (基础 ${hard} + 装备 ${this.bonusAllSkills})`, tx + tw / 2, ty + 58, {
|
||||
font: 'font8',
|
||||
color: 'blue',
|
||||
align: 'center',
|
||||
})
|
||||
if (s.baseDmgMax > 0) {
|
||||
font.drawText(ctx, `法术效果/伤害: ${minDmg} - ${maxDmg}`, tx + tw / 2, ty + 76, {
|
||||
}
|
||||
for (const prereq of vm.prereqs) {
|
||||
if (!prereq.met) {
|
||||
lines.push({
|
||||
text: `需要前置技能: ${prereq.nameZh}`,
|
||||
color: 'red',
|
||||
font: 'font8',
|
||||
color: 'white',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
font.drawText(
|
||||
ctx,
|
||||
`法力消耗: ${s.baseMana} · 需要等级: ${s.reqLevel} (左键点击加点)`,
|
||||
tx + tw / 2,
|
||||
ty + 96,
|
||||
{ font: 'font8', color: 'tan', align: 'center' },
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Narrative: Short/Long description in White (#e8e4dc, font8)
|
||||
const desc = vm.skill.shortDescZh || vm.shortDescZh || vm.skill.longDescZh || vm.longDescZh
|
||||
if (desc) {
|
||||
lines.push({
|
||||
text: desc,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// 4. Current Level header: Blue/White
|
||||
if (vm.isLearned) {
|
||||
const soft = vm.effectiveLevel - vm.hardPoints
|
||||
const lvlText = `当前技能等级: ${vm.effectiveLevel}${soft > 0 ? ` (基础 ${vm.hardPoints} + 装备 +${soft})` : ''}`
|
||||
lines.push({
|
||||
text: lvlText,
|
||||
color: soft > 0 ? 'blue' : 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
} else {
|
||||
lines.push({
|
||||
text: '未习得 (预览 1 级效果)',
|
||||
color: 'gray',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// 5. Current Stats: Mana cost, damage ranges (with synergies and masteries), attack bonus, duration, cast delay in Blue/White (#6868ff / #e8e4dc, font8)
|
||||
if (vm.current.manaCost > 0 || vm.skill.mana > 0) {
|
||||
lines.push({
|
||||
text: `法力消耗: ${vm.current.manaCost}`,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (vm.current.delaySeconds > 0) {
|
||||
lines.push({
|
||||
text: `施法延迟: ${vm.current.delaySeconds} 秒`,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (vm.current.damage.hasDamage) {
|
||||
const d = vm.current.damage
|
||||
lines.push({
|
||||
text: `${d.typeZh || '伤害'}: ${d.finalMin} - ${d.finalMax}`,
|
||||
color: 'blue',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
if (d.durationSeconds !== undefined && d.durationSeconds > 0) {
|
||||
lines.push({
|
||||
text: `持续时间: ${d.durationSeconds} 秒`,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (d.weaponDamagePercent !== undefined && d.weaponDamagePercent > 0) {
|
||||
lines.push({
|
||||
text: `武器伤害: ${d.weaponDamagePercent}%`,
|
||||
color: 'white',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const eff of vm.current.effects) {
|
||||
if (eff.textZh) {
|
||||
lines.push({
|
||||
text: eff.textZh,
|
||||
color: 'blue',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Next Level Preview: Header [下一等级] (Lv X) in Green (#00fc00, font8), followed by next level values. Omitted if unlearned or maxed (20 base points).
|
||||
if (vm.nextLevel && vm.isLearned && vm.hardPoints < 20) {
|
||||
lines.push({
|
||||
text: `[下一等级] (Lv ${vm.nextLevel.level})`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
if (vm.nextLevel.manaCost !== vm.current.manaCost) {
|
||||
lines.push({
|
||||
text: `法力消耗: ${vm.nextLevel.manaCost}`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (vm.nextLevel.damage.hasDamage) {
|
||||
const nd = vm.nextLevel.damage
|
||||
lines.push({
|
||||
text: `${nd.typeZh || '伤害'}: ${nd.finalMin} - ${nd.finalMax}`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
if (nd.durationSeconds !== undefined && nd.durationSeconds > 0 && nd.durationSeconds !== vm.current.damage.durationSeconds) {
|
||||
lines.push({
|
||||
text: `持续时间: ${nd.durationSeconds} 秒`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
if (nd.weaponDamagePercent !== undefined && nd.weaponDamagePercent !== vm.current.damage.weaponDamagePercent) {
|
||||
lines.push({
|
||||
text: `武器伤害: ${nd.weaponDamagePercent}%`,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const eff of vm.nextLevel.effects) {
|
||||
if (eff.textZh) {
|
||||
lines.push({
|
||||
text: eff.textZh,
|
||||
color: 'green',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (vm.hardPoints >= 20) {
|
||||
lines.push({
|
||||
text: '[已达技能点投入上限 (20/20)]',
|
||||
color: 'gold',
|
||||
font: 'font8',
|
||||
align: 'center',
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Synergies Section: Header [协同技能加成] in Blue (fontexocet10), followed by each synergy provider line: • <Name>: 每个有效技能点增加 <X>% 伤害 (当前 +<Y>%)
|
||||
if (vm.synergiesReceived && vm.synergiesReceived.length > 0) {
|
||||
lines.push({
|
||||
text: '[协同技能加成]',
|
||||
color: 'blue',
|
||||
font: 'fontexocet10',
|
||||
align: 'center',
|
||||
})
|
||||
for (const syn of vm.synergiesReceived) {
|
||||
const text = syn.hardPoints > 0
|
||||
? `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害 (当前 +${syn.currentBonusPct}%)`
|
||||
: `• ${syn.providerNameZh}: 每个有效技能点增加 ${syn.percentPerLevel}% 伤害`
|
||||
lines.push({
|
||||
text,
|
||||
color: syn.hardPoints > 0 ? 'blue' : 'gray',
|
||||
font: 'font8',
|
||||
align: 'left',
|
||||
})
|
||||
}
|
||||
}
|
||||
if (vm.masteryInfo && vm.masteryInfo.active) {
|
||||
lines.push({
|
||||
text: `• ${vm.masteryInfo.descriptionZh}`,
|
||||
color: 'blue',
|
||||
font: 'font8',
|
||||
align: 'left',
|
||||
})
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
static computeTooltipPlacement(
|
||||
pos: { x: number; y: number; w: number; h: number },
|
||||
lines: readonly FormattedTooltipLine[],
|
||||
font: D2FontRenderer,
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
const maxLineWidth = lines.reduce((max, l) => {
|
||||
const w = typeof font?.measureText === 'function' ? font.measureText(l.text, l.font) : l.text.length * 8
|
||||
return Math.max(max, w)
|
||||
}, 0)
|
||||
const boxW = Math.max(240, maxLineWidth + 28)
|
||||
const boxH = lines.length * 17 + 24
|
||||
const rawX = pos.x - boxW - 8
|
||||
const clampedX = Math.max(8, Math.min(800 - boxW - 8, rawX))
|
||||
const rawY = Math.round(pos.y + 24 - boxH / 2)
|
||||
const clampedY = Math.max(8, Math.min(552 - boxH, rawY))
|
||||
return { x: clampedX, y: clampedY, w: boxW, h: boxH }
|
||||
}
|
||||
|
||||
drawSkillTooltip(ctx: CanvasRenderingContext2D, font: D2FontRenderer): void {
|
||||
if (!this.hoveredSkill) return
|
||||
const pos = SkillTreePanel.nodeScreenPos(this.hoveredSkill)
|
||||
const vm = this.getTooltipViewModel(this.hoveredSkill.skillId)
|
||||
if (!vm) return
|
||||
|
||||
const lines = SkillTreePanel.formatSkillTooltipLines(vm)
|
||||
if (lines.length === 0) return
|
||||
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, font)
|
||||
|
||||
ctx.fillStyle = 'rgba(4, 4, 4, 0.94)'
|
||||
ctx.fillRect(placement.x, placement.y, placement.w, placement.h)
|
||||
ctx.strokeStyle = '#8c7244'
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeRect(placement.x + 0.5, placement.y + 0.5, placement.w - 1, placement.h - 1)
|
||||
|
||||
lines.forEach((l, idx) => {
|
||||
const lineY = placement.y + 12 + 10 + idx * 17
|
||||
const lineX = l.align === 'center' ? placement.x + Math.round(placement.w / 2) : placement.x + 14
|
||||
font.drawText(ctx, l.text, lineX, lineY, {
|
||||
font: l.font,
|
||||
color: l.color,
|
||||
align: l.align ?? 'center',
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,41 @@ export const STASH_CLOSE_BTN_BOUNDS = { x: 80 + 272, y: 60 + 388, w: 32, h: 32 }
|
|||
export const STASH_DEPOSIT_BTN_BOUNDS = { x: 80 + 68, y: 60 + 386, w: 76, h: 26 } as const
|
||||
export const STASH_WITHDRAW_BTN_BOUNDS = { x: 80 + 154, y: 60 + 386, w: 76, h: 26 } as const
|
||||
|
||||
import {
|
||||
CANONICAL_QUESTS_BY_ACT,
|
||||
type CanonicalQuestItem,
|
||||
type CanonicalQuestStage,
|
||||
} from '../data/canonical-quest-data.ts'
|
||||
|
||||
export interface QuestEntry {
|
||||
readonly id: string
|
||||
readonly act: number
|
||||
readonly index: number
|
||||
readonly titleEn: string
|
||||
readonly titleZh: string
|
||||
readonly titleKey: string
|
||||
readonly status: 'completed' | 'active' | 'locked'
|
||||
readonly description: string
|
||||
readonly objectiveKey: string
|
||||
readonly objectiveEn: string
|
||||
readonly objectiveZh: string
|
||||
readonly completedKey: string
|
||||
readonly completedEn: string
|
||||
readonly completedZh: string
|
||||
readonly rewardSummaryEn: string
|
||||
readonly rewardSummaryZh: string
|
||||
readonly stages: readonly CanonicalQuestStage[]
|
||||
}
|
||||
|
||||
export type QuestTblLookup = (key: string) => string | undefined
|
||||
|
||||
export interface QuestDisplayText {
|
||||
readonly title: string
|
||||
readonly titleKey: string
|
||||
readonly body: string
|
||||
readonly status: 'completed' | 'active' | 'locked'
|
||||
readonly statusTag: string
|
||||
readonly rewardText: string
|
||||
}
|
||||
|
||||
export interface WaypointEntry {
|
||||
|
|
@ -44,44 +71,25 @@ export interface WaypointEntry {
|
|||
readonly unlocked: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_COMPLETED_QUEST_IDS = new Set(['a1q1', 'a1q2', 'a1q3'])
|
||||
|
||||
function createQuestEntry(item: CanonicalQuestItem): QuestEntry {
|
||||
const isCompleted = DEFAULT_COMPLETED_QUEST_IDS.has(item.id)
|
||||
return {
|
||||
...item,
|
||||
status: isCompleted ? 'completed' : 'active',
|
||||
description: isCompleted
|
||||
? `${item.completedZh} (奖励: ${item.rewardSummaryZh})`
|
||||
: `${item.objectiveZh} (目标: ${item.rewardSummaryZh})`,
|
||||
}
|
||||
}
|
||||
|
||||
export const ACT_QUESTS: Readonly<Record<number, readonly QuestEntry[]>> = {
|
||||
1: [
|
||||
{ id: 'a1q1', act: 1, index: 0, titleEn: 'Den of Evil', titleZh: '邪恶洞窟', status: 'completed', description: '阿卡拉委托你肃清鲜血荒地地下的邪恶洞窟。任务已完成,奖励 +1 技能点。' },
|
||||
{ id: 'a1q2', act: 1, index: 1, titleEn: "Sisters' Burial Grounds", titleZh: '修女埋骨之地', status: 'completed', description: '在冰冷之原的埋骨之地击败堕落的血鸟 (Blood Raven),卡夏为你配备了罗格佣兵。' },
|
||||
{ id: 'a1q3', act: 1, index: 2, titleEn: 'The Search for Cain', titleZh: '凯恩的搜寻', status: 'completed', description: '从黑暗森林取得艾尼弗斯卷轴,激活乱石旷野的五根石柱,前往崔斯特瑞姆救出迪卡·凯恩。' },
|
||||
{ id: 'a1q4', act: 1, index: 3, titleEn: 'The Forgotten Tower', titleZh: '遗忘之塔', status: 'active', description: '探索黑色荒地中的五层遗忘之塔,击败女伯爵 (The Countess) 夺回财宝与符文。' },
|
||||
{ id: 'a1q5', act: 1, index: 4, titleEn: 'Tools of the Trade', titleZh: '交易的工具', status: 'active', description: '深入修道院兵营从铁匠手中夺回赫拉迪姆之锤,并交还给恰西进行灌魔。' },
|
||||
{ id: 'a1q6', act: 1, index: 5, titleEn: 'Sisters to the Slaughter', titleZh: '屠杀修女', status: 'active', description: '进入地下墓穴第四层,消灭痛苦之王安达利尔 (Andariel),打通前往鲁高因的道路。' },
|
||||
],
|
||||
2: [
|
||||
{ id: 'a2q1', act: 2, index: 0, titleEn: "Radament's Lair", titleZh: '罗达门特的巢穴', status: 'active', description: '在鲁·高因下水道第三层消灭罗达门特,夺回复仇之书(+1 技能点)与赫拉迪姆卷轴。' },
|
||||
{ id: 'a2q2', act: 2, index: 1, titleEn: 'The Horadric Staff', titleZh: '赫拉迪克之杖', status: 'active', description: '在死亡之殿取得赫拉迪克方块,在遥远绿洲取得国王之杖,在利爪蝮蛇神殿取得蝮蛇护符,将其在方块中合成。' },
|
||||
{ id: 'a2q3', act: 2, index: 2, titleEn: 'Tainted Sun', titleZh: '堕落的太阳', status: 'active', description: '利爪蝮蛇的邪恶仪式遮蔽了太阳,深入利爪蝮蛇神殿摧毁太阳祭坛,恢复鲁·高因的光明。' },
|
||||
{ id: 'a2q4', act: 2, index: 3, titleEn: 'Arcane Sanctuary', titleZh: '神秘避难所', status: 'active', description: '穿过杰海因王宫底层的传送门,进入大法师赫拉森的神秘避难所,搜寻真正的塔·拉夏古墓印记。' },
|
||||
{ id: 'a2q5', act: 2, index: 4, titleEn: 'The Summoner', titleZh: '召唤者', status: 'active', description: '在神秘避难所尽头击败堕落的召唤者,开启通往术士峡谷的传送门。' },
|
||||
{ id: 'a2q6', act: 2, index: 5, titleEn: 'The Seven Tombs', titleZh: '七座古墓', status: 'active', description: '在术士峡谷搜寻带有真正印记的塔·拉夏古墓,用赫拉迪克之杖开启密室,击败督瑞尔 (Duriel)。' },
|
||||
],
|
||||
3: [
|
||||
{ id: 'a3q1', act: 3, index: 0, titleEn: 'The Golden Bird', titleZh: '黄金鸟', status: 'active', description: '在库拉斯特丛林击败独特怪物取得玉制小人,交还给马席夫换取黄金鸟雕像,阿尔卡特为你配制永久增加 20 点生命上限的药剂。' },
|
||||
{ id: 'a3q2', act: 3, index: 1, titleEn: 'Blade of the Old Religion', titleZh: '古代宗教之刃', status: 'active', description: '在剥皮丛林吉得宾祭坛夺回附魔匕首吉得宾,将其带给奥玛斯以加固海港魔法屏障。' },
|
||||
{ id: 'a3q3', act: 3, index: 2, titleEn: "Khalim's Will", titleZh: '克林姆的意志', status: 'active', description: '搜集克林姆的眼球(蜘蛛洞穴)、脑(剥皮地窖)、心脏(库拉斯特下水道)与连枷,合成克林姆的意志击碎强制之球。' },
|
||||
{ id: 'a3q4', act: 3, index: 3, titleEn: "Lam Esen's Tome", titleZh: '蓝·森的古书', status: 'active', description: '在库拉斯特商场的废弃神庙中搜寻蓝·森的黑书并交给艾席拉,获得 +5 属性点奖励。' },
|
||||
{ id: 'a3q5', act: 3, index: 4, titleEn: 'The Blackened Temple', titleZh: '黑暗神殿', status: 'active', description: '在崔凡克消灭最高评议会的堕落狂热信徒,用克林姆的意志击碎强制之球,打开通往憎恨囚牢的大门。' },
|
||||
{ id: 'a3q6', act: 3, index: 5, titleEn: 'The Guardian', titleZh: '守卫者', status: 'active', description: '深入憎恨囚牢第三层,击败憎恨之王墨菲斯托 (Mephisto),夺取他的灵魂石并穿越地狱之门。' },
|
||||
],
|
||||
4: [
|
||||
{ id: 'a4q1', act: 4, index: 0, titleEn: 'The Fallen Angel', titleZh: '堕落天使', status: 'active', description: '泰瑞尔委托你进入绝望平原,击败被恶魔囚禁扭曲的堕落大天使衣卒尔 (Izual),释放其灵魂并获得 +2 技能点。' },
|
||||
{ id: 'a4q2', act: 4, index: 1, titleEn: "Hell's Forge", titleZh: '地狱熔炉', status: 'active', description: '护送墨菲斯托的灵魂石前往火焰之河的地狱熔炉,击败铁匠海法斯特夺取地狱熔炉之锤,粉碎邪恶灵魂石。' },
|
||||
{ id: 'a4q3', act: 4, index: 2, titleEn: "Terror's End", titleZh: '恐怖的终结', status: 'active', description: '深入混沌避难所激活五座封印,击败三位庇护所守卫,最终消灭恐惧之王迪亚波罗 (Diablo)。' },
|
||||
],
|
||||
5: [
|
||||
{ id: 'a5q1', act: 5, index: 0, titleEn: 'Siege on Harrogath', titleZh: '围攻哈洛加斯', status: 'active', description: '在血腥丘陵击败督军山克 (Shenk the Overseer),解除恶魔投石机对哈洛加斯的围攻,拉苏克将为你的一件装备打孔。' },
|
||||
{ id: 'a5q2', act: 5, index: 1, titleEn: 'Rescue on Mount Arreat', titleZh: '亚瑞特山救援', status: 'active', description: '在冰冻高地寻找并释放被恶魔囚禁的 15 名野蛮人斥候,夸尔凯克将奖励你符文组合与招募野蛮人佣兵的特权。' },
|
||||
{ id: 'a5q3', act: 5, index: 2, titleEn: 'Prison of Ice', titleZh: '冰之囚', status: 'active', description: '在冰河深处寻找被尼拉塞克封冻的安亚 (Anya),使用马拉配制的解冻药水解救她,安亚将赠予全职业抗性卷轴(+10% 全抗)。' },
|
||||
{ id: 'a5q4', act: 5, index: 3, titleEn: 'Betrayal of Harrogath', titleZh: '哈洛加斯的背叛', status: 'active', description: '穿过安亚开启的红门进入瓦特之厅,击败背叛者尼拉塞克 (Nihlathak),阻止他将圣山遗物交给巴尔。' },
|
||||
{ id: 'a5q5', act: 5, index: 4, titleEn: 'Rite of Passage', titleZh: '通过祭典', status: 'active', description: '登上亚瑞特山顶触碰古代人祭坛,在不返回城镇的情况下同时击败三位远古野蛮人守卫(科力克、马道克、塔力克),获得海量经验晋级。' },
|
||||
{ id: 'a5q6', act: 5, index: 5, titleEn: 'Eve of Destruction', titleZh: '毁灭前夕', status: 'active', description: '深入世界之石要塞与毁灭王座,清除巴尔的五波恶魔仆从,追击毁灭之王巴尔进入世界之石大厅并将其消灭。' },
|
||||
],
|
||||
1: CANONICAL_QUESTS_BY_ACT[1]!.map(createQuestEntry),
|
||||
2: CANONICAL_QUESTS_BY_ACT[2]!.map(createQuestEntry),
|
||||
3: CANONICAL_QUESTS_BY_ACT[3]!.map(createQuestEntry),
|
||||
4: CANONICAL_QUESTS_BY_ACT[4]!.map(createQuestEntry),
|
||||
5: CANONICAL_QUESTS_BY_ACT[5]!.map(createQuestEntry),
|
||||
}
|
||||
|
||||
/** Flat list of all 27 canonical Diablo II quests across Acts 1..5. */
|
||||
|
|
@ -145,6 +153,9 @@ export const ACT_WAYPOINTS: Readonly<Record<number, readonly WaypointEntry[]>> =
|
|||
],
|
||||
}
|
||||
|
||||
/** Authentic D2Client.dll 1.13c waypoint row Y offsets table at VA 0x6fb8e6d8. */
|
||||
export const WAYPOINT_ROW_OFFSETS_Y: readonly number[] = [60, 96, 132, 168, 205, 241, 277, 313, 349] as const
|
||||
|
||||
export function findWaypointBySlug(slug: string): WaypointEntry | undefined {
|
||||
for (const list of Object.values(ACT_WAYPOINTS)) {
|
||||
const found = list.find(w => w.slug === slug)
|
||||
|
|
@ -180,6 +191,57 @@ export class WorldPanelsHud {
|
|||
this.questStatuses.set(questId, status)
|
||||
}
|
||||
|
||||
private questTblLookup?: QuestTblLookup | undefined
|
||||
private questStringTableChain?: { get(key: string): string | undefined } | null | undefined
|
||||
|
||||
setQuestTblLookup(lookup: QuestTblLookup | null | undefined): void {
|
||||
this.questTblLookup = lookup ?? undefined
|
||||
}
|
||||
|
||||
setQuestStringTableChain(chain: { get(key: string): string | undefined } | null | undefined): void {
|
||||
this.questStringTableChain = chain ?? undefined
|
||||
}
|
||||
|
||||
lookupQuestString(key: string): string | undefined {
|
||||
if (this.questTblLookup) {
|
||||
const val = this.questTblLookup(key)
|
||||
if (val !== undefined && val.trim().length > 0) return val.trim()
|
||||
}
|
||||
if (this.questStringTableChain) {
|
||||
const val = this.questStringTableChain.get(key)
|
||||
if (val !== undefined && val.trim().length > 0) return val.trim()
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
getQuestDisplayText(quest: QuestEntry): QuestDisplayText {
|
||||
const status = this.getQuestStatus(quest)
|
||||
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
|
||||
|
||||
const title = this.lookupQuestString(quest.titleKey) ?? quest.titleZh ?? quest.titleEn
|
||||
let body = ''
|
||||
const rewardText = quest.rewardSummaryZh || quest.rewardSummaryEn
|
||||
|
||||
if (status === 'completed') {
|
||||
const tblCompleted = this.lookupQuestString(quest.completedKey)
|
||||
body = tblCompleted || quest.completedZh || quest.completedEn || quest.description
|
||||
} else if (status === 'active') {
|
||||
const tblObjective = this.lookupQuestString(quest.objectiveKey)
|
||||
body = tblObjective || quest.objectiveZh || quest.objectiveEn || quest.description
|
||||
} else {
|
||||
body = '此任务尚未开启。与城镇居民对话或探索荒野以开启任务。'
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
titleKey: quest.titleKey,
|
||||
body,
|
||||
status,
|
||||
statusTag,
|
||||
rewardText,
|
||||
}
|
||||
}
|
||||
|
||||
private getQuestsAtlasImage(): HTMLImageElement | null {
|
||||
if (this.cachedQuestsAtlasImg) return this.cachedQuestsAtlasImg
|
||||
if (typeof Image !== 'undefined') {
|
||||
|
|
@ -200,6 +262,7 @@ export class WorldPanelsHud {
|
|||
|
||||
stashItems: GridPlacement[] = []
|
||||
hoveredStashItem: { item: UiInventoryItem; x: number; y: number } | null = null
|
||||
hoveredWaypointIdx: number | null = null
|
||||
|
||||
/**
|
||||
* Check whether an item of (w, h) can be placed at (col, row) in the 6x8 stash grid.
|
||||
|
|
@ -308,6 +371,22 @@ export class WorldPanelsHud {
|
|||
|
||||
handleMouseMove(logicalX: number, logicalY: number): void {
|
||||
this.hoveredStashItem = null
|
||||
this.hoveredWaypointIdx = null
|
||||
|
||||
const ox = 80
|
||||
const oy = 60
|
||||
if (logicalX >= ox + 17 && logicalX <= ox + 298) {
|
||||
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
|
||||
for (let i = 0; i < wps.length; i++) {
|
||||
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
|
||||
const wy = oy + rowY
|
||||
if (logicalY >= wy && logicalY <= wy + 31) {
|
||||
this.hoveredWaypointIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
logicalX >= STASH_GRID_ORIGIN.x &&
|
||||
logicalX < STASH_GRID_ORIGIN.x + STASH_GRID_ORIGIN.cols * STASH_GRID_ORIGIN.cellPx &&
|
||||
|
|
@ -380,9 +459,11 @@ export class WorldPanelsHud {
|
|||
} else if (kind === 'waypoint') {
|
||||
const list = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const wy = oy + 62 + i * 36
|
||||
if (logicalX >= ox + 22 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
|
||||
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
|
||||
const wy = oy + rowY
|
||||
if (logicalX >= ox + 17 && logicalX <= ox + 298 && logicalY >= wy && logicalY <= wy + 32) {
|
||||
const wp = list[i]!
|
||||
if (!wp.unlocked) return true
|
||||
callbacks.onWaypointTeleport(wp.act, wp.slug, wp.levelId)
|
||||
callbacks.onClose()
|
||||
return true
|
||||
|
|
@ -473,6 +554,9 @@ export class WorldPanelsHud {
|
|||
buySellBtnImg: HTMLImageElement | null
|
||||
questsAtlasImg?: HTMLImageElement | null
|
||||
itemsAtlasImg?: HTMLImageElement | null
|
||||
waypointTabsImg?: HTMLImageElement | null
|
||||
waypointIconsImg?: HTMLImageElement | null
|
||||
questTabsImg?: HTMLImageElement | null
|
||||
},
|
||||
font: D2FontRenderer,
|
||||
): void {
|
||||
|
|
@ -486,7 +570,7 @@ export class WorldPanelsHud {
|
|||
|
||||
if (kind === 'quest') {
|
||||
if (assets.questBgImg) ctx.drawImage(assets.questBgImg, ox, oy)
|
||||
this.drawActTabs(ctx, ox, oy, font)
|
||||
this.drawActTabs(ctx, ox, oy, font, assets.questTabsImg)
|
||||
|
||||
const quests = ACT_QUESTS[this.selectedActTab] ?? ACT_QUESTS[1]!
|
||||
if (this.selectedQuestIdx >= quests.length) {
|
||||
|
|
@ -542,38 +626,55 @@ export class WorldPanelsHud {
|
|||
|
||||
const activeQ = quests[this.selectedQuestIdx] ?? quests[0]!
|
||||
if (activeQ) {
|
||||
const status = this.getQuestStatus(activeQ)
|
||||
const qInfo = this.getQuestDisplayText(activeQ)
|
||||
ctx.fillStyle = 'rgba(8, 7, 6, 0.92)'
|
||||
ctx.fillRect(ox + 18, oy + 292, 284, 86)
|
||||
ctx.fillRect(ox + 18, oy + 264, 284, 116)
|
||||
ctx.strokeStyle = '#6c5838'
|
||||
ctx.strokeRect(ox + 18.5, oy + 292.5, 283, 85)
|
||||
ctx.strokeRect(ox + 18.5, oy + 264.5, 283, 115)
|
||||
|
||||
const statusTag = status === 'completed' ? '[已完成]' : status === 'active' ? '[进行中]' : '[未开启]'
|
||||
font.drawText(ctx, `${activeQ.titleZh} (${activeQ.titleEn}) ${statusTag}`, ox + 160, oy + 314, {
|
||||
font.drawText(ctx, `${qInfo.title} (${activeQ.titleEn}) ${qInfo.statusTag}`, ox + 160, oy + 282, {
|
||||
font: 'fontexocet10',
|
||||
color: status === 'completed' ? 'gold' : 'white',
|
||||
color: qInfo.status === 'completed' ? 'gold' : 'white',
|
||||
align: 'center',
|
||||
})
|
||||
this.drawWrappedText(ctx, font, activeQ.description, ox + 28, oy + 336, 264, 18)
|
||||
const rewardSuffix = qInfo.status === 'completed'
|
||||
? (qInfo.rewardText ? `\n[奖励: ${qInfo.rewardText}]` : '')
|
||||
: (qInfo.rewardText ? `\n[目标: ${qInfo.rewardText}]` : '')
|
||||
this.drawWrappedText(ctx, font, qInfo.body + rewardSuffix, ox + 26, oy + 300, 268, 14, 5)
|
||||
}
|
||||
} else if (kind === 'waypoint') {
|
||||
if (assets.waypointBgImg) ctx.drawImage(assets.waypointBgImg, ox, oy)
|
||||
this.drawActTabs(ctx, ox, oy, font)
|
||||
this.drawActTabs(ctx, ox, oy, font, assets.waypointTabsImg)
|
||||
const wps = ACT_WAYPOINTS[this.selectedActTab] ?? ACT_WAYPOINTS[1]!
|
||||
const activeIconIdx = Math.max(0, Math.min(4, this.selectedActTab - 1))
|
||||
const iconSx = activeIconIdx * 30
|
||||
|
||||
for (let i = 0; i < wps.length; i++) {
|
||||
const wp = wps[i]!
|
||||
const wy = oy + 62 + i * 36
|
||||
ctx.fillStyle = 'rgba(18, 16, 13, 0.85)'
|
||||
ctx.fillRect(ox + 22, wy, 276, 31)
|
||||
ctx.strokeStyle = '#5a4a32'
|
||||
ctx.strokeRect(ox + 22.5, wy + 0.5, 275, 30)
|
||||
const rowY = WAYPOINT_ROW_OFFSETS_Y[i] ?? (60 + i * 36)
|
||||
const wy = oy + rowY
|
||||
const isHovered = this.hoveredWaypointIdx === i
|
||||
|
||||
// Blue active waypoint diamond icon
|
||||
ctx.fillStyle = '#4888ff'
|
||||
ctx.fillRect(ox + 32, wy + 9, 12, 12)
|
||||
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 56, wy + 21, {
|
||||
ctx.fillStyle = isHovered ? 'rgba(42, 34, 22, 0.88)' : 'rgba(18, 16, 13, 0.85)'
|
||||
ctx.fillRect(ox + 17, wy, 281, 31)
|
||||
ctx.strokeStyle = isHovered ? '#e8c26b' : '#5a4a32'
|
||||
ctx.strokeRect(ox + 17.5, wy + 0.5, 280, 30)
|
||||
|
||||
// Authentic D2 waypoint icon from waygateicons.dc6 (30x30)
|
||||
if (assets.waypointIconsImg && assets.waypointIconsImg.complete && assets.waypointIconsImg.naturalWidth > 0) {
|
||||
ctx.save()
|
||||
if (!wp.unlocked) ctx.globalAlpha = 0.35
|
||||
ctx.drawImage(assets.waypointIconsImg, iconSx, 0, 30, 30, ox + 17, wy + 1, 30, 30)
|
||||
ctx.restore()
|
||||
} else {
|
||||
// Fallback stone diamond
|
||||
ctx.fillStyle = wp.unlocked ? '#4888ff' : '#4a4a4a'
|
||||
ctx.fillRect(ox + 26, wy + 9, 12, 12)
|
||||
}
|
||||
|
||||
font.drawText(ctx, `${wp.nameZh} · ${wp.nameEn}`, ox + 80, wy + 21, {
|
||||
font: 'fontexocet10',
|
||||
color: 'gold',
|
||||
color: wp.unlocked ? (isHovered ? 'white' : 'gold') : 'gray',
|
||||
})
|
||||
}
|
||||
} else if (kind === 'stash') {
|
||||
|
|
@ -704,7 +805,25 @@ export class WorldPanelsHud {
|
|||
}
|
||||
}
|
||||
|
||||
private drawActTabs(ctx: CanvasRenderingContext2D, ox: number, oy: number, font: D2FontRenderer): void {
|
||||
private drawActTabs(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
ox: number,
|
||||
oy: number,
|
||||
font: D2FontRenderer,
|
||||
tabsImg?: HTMLImageElement | null,
|
||||
): void {
|
||||
if (tabsImg && tabsImg.complete && tabsImg.naturalWidth > 0) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const act = i + 1
|
||||
const active = this.selectedActTab === act
|
||||
const frameIdx = active ? 5 + i : i
|
||||
const sx = frameIdx * 63
|
||||
const tx = ox + 4 + i * 62
|
||||
ctx.drawImage(tabsImg, sx, 0, 63, 31, tx, oy + 8, 63, 31)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const numerals = ['I', 'II', 'III', 'IV', 'V'] as const
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const act = i + 1
|
||||
|
|
@ -730,20 +849,36 @@ export class WorldPanelsHud {
|
|||
y: number,
|
||||
maxW: number,
|
||||
lineH: number,
|
||||
maxLines = 10,
|
||||
): void {
|
||||
let line = ''
|
||||
const paragraphs = text.split(/\r?\n/)
|
||||
let curY = y
|
||||
for (const ch of text) {
|
||||
if (font.measureText(line + ch, 'font8') > maxW) {
|
||||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
||||
line = ch
|
||||
curY += lineH
|
||||
} else {
|
||||
line += ch
|
||||
let linesDrawn = 0
|
||||
|
||||
for (const p of paragraphs) {
|
||||
if (linesDrawn >= maxLines) break
|
||||
let line = ''
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
const ch = p[i]!
|
||||
if (font.measureText(line + ch, 'font8') > maxW) {
|
||||
if (linesDrawn + 1 >= maxLines) {
|
||||
const truncated = line.slice(0, Math.max(0, line.length - 2)) + '...'
|
||||
font.drawText(ctx, truncated, x, curY, { font: 'font8', color: 'white' })
|
||||
return
|
||||
}
|
||||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
||||
linesDrawn++
|
||||
line = ch
|
||||
curY += lineH
|
||||
} else {
|
||||
line += ch
|
||||
}
|
||||
}
|
||||
if (line && linesDrawn < maxLines) {
|
||||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
||||
linesDrawn++
|
||||
curY += lineH
|
||||
}
|
||||
}
|
||||
if (line) {
|
||||
font.drawText(ctx, line, x, curY, { font: 'font8', color: 'white' })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,392 @@
|
|||
/**
|
||||
* Tests for Normal Attack Icon Completion & Dual Slot Binding Mechanics (Issue #151).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
SkillHotkeysHud,
|
||||
resolveSkillIcon,
|
||||
getAvailableSkillsForSide,
|
||||
} from '../src/ui/hotkeys.ts'
|
||||
import {
|
||||
getAttackWeaponInfo,
|
||||
checkAmmunition,
|
||||
getSkillRange,
|
||||
castSkill,
|
||||
type AttackWeaponInputItem,
|
||||
} from '../src/scene/act-scene.ts'
|
||||
import { GameEngine, type GameEngineOptions } from '../src/game/engine.ts'
|
||||
import type { Monster } from '../src/game/combat.ts'
|
||||
|
||||
describe('Issue #151: Normal Attack Icon Completion & Dual Slot Binding', () => {
|
||||
describe('Skill 0 Definition & Dual-Palette Usability', () => {
|
||||
it('defines Skill 0 Attack with leftUsable: true and rightUsable: true', () => {
|
||||
const attackSkill = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
expect(attackSkill).toBeDefined()
|
||||
expect(attackSkill!.name).toBe('Attack')
|
||||
expect(attackSkill!.nameZh).toBe('普通攻击')
|
||||
expect(attackSkill!.leftUsable).toBe(true)
|
||||
expect(attackSkill!.rightUsable).toBe(true)
|
||||
expect(attackSkill!.manaCost).toBe(0)
|
||||
})
|
||||
|
||||
it('includes Skill 0 in both left and right available skill palettes', () => {
|
||||
const leftSkills = getAvailableSkillsForSide('left')
|
||||
const rightSkills = getAvailableSkillsForSide('right')
|
||||
|
||||
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
||||
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
||||
|
||||
expect(leftAttack).toBeDefined()
|
||||
expect(rightAttack).toBeDefined()
|
||||
expect(leftAttack!.name).toBe('Attack')
|
||||
expect(rightAttack!.name).toBe('Attack')
|
||||
})
|
||||
|
||||
it('allows assigning Skill 0 to left slot, right slot, or both simultaneously (dual binding)', () => {
|
||||
const hud = new SkillHotkeysHud()
|
||||
|
||||
// Initial defaults
|
||||
expect(hud.leftSkillId).toBe(47) // Fire Ball
|
||||
expect(hud.rightSkillId).toBe(64) // Frozen Orb
|
||||
|
||||
// Assign Attack to left slot
|
||||
hud.assignSkill('left', 0)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(64)
|
||||
|
||||
// Assign Attack to right slot
|
||||
hud.assignSkill('right', 0)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
|
||||
// Change left slot back to Fire Ball
|
||||
hud.assignSkill('left', 47)
|
||||
expect(hud.leftSkillId).toBe(47)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
|
||||
// Use helper setDualSkill
|
||||
hud.setDualSkill(0)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Icon Asset & DC6 Frame 0 Resolution', () => {
|
||||
it('resolves Skill 0 to authentic Skillicon.DC6 frame 0 icon', () => {
|
||||
const iconMapping = resolveSkillIcon(0, false)
|
||||
expect(iconMapping).toBeDefined()
|
||||
expect(iconMapping.skillId).toBe(0)
|
||||
expect(iconMapping.name).toBe('Attack')
|
||||
expect(iconMapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6')
|
||||
expect(iconMapping.frameIndex).toBe(0)
|
||||
expect(iconMapping.atlasRect.x).toBe(0)
|
||||
expect(iconMapping.atlasRect.y).toBe(0)
|
||||
expect(iconMapping.atlasRect.width).toBe(48)
|
||||
expect(iconMapping.atlasRect.height).toBe(48)
|
||||
})
|
||||
|
||||
it('verifies public/skills/icon_0.png exists and is a valid 48x48 PNG image', () => {
|
||||
const iconPath = join(process.cwd(), 'public', 'skills', 'icon_0.png')
|
||||
expect(existsSync(iconPath)).toBe(true)
|
||||
|
||||
const buf = readFileSync(iconPath)
|
||||
// Check PNG magic header: \x89PNG\r\n\x1a\n
|
||||
expect(buf[0]).toBe(0x89)
|
||||
expect(buf[1]).toBe(0x50) // P
|
||||
expect(buf[2]).toBe(0x4e) // N
|
||||
expect(buf[3]).toBe(0x47) // G
|
||||
expect(buf[4]).toBe(0x0d) // \r
|
||||
expect(buf[5]).toBe(0x0a) // \n
|
||||
expect(buf[6]).toBe(0x1a)
|
||||
expect(buf[7]).toBe(0x0a) // \n
|
||||
|
||||
// Check IHDR dimensions (width at bytes 16-19, height at bytes 20-23)
|
||||
const width = buf.readUInt32BE(16)
|
||||
const height = buf.readUInt32BE(20)
|
||||
expect(width).toBe(48)
|
||||
expect(height).toBe(48)
|
||||
})
|
||||
|
||||
it('verifies public/ui/icon_0.png exists for baked UI references', () => {
|
||||
const iconPath = join(process.cwd(), 'public', 'ui', 'icon_0.png')
|
||||
expect(existsSync(iconPath)).toBe(true)
|
||||
const buf = readFileSync(iconPath)
|
||||
expect(buf.readUInt32BE(16)).toBe(48)
|
||||
expect(buf.readUInt32BE(20)).toBe(48)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Weapon Timing, Damage, and Ammunition Checks', () => {
|
||||
it('evaluates unarmed attack characteristics correctly', () => {
|
||||
const unarmed = getAttackWeaponInfo(null)
|
||||
expect(unarmed.kind).toBe('unarmed')
|
||||
expect(unarmed.subtype).toBe('fist')
|
||||
expect(unarmed.isRanged).toBe(false)
|
||||
expect(unarmed.damageMin).toBe(1)
|
||||
expect(unarmed.damageMax).toBe(2)
|
||||
expect(unarmed.reach).toBe(52)
|
||||
expect(unarmed.cooldown).toBe(12)
|
||||
expect(unarmed.swingTicks).toBe(6)
|
||||
})
|
||||
|
||||
it('evaluates melee weapon attack timing and damage correctly', () => {
|
||||
const crystalSword: AttackWeaponInputItem = {
|
||||
code: 'crs',
|
||||
name: 'Crystal Sword',
|
||||
damage: '5 to 15',
|
||||
}
|
||||
const swordInfo = getAttackWeaponInfo(crystalSword)
|
||||
expect(swordInfo.kind).toBe('melee')
|
||||
expect(swordInfo.subtype).toBe('sword')
|
||||
expect(swordInfo.isRanged).toBe(false)
|
||||
expect(swordInfo.damageMin).toBe(5)
|
||||
expect(swordInfo.damageMax).toBe(15)
|
||||
expect(swordInfo.reach).toBe(52)
|
||||
expect(swordInfo.cooldown).toBe(14)
|
||||
expect(swordInfo.swingTicks).toBe(7)
|
||||
|
||||
// Test extended reach for polearm
|
||||
const polearm: AttackWeaponInputItem = {
|
||||
code: 'vou',
|
||||
name: 'Voulge',
|
||||
damage: '6 - 21',
|
||||
}
|
||||
const polearmInfo = getAttackWeaponInfo(polearm)
|
||||
expect(polearmInfo.subtype).toBe('polearm')
|
||||
expect(polearmInfo.reach).toBe(60)
|
||||
})
|
||||
|
||||
it('evaluates ranged weapon (bow/crossbow) timing and ammo requirement', () => {
|
||||
const shortBow: AttackWeaponInputItem = {
|
||||
code: 'sbw',
|
||||
name: 'Short Bow',
|
||||
damage: '1 to 4',
|
||||
}
|
||||
const bowInfo = getAttackWeaponInfo(shortBow)
|
||||
expect(bowInfo.kind).toBe('ranged')
|
||||
expect(bowInfo.subtype).toBe('bow')
|
||||
expect(bowInfo.isRanged).toBe(true)
|
||||
expect(bowInfo.ammoType).toBe('arrow')
|
||||
expect(bowInfo.reach).toBe(450)
|
||||
expect(bowInfo.cooldown).toBe(14)
|
||||
|
||||
const lightCrossbow: AttackWeaponInputItem = {
|
||||
code: 'lxb',
|
||||
name: 'Light Crossbow',
|
||||
damage: '6 to 9',
|
||||
}
|
||||
const xbowInfo = getAttackWeaponInfo(lightCrossbow)
|
||||
expect(xbowInfo.kind).toBe('ranged')
|
||||
expect(xbowInfo.subtype).toBe('crossbow')
|
||||
expect(xbowInfo.isRanged).toBe(true)
|
||||
expect(xbowInfo.ammoType).toBe('bolt')
|
||||
expect(xbowInfo.reach).toBe(450)
|
||||
expect(xbowInfo.cooldown).toBe(16)
|
||||
})
|
||||
|
||||
it('verifies checkAmmunition for bows and crossbows', () => {
|
||||
// Offhand arrow equipped
|
||||
expect(checkAmmunition('arrow', { code: 'aqv', name: 'Arrows' })).toBe(true)
|
||||
// Offhand bolt equipped when bow needs arrow -> false
|
||||
expect(checkAmmunition('arrow', { code: 'cqv', name: 'Bolts' })).toBe(false)
|
||||
// Offhand bolt equipped when crossbow needs bolt -> true
|
||||
expect(checkAmmunition('bolt', { code: 'cqv', name: 'Bolts' })).toBe(true)
|
||||
|
||||
// Ammo in inventory grid
|
||||
const invWithArrows = {
|
||||
gridItems: [{ item: { code: 'aqv', nameZh: '箭矢' } }],
|
||||
}
|
||||
expect(checkAmmunition('arrow', null, invWithArrows)).toBe(true)
|
||||
expect(checkAmmunition('bolt', null, invWithArrows)).toBe(false)
|
||||
|
||||
const invWithBolts = {
|
||||
items: [{ code: 'cqv', nameZh: '十字弓弹' }],
|
||||
}
|
||||
expect(checkAmmunition('bolt', null, invWithBolts)).toBe(true)
|
||||
expect(checkAmmunition('arrow', null, invWithBolts)).toBe(false)
|
||||
|
||||
// Empty inventory & offhand
|
||||
expect(checkAmmunition('arrow', null, null)).toBe(false)
|
||||
expect(checkAmmunition('bolt', null, { items: [] })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Combat Execution via castSkill', () => {
|
||||
let engine: GameEngine
|
||||
let statusElem: HTMLElement
|
||||
const dummyRuntime: any = {
|
||||
level: 'test',
|
||||
grid: { width: 100, height: 100, cells: new Uint8Array(10000) },
|
||||
waypoints: [],
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const dummyTerrain = {
|
||||
widthPx: 1000,
|
||||
heightPx: 1000,
|
||||
overlap: () => 0,
|
||||
}
|
||||
const dummyOpts: GameEngineOptions = {
|
||||
spawn: { x: 100, y: 100 },
|
||||
stats: [],
|
||||
xpTable: [0, 100],
|
||||
itemBases: [],
|
||||
prefixAffixes: [],
|
||||
suffixAffixes: [],
|
||||
skills: [],
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
monsterCount: 0,
|
||||
combatOptions: {
|
||||
playerSpeed: 100,
|
||||
playerReach: 50,
|
||||
playerCooldownTicks: 10,
|
||||
playerDamage: 5,
|
||||
playerManaPerAttack: 0,
|
||||
respawnTicks: 100,
|
||||
},
|
||||
talkRadius: 80,
|
||||
pickupRadius: 40,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
}
|
||||
engine = new GameEngine(dummyTerrain, dummyOpts)
|
||||
engine.world.player.x = 100
|
||||
engine.world.player.y = 100
|
||||
engine.world.player.cooldown = 0
|
||||
statusElem = { textContent: '' } as any
|
||||
})
|
||||
|
||||
it('executes unarmed attack hitting a nearby monster with base physical damage and unarmed timing', () => {
|
||||
const monster: Monster = {
|
||||
index: 0,
|
||||
x: 120,
|
||||
y: 100,
|
||||
hp: 50,
|
||||
cooldown: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
stats: { id: 'zombie', name: 'Zombie', hp: 50, damage: 2, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 10 },
|
||||
}
|
||||
engine.world.monsters = [monster]
|
||||
|
||||
const success = castSkill(0, 120, 100, {
|
||||
engine,
|
||||
runtime: dummyRuntime,
|
||||
hudManager: null,
|
||||
status: statusElem,
|
||||
equippedWeapon: null, // unarmed
|
||||
})
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(engine.world.player.cooldown).toBe(12)
|
||||
expect(engine.world.player.swingTicks).toBe(6)
|
||||
expect(monster.hp).toBeLessThan(50) // damaged
|
||||
expect(engine.metrics.playerHits).toBe(1)
|
||||
expect(statusElem.textContent).toContain('普通攻击(空手拳击)命中')
|
||||
})
|
||||
|
||||
it('executes melee weapon attack with equipped weapon timing and damage', () => {
|
||||
const monster: Monster = {
|
||||
index: 0,
|
||||
x: 130,
|
||||
y: 100,
|
||||
hp: 100,
|
||||
cooldown: 0,
|
||||
hitFlash: 0,
|
||||
corpseTicks: 0,
|
||||
state: 'idle',
|
||||
facing: 0,
|
||||
stats: { id: 'skeleton', name: 'Skeleton', hp: 100, damage: 5, cooldownTicks: 10, reach: 20, aggroRadius: 100, speed: 2, xp: 15 },
|
||||
}
|
||||
engine.world.monsters = [monster]
|
||||
|
||||
const success = castSkill(0, 130, 100, {
|
||||
engine,
|
||||
runtime: dummyRuntime,
|
||||
hudManager: null,
|
||||
status: statusElem,
|
||||
equippedWeapon: {
|
||||
code: 'crs',
|
||||
name: 'Crystal Sword',
|
||||
damage: '20 to 30',
|
||||
},
|
||||
})
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(engine.world.player.cooldown).toBe(14)
|
||||
expect(engine.world.player.swingTicks).toBe(7)
|
||||
// Damage should be (20 + 30) / 2 = 25
|
||||
expect(monster.hp).toBe(75)
|
||||
expect(statusElem.textContent).toContain('普通攻击命中')
|
||||
})
|
||||
|
||||
it('fails fast on ranged attack when ammunition is missing', () => {
|
||||
const success = castSkill(0, 200, 100, {
|
||||
engine,
|
||||
runtime: dummyRuntime,
|
||||
hudManager: null,
|
||||
status: statusElem,
|
||||
equippedWeapon: {
|
||||
code: 'sbw',
|
||||
name: 'Short Bow',
|
||||
damage: '1 to 4',
|
||||
},
|
||||
hasAmmo: false,
|
||||
})
|
||||
|
||||
expect(success).toBe(false)
|
||||
expect(engine.projectiles.length).toBe(0)
|
||||
expect(statusElem.textContent).toBe('缺少箭矢!')
|
||||
})
|
||||
|
||||
it('launches basic arrow projectile on ranged attack when ammunition is present', () => {
|
||||
const success = castSkill(0, 300, 100, {
|
||||
engine,
|
||||
runtime: dummyRuntime,
|
||||
hudManager: null,
|
||||
status: statusElem,
|
||||
equippedWeapon: {
|
||||
code: 'sbw',
|
||||
name: 'Short Bow',
|
||||
damage: '10 to 20',
|
||||
},
|
||||
hasAmmo: true,
|
||||
})
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(engine.world.player.cooldown).toBe(14)
|
||||
expect(engine.projectiles.length).toBe(1)
|
||||
|
||||
const proj = engine.projectiles[0]
|
||||
expect(proj.skillId).toBe('0')
|
||||
expect(proj.fromPlayer).toBe(true)
|
||||
expect(proj.damage).toBe(15) // average of 10 and 20
|
||||
expect(proj.vx).toBeGreaterThan(0)
|
||||
expect(statusElem.textContent).toContain('发射箭矢')
|
||||
})
|
||||
|
||||
it('records whiff event when melee attack is performed with no monster in reach', () => {
|
||||
engine.world.monsters = [] // no monsters
|
||||
|
||||
const success = castSkill(0, 120, 100, {
|
||||
engine,
|
||||
runtime: dummyRuntime,
|
||||
hudManager: null,
|
||||
status: statusElem,
|
||||
equippedWeapon: null, // unarmed
|
||||
})
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(engine.world.player.cooldown).toBe(12)
|
||||
const whiffEvent = engine.world.events.find((e) => e.kind === 'playerHit' && (e as any).text === 'whiff')
|
||||
expect(whiffEvent).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,712 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
CANONICAL_TOWN_STASHES,
|
||||
type TownStashEntity,
|
||||
SceneMouseController,
|
||||
type MapRuntime,
|
||||
} from '../src/scene/act-scene.ts'
|
||||
import {
|
||||
WorldPanelsHud,
|
||||
STASH_GOLD_CAP,
|
||||
PLAYER_GOLD_CAP,
|
||||
STASH_DEPOSIT_BTN_BOUNDS,
|
||||
STASH_WITHDRAW_BTN_BOUNDS,
|
||||
} from '../src/ui/world-panels.ts'
|
||||
import { HudManager } from '../src/ui/hud-manager.ts'
|
||||
import { GameEngine } from '../src/game/engine.ts'
|
||||
import { createIsoTerrain } from '../src/game/iso-terrain.ts'
|
||||
import { ViewportCamera } from '../src/sim/camera.ts'
|
||||
import { KeyboardInput } from '../src/sim/input.ts'
|
||||
import { WaypointNetwork } from '../src/game/portal.ts'
|
||||
|
||||
class MockWindow extends EventTarget {
|
||||
innerWidth = 1920
|
||||
innerHeight = 1080
|
||||
devicePixelRatio = 1
|
||||
}
|
||||
|
||||
if (typeof globalThis.window === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.window = new MockWindow()
|
||||
}
|
||||
if (typeof globalThis.HTMLInputElement === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.HTMLInputElement = class HTMLInputElement {}
|
||||
}
|
||||
if (typeof globalThis.HTMLSelectElement === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
||||
}
|
||||
|
||||
describe('Challenger M5 Empirical Stress Test: Town Stash Entities, Interaction Geometry & Gold Mechanics', () => {
|
||||
describe('1. 5-Act Town Stash Entity Verification', () => {
|
||||
it('verifies all 5 town acts spawn a stash entity with ID 267 and token "B6"', () => {
|
||||
for (let act = 1; act <= 5; act++) {
|
||||
const stash = CANONICAL_TOWN_STASHES[act]
|
||||
expect(stash, `Act ${act} stash must be defined`).toBeDefined()
|
||||
expect(stash.id).toBe(267)
|
||||
expect(stash.objectsTxtId).toBe(267)
|
||||
expect(stash.token).toBe('B6')
|
||||
expect(stash.act).toBe(act)
|
||||
expect(stash.nameZh).toBe('储物箱')
|
||||
expect(stash.nameEn).toBe('Stash')
|
||||
expect(stash.clickRadiusPx).toBe(48)
|
||||
expect(stash.interactionRangePx).toBe(80)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies exact coordinates in each act matching authentic 1.13c ground truth', () => {
|
||||
const expectedCoordinates: Record<number, { x: number; y: number; subTileX: number; subTileY: number }> = {
|
||||
1: { x: 4512, y: 1720, subTileX: 112, subTileY: 86 }, // Rogue Encampment
|
||||
2: { x: 5328, y: 1568, subTileX: 133, subTileY: 78 }, // Lut Gholein
|
||||
3: { x: 5264, y: 1584, subTileX: 131, subTileY: 79 }, // Kurast Docks
|
||||
4: { x: 704, y: 552, subTileX: 17, subTileY: 27 }, // Pandemonium Fortress
|
||||
5: { x: 3792, y: 1448, subTileX: 94, subTileY: 72 }, // Harrogath
|
||||
}
|
||||
|
||||
for (let act = 1; act <= 5; act++) {
|
||||
const stash = CANONICAL_TOWN_STASHES[act]
|
||||
const expected = expectedCoordinates[act]!
|
||||
expect(stash.x, `Act ${act} stash X`).toBe(expected.x)
|
||||
expect(stash.y, `Act ${act} stash Y`).toBe(expected.y)
|
||||
expect(stash.subTileX, `Act ${act} stash subTileX`).toBe(expected.subTileX)
|
||||
expect(stash.subTileY, `Act ${act} stash subTileY`).toBe(expected.subTileY)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies sub-tile conversion alignment against authentic DS1 town origins', () => {
|
||||
const townOrigins: Record<number, { originX: number; originY: number }> = {
|
||||
1: { originX: 2720, originY: 344 }, // Act 1 Rogue Encampment
|
||||
2: { originX: 3200, originY: 320 }, // Act 2 Lut Gholein
|
||||
3: { originX: 3168, originY: 320 }, // Act 3 Kurast Docks
|
||||
4: { originX: 432, originY: 120 }, // Act 4 Pandemonium Fortress
|
||||
5: { originX: 2288, originY: 296 }, // Act 5 Harrogath
|
||||
}
|
||||
|
||||
for (let act = 1; act <= 5; act++) {
|
||||
const stash = CANONICAL_TOWN_STASHES[act]!
|
||||
const origin = townOrigins[act]!
|
||||
// Exact 1.13c DS1 coordinate mapping: worldPos = origin + subTile * 16
|
||||
expect(origin.originX + stash.subTileX * 16, `Act ${act} stash world X`).toBe(stash.x)
|
||||
expect(origin.originY + stash.subTileY * 16, `Act ${act} stash world Y`).toBe(stash.y)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies safe zone coverage around town stashes', () => {
|
||||
for (let act = 1; act <= 5; act++) {
|
||||
const stash = CANONICAL_TOWN_STASHES[act]
|
||||
// Stash safe zones are configured with radius 160px in runScene
|
||||
expect(stash.x).toBeGreaterThan(0)
|
||||
expect(stash.y).toBeGreaterThan(0)
|
||||
expect(stash.clickRadiusPx).toBe(48)
|
||||
expect(stash.interactionRangePx).toBe(80)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies multi-act scene runtime integration: all 5 acts resolve their canonical stash', () => {
|
||||
const townLevels: Record<number, { levelId: number; name: string; slug: string }> = {
|
||||
1: { levelId: 1, name: 'Rogue Encampment', slug: 'act1-towne1' },
|
||||
2: { levelId: 40, name: 'Lut Gholein', slug: 'act2-lutn' },
|
||||
3: { levelId: 75, name: 'Kurast Docks', slug: 'act3-docktown3' },
|
||||
4: { levelId: 103, name: 'The Pandemonium Fortress', slug: 'act4-fortress' },
|
||||
5: { levelId: 109, name: 'Harrogath', slug: 'act5-townwest' },
|
||||
}
|
||||
|
||||
for (let act = 1; act <= 5; act++) {
|
||||
const info = townLevels[act]!
|
||||
const canonical = CANONICAL_TOWN_STASHES[act]!
|
||||
const runtimeStashes: TownStashEntity[] = [canonical]
|
||||
|
||||
expect(runtimeStashes.length).toBe(1)
|
||||
const stash = runtimeStashes[0]!
|
||||
expect(stash.id).toBe(267)
|
||||
expect(stash.objectsTxtId).toBe(267)
|
||||
expect(stash.token).toBe('B6')
|
||||
expect(stash.act).toBe(act)
|
||||
expect(stash.nameZh).toBe('储物箱')
|
||||
expect(stash.nameEn).toBe('Stash')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Interaction Geometry Stress Testing', () => {
|
||||
let engine: GameEngine
|
||||
let camera: ViewportCamera
|
||||
let input: KeyboardInput
|
||||
let waypointNetwork: WaypointNetwork
|
||||
let statusElem: HTMLElement
|
||||
let controller: SceneMouseController
|
||||
let runtime: MapRuntime
|
||||
let stashOpenCount: number
|
||||
let mockHudManager: any
|
||||
|
||||
beforeEach(() => {
|
||||
stashOpenCount = 0
|
||||
const grid = {
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
cellsX: 200,
|
||||
cellsY: 200,
|
||||
blocked: new Uint8Array(1000 * 1000),
|
||||
gridWidth: 1000,
|
||||
}
|
||||
const terrain = createIsoTerrain(grid, 10000, 5000, { width: 20, height: 10 })
|
||||
engine = new GameEngine(terrain, {
|
||||
spawn: { x: 4500, y: 1700 },
|
||||
stats: [],
|
||||
monsterCount: 0,
|
||||
questDefs: [],
|
||||
npcDefs: [],
|
||||
} as any)
|
||||
|
||||
const mockCanvas = {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getContext: () => null,
|
||||
style: { cursor: '' },
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
camera = new ViewportCamera(mockCanvas, 10000, 5000)
|
||||
input = new KeyboardInput()
|
||||
waypointNetwork = new WaypointNetwork()
|
||||
statusElem = { textContent: '' } as unknown as HTMLElement
|
||||
|
||||
mockHudManager = {
|
||||
leftPanel: 'none',
|
||||
toggleLeftPanel: (panel: any) => {
|
||||
if (panel === 'stash') stashOpenCount++
|
||||
mockHudManager.leftPanel = panel
|
||||
},
|
||||
clientToLogical: (x: number, y: number) => ({ x, y }),
|
||||
isPointInterceptedByHud: () => false,
|
||||
} as unknown as HudManager
|
||||
|
||||
runtime = {
|
||||
source: 'pack',
|
||||
base: 'samples/d2-packs',
|
||||
level: 'Rogue Encampment',
|
||||
act: 1,
|
||||
levelId: 1,
|
||||
entryPath: 'act1-towne1',
|
||||
entrances: [],
|
||||
warps: [],
|
||||
waypoints: [],
|
||||
stashes: [
|
||||
CANONICAL_TOWN_STASHES[1]!,
|
||||
CANONICAL_TOWN_STASHES[2]!,
|
||||
CANONICAL_TOWN_STASHES[3]!,
|
||||
CANONICAL_TOWN_STASHES[4]!,
|
||||
CANONICAL_TOWN_STASHES[5]!,
|
||||
],
|
||||
variants: ['towne1'],
|
||||
variantParam: 'level',
|
||||
variant: 'towne1',
|
||||
quadrants: ['towne1'],
|
||||
quadrantParam: 'level',
|
||||
quadrant: 'towne1',
|
||||
cellsX: 200,
|
||||
cellsY: 200,
|
||||
widthPx: 10000,
|
||||
heightPx: 5000,
|
||||
grid,
|
||||
floors: [],
|
||||
walls: [],
|
||||
roofs: [],
|
||||
objectDrawables: [],
|
||||
spawn: { x: 4500, y: 1700 },
|
||||
npcs: [],
|
||||
monsterPacks: [],
|
||||
monsterTypes: [],
|
||||
objects: 5,
|
||||
frames: 0,
|
||||
notes: [],
|
||||
pages: [],
|
||||
objectPages: [],
|
||||
charBases: [],
|
||||
palette: null,
|
||||
priorityPages: [],
|
||||
loadOrder: [],
|
||||
loadPages: async () => {},
|
||||
}
|
||||
|
||||
controller = new SceneMouseController({
|
||||
canvas: mockCanvas,
|
||||
engine,
|
||||
camera,
|
||||
input,
|
||||
getRuntime: () => runtime,
|
||||
hudManager: mockHudManager,
|
||||
waypointNetwork,
|
||||
status: statusElem,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Click radius (<= 48px hit, > 48px miss)', () => {
|
||||
const stash1 = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
||||
|
||||
it('asserts exact center click hits the stash', () => {
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y, 48)).toEqual(stash1)
|
||||
})
|
||||
|
||||
it('asserts clicks at distance <= 48px hit the stash in all cardinal directions', () => {
|
||||
expect(controller.findNearestStash(stash1.x + 48, stash1.y, 48)).toEqual(stash1)
|
||||
expect(controller.findNearestStash(stash1.x - 48, stash1.y, 48)).toEqual(stash1)
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y + 48, 48)).toEqual(stash1)
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y - 48, 48)).toEqual(stash1)
|
||||
})
|
||||
|
||||
it('asserts 36-angle radial sweep at radius 47.5px (strictly <= 48px) all hit the stash', () => {
|
||||
for (let i = 0; i < 36; i++) {
|
||||
const angle = (i * 2 * Math.PI) / 36
|
||||
const x = stash1.x + 47.5 * Math.cos(angle)
|
||||
const y = stash1.y + 47.5 * Math.sin(angle)
|
||||
const hit = controller.findNearestStash(x, y, 48)
|
||||
expect(hit, `Angle ${i * 10}deg at distance <= 48 should hit stash`).toEqual(stash1)
|
||||
}
|
||||
})
|
||||
|
||||
it('asserts clicks at distance > 48px do NOT hit the stash', () => {
|
||||
// Just beyond boundary (49px, 50px, 60px)
|
||||
expect(controller.findNearestStash(stash1.x + 49, stash1.y, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x - 49, stash1.y, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y + 49, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x, stash1.y - 49, 48)).toBeNull()
|
||||
|
||||
// Diagonal beyond boundary (35, 35) -> dist = 49.5px > 48px
|
||||
expect(controller.findNearestStash(stash1.x + 35, stash1.y + 35, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x - 35, stash1.y - 35, 48)).toBeNull()
|
||||
|
||||
// Distant clicks
|
||||
expect(controller.findNearestStash(stash1.x + 100, stash1.y, 48)).toBeNull()
|
||||
expect(controller.findNearestStash(0, 0, 48)).toBeNull()
|
||||
})
|
||||
|
||||
it('asserts 36-angle radial sweep at radius 50px all miss the stash', () => {
|
||||
for (let i = 0; i < 36; i++) {
|
||||
const angle = (i * 2 * Math.PI) / 36
|
||||
const x = stash1.x + 50 * Math.cos(angle)
|
||||
const y = stash1.y + 50 * Math.sin(angle)
|
||||
const hit = controller.findNearestStash(x, y, 48)
|
||||
expect(hit, `Angle ${i * 10}deg at distance 50px must not hit stash`).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('discriminates between closest stash when multiple stashes are present', () => {
|
||||
const stash3 = CANONICAL_TOWN_STASHES[3]! // (5264, 1584)
|
||||
const stash2 = CANONICAL_TOWN_STASHES[2]! // (5328, 1568)
|
||||
// Click near stash3
|
||||
expect(controller.findNearestStash(stash3.x + 10, stash3.y, 48)).toEqual(stash3)
|
||||
// Click near stash2
|
||||
expect(controller.findNearestStash(stash2.x + 10, stash2.y, 48)).toEqual(stash2)
|
||||
})
|
||||
|
||||
it('tests custom maxRadius overrides (e.g. 24px, 64px)', () => {
|
||||
// At 30px away: misses with maxRadius=24, hits with maxRadius=48 and maxRadius=64
|
||||
expect(controller.findNearestStash(stash1.x + 30, stash1.y, 24)).toBeNull()
|
||||
expect(controller.findNearestStash(stash1.x + 30, stash1.y, 48)).toEqual(stash1)
|
||||
expect(controller.findNearestStash(stash1.x + 60, stash1.y, 64)).toEqual(stash1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Distance threshold (<= 80px immediate open, > 80px pathfind)', () => {
|
||||
const stash = CANONICAL_TOWN_STASHES[1]! // (4512, 1720)
|
||||
|
||||
it('player at distance <= 80px opens stash immediately without movement', () => {
|
||||
const testDistances = [0, 20, 50, 75, 80]
|
||||
for (const dist of testDistances) {
|
||||
stashOpenCount = 0
|
||||
mockHudManager.leftPanel = 'none'
|
||||
engine.world.player.x = stash.x - dist
|
||||
engine.world.player.y = stash.y
|
||||
|
||||
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||
|
||||
expect(controller.activeClickType).toBe('stash')
|
||||
expect(controller.pendingInteraction).toBeNull()
|
||||
expect(controller.navTarget).toBeNull()
|
||||
expect(stashOpenCount).toBe(1)
|
||||
expect(statusElem.textContent).toContain('储物箱')
|
||||
}
|
||||
})
|
||||
|
||||
it('player at distance > 80px schedules pathfinding/walk and does not open stash immediately', () => {
|
||||
const testDistances = [81, 100, 250, 500]
|
||||
for (const dist of testDistances) {
|
||||
stashOpenCount = 0
|
||||
mockHudManager.leftPanel = 'none'
|
||||
engine.world.player.x = stash.x - dist
|
||||
engine.world.player.y = stash.y
|
||||
|
||||
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||
|
||||
expect(controller.activeClickType).toBe('stash')
|
||||
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
||||
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
||||
expect(stashOpenCount).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('player walks toward stash and opens automatically upon reaching <= 80px in tick()', () => {
|
||||
engine.world.player.x = stash.x - 300
|
||||
engine.world.player.y = stash.y
|
||||
|
||||
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||
|
||||
expect(stashOpenCount).toBe(0)
|
||||
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
||||
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
||||
|
||||
// Step 1: Walk to 200px away -> still too far
|
||||
engine.world.player.x = stash.x - 200
|
||||
controller.tick()
|
||||
expect(stashOpenCount).toBe(0)
|
||||
expect(controller.pendingInteraction).not.toBeNull()
|
||||
expect(controller.navTarget).toEqual({ x: stash.x, y: stash.y })
|
||||
|
||||
// Step 2: Walk to 90px away -> still outside 80px
|
||||
engine.world.player.x = stash.x - 90
|
||||
controller.tick()
|
||||
expect(stashOpenCount).toBe(0)
|
||||
expect(controller.pendingInteraction).not.toBeNull()
|
||||
|
||||
// Step 3: Arrive at 75px away (<= 80px) -> triggers open!
|
||||
engine.world.player.x = stash.x - 75
|
||||
controller.tick()
|
||||
expect(stashOpenCount).toBe(1)
|
||||
expect(controller.pendingInteraction).toBeNull()
|
||||
expect(controller.navTarget).toBeNull()
|
||||
expect(statusElem.textContent).toContain('储物箱')
|
||||
})
|
||||
|
||||
it('cancels pending stash interaction if user left-clicks ground or elsewhere', () => {
|
||||
engine.world.player.x = stash.x - 200
|
||||
engine.world.player.y = stash.y
|
||||
controller.handleLeftClickWorld(stash.x, stash.y)
|
||||
expect(controller.pendingInteraction).toEqual({ kind: 'stash', stash })
|
||||
|
||||
// Click ground at an unrelated point
|
||||
controller.handleLeftClickWorld(stash.x + 500, stash.y + 500)
|
||||
expect(controller.activeClickType).toBe('ground')
|
||||
expect(controller.pendingInteraction).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Gold Mechanics Stress Testing', () => {
|
||||
let panels: WorldPanelsHud
|
||||
let inv: { gold: number }
|
||||
|
||||
beforeEach(() => {
|
||||
panels = new WorldPanelsHud()
|
||||
inv = { gold: 500_000 }
|
||||
panels.stashGold = 100_000
|
||||
})
|
||||
|
||||
it('deposit and withdraw valid amounts: character gold + stash gold remains strictly constant', () => {
|
||||
const initialTotal = inv.gold + panels.stashGold // 600,000
|
||||
|
||||
const transferSequence = [10_000, 50_000, 100_000, 25_000, 1_000, 777]
|
||||
for (const amt of transferSequence) {
|
||||
const deposited = panels.depositGold(amt, inv)
|
||||
expect(deposited).toBe(amt)
|
||||
expect(inv.gold + panels.stashGold).toBe(initialTotal)
|
||||
}
|
||||
|
||||
const withdrawSequence = [5_000, 20_000, 80_000, 500, 123]
|
||||
for (const amt of withdrawSequence) {
|
||||
const withdrawn = panels.withdrawGold(amt, inv)
|
||||
expect(withdrawn).toBe(amt)
|
||||
expect(inv.gold + panels.stashGold).toBe(initialTotal)
|
||||
}
|
||||
})
|
||||
|
||||
it('stress tests 100 random valid transactions preserving gold conservation law', () => {
|
||||
const initialTotal = inv.gold + panels.stashGold // 600,000
|
||||
let seed = 123456789
|
||||
const nextRand = () => {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0
|
||||
return seed / 4294967296
|
||||
}
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (nextRand() > 0.5) {
|
||||
// Deposit random amount up to player's current gold
|
||||
const amt = Math.floor(nextRand() * (inv.gold + 1))
|
||||
panels.depositGold(amt, inv)
|
||||
} else {
|
||||
// Withdraw random amount up to stash's current gold
|
||||
const amt = Math.floor(nextRand() * (panels.stashGold + 1))
|
||||
panels.withdrawGold(amt, inv)
|
||||
}
|
||||
// Strict conservation invariant:
|
||||
expect(inv.gold + panels.stashGold).toBe(initialTotal)
|
||||
expect(inv.gold).toBeGreaterThanOrEqual(0)
|
||||
expect(panels.stashGold).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('negative values (e.g. -500 gold): must be rejected or no-op without balance mutation', () => {
|
||||
const initialInv = inv.gold
|
||||
const initialStash = panels.stashGold
|
||||
|
||||
const negativeAmounts = [-1, -500, -10_000, -2_500_000, -1e9, Number.MIN_SAFE_INTEGER]
|
||||
for (const neg of negativeAmounts) {
|
||||
expect(panels.depositGold(neg, inv)).toBe(0)
|
||||
expect(inv.gold).toBe(initialInv)
|
||||
expect(panels.stashGold).toBe(initialStash)
|
||||
|
||||
expect(panels.withdrawGold(neg, inv)).toBe(0)
|
||||
expect(inv.gold).toBe(initialInv)
|
||||
expect(panels.stashGold).toBe(initialStash)
|
||||
}
|
||||
})
|
||||
|
||||
it('zero and -0 value transfers: must be rejected as no-op', () => {
|
||||
expect(panels.depositGold(0, inv)).toBe(0)
|
||||
expect(panels.depositGold(-0, inv)).toBe(0)
|
||||
expect(panels.withdrawGold(0, inv)).toBe(0)
|
||||
expect(panels.withdrawGold(-0, inv)).toBe(0)
|
||||
expect(inv.gold).toBe(500_000)
|
||||
expect(panels.stashGold).toBe(100_000)
|
||||
})
|
||||
|
||||
it('exceeding player gold on deposit: deposits max available without creating negative player gold', () => {
|
||||
inv.gold = 35_000
|
||||
panels.stashGold = 50_000
|
||||
|
||||
const deposited = panels.depositGold(100_000, inv)
|
||||
expect(deposited).toBe(35_000)
|
||||
expect(inv.gold).toBe(0)
|
||||
expect(panels.stashGold).toBe(85_000)
|
||||
|
||||
// Further deposit when player gold is 0 yields 0
|
||||
expect(panels.depositGold(10_000, inv)).toBe(0)
|
||||
expect(inv.gold).toBe(0)
|
||||
expect(panels.stashGold).toBe(85_000)
|
||||
})
|
||||
|
||||
it('exceeding stash gold on withdraw: withdraws max available without creating negative stash gold', () => {
|
||||
panels.stashGold = 42_000
|
||||
inv.gold = 10_000
|
||||
|
||||
const withdrawn = panels.withdrawGold(100_000, inv)
|
||||
expect(withdrawn).toBe(42_000)
|
||||
expect(panels.stashGold).toBe(0)
|
||||
expect(inv.gold).toBe(52_000)
|
||||
|
||||
// Further withdrawal when stash gold is 0 yields 0
|
||||
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
||||
expect(panels.stashGold).toBe(0)
|
||||
expect(inv.gold).toBe(52_000)
|
||||
})
|
||||
|
||||
it('gold cap enforcement: character and stash gold cannot exceed 2,500,000', () => {
|
||||
expect(STASH_GOLD_CAP).toBe(2_500_000)
|
||||
expect(PLAYER_GOLD_CAP).toBe(2_500_000)
|
||||
|
||||
// Stash cap clamping on deposit
|
||||
panels.stashGold = 2_450_000
|
||||
inv.gold = 200_000
|
||||
const dep1 = panels.depositGold(100_000, inv)
|
||||
expect(dep1).toBe(50_000)
|
||||
expect(panels.stashGold).toBe(2_500_000)
|
||||
expect(inv.gold).toBe(150_000)
|
||||
|
||||
// Further deposits clamped to 0
|
||||
expect(panels.depositGold(50_000, inv)).toBe(0)
|
||||
expect(panels.stashGold).toBe(2_500_000)
|
||||
expect(inv.gold).toBe(150_000)
|
||||
|
||||
// Player cap clamping on withdrawal
|
||||
inv.gold = 2_480_000
|
||||
panels.stashGold = 500_000
|
||||
const w1 = panels.withdrawGold(100_000, inv)
|
||||
expect(w1).toBe(20_000)
|
||||
expect(inv.gold).toBe(2_500_000)
|
||||
expect(panels.stashGold).toBe(480_000)
|
||||
|
||||
// Further withdrawals clamped to 0
|
||||
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
||||
expect(inv.gold).toBe(2_500_000)
|
||||
expect(panels.stashGold).toBe(480_000)
|
||||
})
|
||||
|
||||
it('boundary caps: handles both character and stash at 2,500,000 or both at 0', () => {
|
||||
// Both at zero
|
||||
inv.gold = 0
|
||||
panels.stashGold = 0
|
||||
expect(panels.depositGold(10_000, inv)).toBe(0)
|
||||
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
||||
expect(inv.gold).toBe(0)
|
||||
expect(panels.stashGold).toBe(0)
|
||||
|
||||
// Both at maximum 2,500,000
|
||||
inv.gold = 2_500_000
|
||||
panels.stashGold = 2_500_000
|
||||
expect(panels.depositGold(10_000, inv)).toBe(0)
|
||||
expect(panels.withdrawGold(10_000, inv)).toBe(0)
|
||||
expect(inv.gold).toBe(2_500_000)
|
||||
expect(panels.stashGold).toBe(2_500_000)
|
||||
})
|
||||
|
||||
it('infinity and extreme numbers: safely clamps or rejects without corrupting finite state', () => {
|
||||
inv.gold = 300_000
|
||||
panels.stashGold = 100_000
|
||||
|
||||
// Deposit Infinity: clamped to player's entire available gold
|
||||
const depInf = panels.depositGold(Infinity, inv)
|
||||
expect(depInf).toBe(300_000)
|
||||
expect(inv.gold).toBe(0)
|
||||
expect(panels.stashGold).toBe(400_000)
|
||||
|
||||
// Withdraw Infinity: clamped to stash's entire available gold
|
||||
const wInf = panels.withdrawGold(Infinity, inv)
|
||||
expect(wInf).toBe(400_000)
|
||||
expect(inv.gold).toBe(400_000)
|
||||
expect(panels.stashGold).toBe(0)
|
||||
|
||||
// Negative infinity: rejected as <= 0
|
||||
expect(panels.depositGold(-Infinity, inv)).toBe(0)
|
||||
expect(panels.withdrawGold(-Infinity, inv)).toBe(0)
|
||||
expect(inv.gold).toBe(400_000)
|
||||
expect(panels.stashGold).toBe(0)
|
||||
|
||||
// Number.MAX_SAFE_INTEGER
|
||||
inv.gold = 50_000
|
||||
const depMaxSafe = panels.depositGold(Number.MAX_SAFE_INTEGER, inv)
|
||||
expect(depMaxSafe).toBe(50_000)
|
||||
expect(inv.gold).toBe(0)
|
||||
expect(panels.stashGold).toBe(50_000)
|
||||
})
|
||||
|
||||
it('handles simulated UI button clicks for deposit and withdraw', () => {
|
||||
inv.gold = 100_000
|
||||
panels.stashGold = 200_000
|
||||
|
||||
const clickCallbacks = {
|
||||
onClose: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
inventory: inv as any,
|
||||
}
|
||||
|
||||
// Click deposit button (center of STASH_DEPOSIT_BTN_BOUNDS)
|
||||
const depX = STASH_DEPOSIT_BTN_BOUNDS.x + 10
|
||||
const depY = STASH_DEPOSIT_BTN_BOUNDS.y + 10
|
||||
const handledDep = panels.handleLeftDockClick('stash', depX, depY, clickCallbacks)
|
||||
expect(handledDep).toBe(true)
|
||||
expect(panels.stashGold).toBe(250_000) // Default batch is 50,000
|
||||
expect(inv.gold).toBe(50_000)
|
||||
|
||||
// Click withdraw button (center of STASH_WITHDRAW_BTN_BOUNDS)
|
||||
const wX = STASH_WITHDRAW_BTN_BOUNDS.x + 10
|
||||
const wY = STASH_WITHDRAW_BTN_BOUNDS.y + 10
|
||||
const handledW = panels.handleLeftDockClick('stash', wX, wY, clickCallbacks)
|
||||
expect(handledW).toBe(true)
|
||||
expect(panels.stashGold).toBe(200_000)
|
||||
expect(inv.gold).toBe(100_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Dual-Dock Panel State Consistency', () => {
|
||||
let hudCanvas: HTMLCanvasElement
|
||||
let hud: HudManager
|
||||
|
||||
beforeEach(() => {
|
||||
hudCanvas = {
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width: 800, height: 600 }),
|
||||
clientWidth: 800,
|
||||
clientHeight: 600,
|
||||
width: 800,
|
||||
height: 600,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getContext: () => null,
|
||||
style: { cursor: '' },
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
hud = new HudManager(hudCanvas, {
|
||||
onWaypointTeleport: () => {},
|
||||
onToggleAutomap: () => {},
|
||||
})
|
||||
})
|
||||
|
||||
it('opening stash automatically opens left dock as "stash" and right dock as "inv"', () => {
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
expect(hud.rightPanel).toBe('none')
|
||||
expect(hud.inventory.visible).toBe(false)
|
||||
|
||||
hud.toggleLeftPanel('stash')
|
||||
|
||||
expect(hud.leftPanel).toBe('stash')
|
||||
expect(hud.rightPanel).toBe('inv')
|
||||
expect(hud.inventory.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('opening stash when inventory is already open preserves inventory on right dock', () => {
|
||||
hud.toggleRightPanel('inv')
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
expect(hud.rightPanel).toBe('inv')
|
||||
|
||||
hud.toggleLeftPanel('stash')
|
||||
expect(hud.leftPanel).toBe('stash')
|
||||
expect(hud.rightPanel).toBe('inv')
|
||||
expect(hud.inventory.visible).toBe(true)
|
||||
})
|
||||
|
||||
it('closing stash via toggle restores left dock to "none"', () => {
|
||||
hud.toggleLeftPanel('stash')
|
||||
expect(hud.leftPanel).toBe('stash')
|
||||
|
||||
hud.toggleLeftPanel('stash')
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
})
|
||||
|
||||
it('closeAllPanels closes both stash and inventory simultaneously with clean state', () => {
|
||||
hud.toggleLeftPanel('stash')
|
||||
expect(hud.leftPanel).toBe('stash')
|
||||
expect(hud.rightPanel).toBe('inv')
|
||||
expect(hud.inventory.visible).toBe(true)
|
||||
|
||||
const closed = hud.closeAllPanels()
|
||||
expect(closed).toBe(true)
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
expect(hud.rightPanel).toBe('none')
|
||||
expect(hud.inventory.visible).toBe(false)
|
||||
expect(hud.charSheet.visible).toBe(false)
|
||||
expect(hud.skillTree.visible).toBe(false)
|
||||
})
|
||||
|
||||
it('switching from stash to another left panel (e.g. quest) cleanly deactivates stash', () => {
|
||||
hud.toggleLeftPanel('stash')
|
||||
expect(hud.leftPanel).toBe('stash')
|
||||
|
||||
hud.toggleLeftPanel('quest')
|
||||
expect(hud.leftPanel).toBe('quest')
|
||||
expect(hud.rightPanel).toBe('inv') // Inventory remains open on right dock
|
||||
|
||||
hud.toggleLeftPanel('quest')
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
})
|
||||
|
||||
it('cycling through all left dock panels (char, quest, waypoint, stash) maintains dock invariant', () => {
|
||||
const leftPanels: ('char' | 'quest' | 'waypoint' | 'stash')[] = ['char', 'quest', 'waypoint', 'stash']
|
||||
for (const p of leftPanels) {
|
||||
hud.toggleLeftPanel(p)
|
||||
expect(hud.leftPanel).toBe(p)
|
||||
if (p === 'char') expect(hud.charSheet.visible).toBe(true)
|
||||
if (p === 'stash') {
|
||||
expect(hud.rightPanel).toBe('inv')
|
||||
expect(hud.inventory.visible).toBe(true)
|
||||
}
|
||||
}
|
||||
hud.closeAllPanels()
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
expect(hud.rightPanel).toBe('none')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,651 @@
|
|||
/**
|
||||
* tests/challenger-m5-stash-grid.test.ts
|
||||
*
|
||||
* Empirical Challenger Stress Test Suite for Milestone M5 (Issue #150):
|
||||
* Stash Storage Grid (6x8), Item Placement, Bounds Checking, Collision Detection,
|
||||
* Item Picking/Swapping, and Quick Transfer Mechanics.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import {
|
||||
STASH_GRID_ORIGIN,
|
||||
STASH_PANEL_ORIGIN,
|
||||
WorldPanelsHud,
|
||||
} from '../src/ui/world-panels.ts'
|
||||
import {
|
||||
InventoryPanel,
|
||||
type UiInventoryItem,
|
||||
INV_GRID_ORIGIN,
|
||||
} from '../src/ui/inventory.ts'
|
||||
|
||||
class MockWindow extends EventTarget {
|
||||
innerWidth = 1920
|
||||
innerHeight = 1080
|
||||
devicePixelRatio = 1
|
||||
}
|
||||
|
||||
if (typeof globalThis.window === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.window = new MockWindow()
|
||||
}
|
||||
if (typeof globalThis.HTMLInputElement === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.HTMLInputElement = class HTMLInputElement {}
|
||||
}
|
||||
if (typeof globalThis.HTMLSelectElement === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
||||
}
|
||||
|
||||
function createItem(id: string, code: string, w: number, h: number, nameZh: string): UiInventoryItem {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
invFile: code,
|
||||
name: nameZh,
|
||||
nameZh,
|
||||
baseNameZh: nameZh,
|
||||
quality: 'normal',
|
||||
invWidth: w,
|
||||
invHeight: h,
|
||||
allowedSlots: [],
|
||||
stats: [{ text: `Stress item ${id}`, color: 'white' }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('Challenger M5 Empirical Stress Tests: 6x8 Stash Storage Grid & Mechanics', () => {
|
||||
let panels: WorldPanelsHud
|
||||
let inv: InventoryPanel
|
||||
|
||||
beforeEach(() => {
|
||||
panels = new WorldPanelsHud()
|
||||
inv = new InventoryPanel()
|
||||
// Clear default inventory items so we start with a clean slate
|
||||
inv.gridItems = []
|
||||
})
|
||||
|
||||
describe('1. Comprehensive Multi-Size Item Placement & Coordinate Bounds', () => {
|
||||
const itemSizes: Array<{ name: string; code: string; w: number; h: number }> = [
|
||||
{ name: '1x1 Ring', code: 'rin', w: 1, h: 1 },
|
||||
{ name: '1x2 Potion', code: 'hp1', w: 1, h: 2 },
|
||||
{ name: '2x2 Helm', code: 'cap', w: 2, h: 2 },
|
||||
{ name: '2x3 Crystal Sword', code: 'crs', w: 2, h: 3 },
|
||||
{ name: '2x4 Pike', code: 'pik', w: 2, h: 4 },
|
||||
]
|
||||
|
||||
for (const { name, code, w, h } of itemSizes) {
|
||||
const maxCol = STASH_GRID_ORIGIN.cols - w
|
||||
const maxRow = STASH_GRID_ORIGIN.rows - h
|
||||
const totalValid = (maxCol + 1) * (maxRow + 1)
|
||||
|
||||
it(`exhaustively places ${name} (${w}x${h}) at all ${totalValid} valid coordinates on 6x8 grid`, () => {
|
||||
let testedCount = 0
|
||||
for (let col = 0; col <= maxCol; col++) {
|
||||
for (let row = 0; row <= maxRow; row++) {
|
||||
const freshHud = new WorldPanelsHud()
|
||||
const item = createItem(`item-${code}-${col}-${row}`, code, w, h, `${name} (${col},${row})`)
|
||||
|
||||
// Must be accepted
|
||||
expect(freshHud.canPlaceInStash(col, row, w, h)).toBe(true)
|
||||
|
||||
// Place item
|
||||
freshHud.stashItems.push({ item, col, row })
|
||||
expect(freshHud.stashItems).toHaveLength(1)
|
||||
|
||||
// Verify getStashOverlaps identifies this exact item
|
||||
const overlaps = freshHud.getStashOverlaps(col, row, w, h)
|
||||
expect(overlaps).toHaveLength(1)
|
||||
expect(overlaps[0]!.item.id).toBe(item.id)
|
||||
|
||||
// Check every single sub-cell occupied by this item
|
||||
for (let dc = 0; dc < w; dc++) {
|
||||
for (let dr = 0; dr < h; dr++) {
|
||||
expect(freshHud.canPlaceInStash(col + dc, row + dr, 1, 1)).toBe(false)
|
||||
}
|
||||
}
|
||||
|
||||
testedCount++
|
||||
}
|
||||
}
|
||||
expect(testedCount).toBe(totalValid)
|
||||
})
|
||||
}
|
||||
|
||||
it('enforces exact boundary conditions for 2x2 item: (4,6) passes; (5,6) and (4,7) rejected', () => {
|
||||
// (4, 6) -> col+2 = 6 <= 6, row+2 = 8 <= 8 (Valid bottom-rightmost coordinate for 2x2)
|
||||
expect(panels.canPlaceInStash(4, 6, 2, 2)).toBe(true)
|
||||
expect(() => panels.getStashOverlaps(4, 6, 2, 2)).not.toThrow()
|
||||
|
||||
// (5, 6) -> col+2 = 7 > 6 (Exceeds columns)
|
||||
expect(panels.canPlaceInStash(5, 6, 2, 2)).toBe(false)
|
||||
expect(() => panels.getStashOverlaps(5, 6, 2, 2)).toThrow(RangeError)
|
||||
|
||||
// (4, 7) -> row+2 = 9 > 8 (Exceeds rows)
|
||||
expect(panels.canPlaceInStash(4, 7, 2, 2)).toBe(false)
|
||||
expect(() => panels.getStashOverlaps(4, 7, 2, 2)).toThrow(RangeError)
|
||||
|
||||
// (5, 7) -> Exceeds both
|
||||
expect(panels.canPlaceInStash(5, 7, 2, 2)).toBe(false)
|
||||
expect(() => panels.getStashOverlaps(5, 7, 2, 2)).toThrow(RangeError)
|
||||
})
|
||||
|
||||
it('enforces boundary conditions for all other multi-cell sizes', () => {
|
||||
// 1x1: (5, 7) passes; (6, 7), (5, 8) rejected
|
||||
expect(panels.canPlaceInStash(5, 7, 1, 1)).toBe(true)
|
||||
expect(panels.canPlaceInStash(6, 7, 1, 1)).toBe(false)
|
||||
expect(panels.canPlaceInStash(5, 8, 1, 1)).toBe(false)
|
||||
|
||||
// 1x2: (5, 6) passes; (6, 6), (5, 7) rejected
|
||||
expect(panels.canPlaceInStash(5, 6, 1, 2)).toBe(true)
|
||||
expect(panels.canPlaceInStash(6, 6, 1, 2)).toBe(false)
|
||||
expect(panels.canPlaceInStash(5, 7, 1, 2)).toBe(false)
|
||||
|
||||
// 2x3: (4, 5) passes; (5, 5), (4, 6) rejected
|
||||
expect(panels.canPlaceInStash(4, 5, 2, 3)).toBe(true)
|
||||
expect(panels.canPlaceInStash(5, 5, 2, 3)).toBe(false)
|
||||
expect(panels.canPlaceInStash(4, 6, 2, 3)).toBe(false)
|
||||
|
||||
// 2x4: (4, 4) passes; (5, 4), (4, 5) rejected
|
||||
expect(panels.canPlaceInStash(4, 4, 2, 4)).toBe(true)
|
||||
expect(panels.canPlaceInStash(5, 4, 2, 4)).toBe(false)
|
||||
expect(panels.canPlaceInStash(4, 5, 2, 4)).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects negative and out-of-grid coordinates unconditionally', () => {
|
||||
const negativeCoords = [
|
||||
[-1, 0],
|
||||
[0, -1],
|
||||
[-1, -1],
|
||||
[-5, 2],
|
||||
[3, -4],
|
||||
[-100, -100],
|
||||
]
|
||||
for (const [col, row] of negativeCoords) {
|
||||
expect(panels.canPlaceInStash(col, row, 1, 1)).toBe(false)
|
||||
expect(() => panels.getStashOverlaps(col, row, 1, 1)).toThrow(RangeError)
|
||||
}
|
||||
|
||||
const outOfGridCoords = [
|
||||
[6, 0],
|
||||
[0, 8],
|
||||
[6, 8],
|
||||
[7, 0],
|
||||
[0, 9],
|
||||
[10, 10],
|
||||
[100, 100],
|
||||
]
|
||||
for (const [col, row] of outOfGridCoords) {
|
||||
expect(panels.canPlaceInStash(col, row, 1, 1)).toBe(false)
|
||||
expect(() => panels.getStashOverlaps(col, row, 1, 1)).toThrow(RangeError)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Collision Detection & Overlap Prevention', () => {
|
||||
it('verifies that an item cannot overlap even a single cell of an existing placed item', () => {
|
||||
// Place a 2x3 sword at (col: 2, row: 2).
|
||||
// Occupied cells:
|
||||
// (2,2), (3,2)
|
||||
// (2,3), (3,3)
|
||||
// (2,4), (3,4)
|
||||
const sword = createItem('sword-main', 'crs', 2, 3, '水晶剑 (Crystal Sword)')
|
||||
panels.stashItems.push({ item: sword, col: 2, row: 2 })
|
||||
|
||||
// 1. Every occupied cell cannot host a 1x1 item
|
||||
for (let c = 2; c <= 3; c++) {
|
||||
for (let r = 2; r <= 4; r++) {
|
||||
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
|
||||
const overlaps = panels.getStashOverlaps(c, r, 1, 1)
|
||||
expect(overlaps).toHaveLength(1)
|
||||
expect(overlaps[0]!.item.id).toBe('sword-main')
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Multi-cell items overlapping only a single corner cell
|
||||
// Top-Left corner: 2x2 at (1, 1) overlaps sword ONLY at (2, 2)
|
||||
expect(panels.canPlaceInStash(1, 1, 2, 2)).toBe(false)
|
||||
expect(panels.getStashOverlaps(1, 1, 2, 2)).toHaveLength(1)
|
||||
|
||||
// Top-Right corner: 2x2 at (3, 1) overlaps sword ONLY at (3, 2)
|
||||
expect(panels.canPlaceInStash(3, 1, 2, 2)).toBe(false)
|
||||
expect(panels.getStashOverlaps(3, 1, 2, 2)).toHaveLength(1)
|
||||
|
||||
// Bottom-Left corner: 2x2 at (1, 4) overlaps sword ONLY at (2, 4)
|
||||
expect(panels.canPlaceInStash(1, 4, 2, 2)).toBe(false)
|
||||
expect(panels.getStashOverlaps(1, 4, 2, 2)).toHaveLength(1)
|
||||
|
||||
// Bottom-Right corner: 2x2 at (3, 4) overlaps sword ONLY at (3, 4)
|
||||
expect(panels.canPlaceInStash(3, 4, 2, 2)).toBe(false)
|
||||
expect(panels.getStashOverlaps(3, 4, 2, 2)).toHaveLength(1)
|
||||
|
||||
// 1x2 overlapping only (2, 2) from top at (2, 1)
|
||||
expect(panels.canPlaceInStash(2, 1, 1, 2)).toBe(false)
|
||||
|
||||
// 1x2 overlapping only (3, 4) from bottom at (3, 4)
|
||||
expect(panels.canPlaceInStash(3, 4, 1, 2)).toBe(false)
|
||||
|
||||
// 3. Immediately adjacent non-touching cells are fully free
|
||||
expect(panels.canPlaceInStash(1, 2, 1, 1)).toBe(true) // left
|
||||
expect(panels.canPlaceInStash(1, 3, 1, 1)).toBe(true)
|
||||
expect(panels.canPlaceInStash(1, 4, 1, 1)).toBe(true)
|
||||
expect(panels.canPlaceInStash(4, 2, 1, 1)).toBe(true) // right
|
||||
expect(panels.canPlaceInStash(4, 3, 1, 1)).toBe(true)
|
||||
expect(panels.canPlaceInStash(4, 4, 1, 1)).toBe(true)
|
||||
expect(panels.canPlaceInStash(2, 1, 1, 1)).toBe(true) // top
|
||||
expect(panels.canPlaceInStash(3, 1, 1, 1)).toBe(true)
|
||||
expect(panels.canPlaceInStash(2, 5, 1, 1)).toBe(true) // bottom
|
||||
expect(panels.canPlaceInStash(3, 5, 1, 1)).toBe(true)
|
||||
})
|
||||
|
||||
it('fills stash to maximum capacity (48 1x1 items) and asserts canPlaceInStash & autoPlaceInStash return false', () => {
|
||||
// 6 columns * 8 rows = 48 items
|
||||
for (let c = 0; c < 6; c++) {
|
||||
for (let r = 0; r < 8; r++) {
|
||||
const item = createItem(`gem-${c}-${r}`, 'gem', 1, 1, `宝石 (${c},${r})`)
|
||||
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(true)
|
||||
panels.stashItems.push({ item, col: c, row: r })
|
||||
}
|
||||
}
|
||||
expect(panels.stashItems).toHaveLength(48)
|
||||
|
||||
// Now every coordinate and size must return false
|
||||
for (let c = 0; c < 6; c++) {
|
||||
for (let r = 0; r < 8; r++) {
|
||||
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Any attempt to place any size must fail
|
||||
const testItem1x1 = createItem('overflow-1x1', 'rin', 1, 1, '溢出戒指')
|
||||
const testItem2x2 = createItem('overflow-2x2', 'cap', 2, 2, '溢出头盔')
|
||||
const testItem2x4 = createItem('overflow-2x4', 'pik', 2, 4, '溢出长枪')
|
||||
|
||||
expect(panels.autoPlaceInStash(testItem1x1)).toBe(false)
|
||||
expect(panels.autoPlaceInStash(testItem2x2)).toBe(false)
|
||||
expect(panels.autoPlaceInStash(testItem2x4)).toBe(false)
|
||||
expect(panels.stashItems).toHaveLength(48)
|
||||
})
|
||||
|
||||
it('tiles stash completely with 12 2x2 items and asserts zero further placements', () => {
|
||||
// 3 cols of 2x2 (cols 0, 2, 4) * 4 rows of 2x2 (rows 0, 2, 4, 6) = 12 items (48 cells)
|
||||
for (let c = 0; c < 6; c += 2) {
|
||||
for (let r = 0; r < 8; r += 2) {
|
||||
const shield = createItem(`shield-${c}-${r}`, 'kit', 2, 2, `盾牌 (${c},${r})`)
|
||||
expect(panels.canPlaceInStash(c, r, 2, 2)).toBe(true)
|
||||
panels.stashItems.push({ item: shield, col: c, row: r })
|
||||
}
|
||||
}
|
||||
expect(panels.stashItems).toHaveLength(12)
|
||||
|
||||
// Stash is 100% saturated
|
||||
for (let c = 0; c < 6; c++) {
|
||||
for (let r = 0; r < 8; r++) {
|
||||
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
|
||||
}
|
||||
}
|
||||
expect(panels.autoPlaceInStash(createItem('fail-1x1', 'rin', 1, 1, '戒指'))).toBe(false)
|
||||
expect(panels.autoPlaceInStash(createItem('fail-2x2', 'cap', 2, 2, '头盔'))).toBe(false)
|
||||
})
|
||||
|
||||
it('tiles stash completely with 6 2x4 items and asserts zero further placements', () => {
|
||||
// 3 cols of 2x4 (cols 0, 2, 4) * 2 rows of 2x4 (rows 0, 4) = 6 items (48 cells)
|
||||
for (let c = 0; c < 6; c += 2) {
|
||||
for (let r = 0; r < 8; r += 4) {
|
||||
const pike = createItem(`pike-${c}-${r}`, 'pik', 2, 4, `长枪 (${c},${r})`)
|
||||
expect(panels.canPlaceInStash(c, r, 2, 4)).toBe(true)
|
||||
panels.stashItems.push({ item: pike, col: c, row: r })
|
||||
}
|
||||
}
|
||||
expect(panels.stashItems).toHaveLength(6)
|
||||
|
||||
// Fully covered
|
||||
for (let c = 0; c < 6; c++) {
|
||||
for (let r = 0; r < 8; r++) {
|
||||
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(false)
|
||||
}
|
||||
}
|
||||
expect(panels.autoPlaceInStash(createItem('fail-1x1', 'rin', 1, 1, '戒指'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Item Picking, Swapping, and Drag-and-Drop', () => {
|
||||
it('picks up multi-cell item onto cursor from any constituent cell and verifies vacancy', () => {
|
||||
const sword = createItem('sword-pick', 'crs', 2, 3, '水晶剑')
|
||||
panels.stashItems.push({ item: sword, col: 1, row: 2 })
|
||||
|
||||
// The sword spans cols 1..2, rows 2..4 (6 sub-cells).
|
||||
// Test that clicking ANY of these 6 cells with empty cursor picks up the sword.
|
||||
const constituentCells = [
|
||||
[1, 2], [2, 2],
|
||||
[1, 3], [2, 3],
|
||||
[1, 4], [2, 4],
|
||||
]
|
||||
|
||||
for (const [col, row] of constituentCells) {
|
||||
// Reset stash with the sword
|
||||
panels.stashItems = [{ item: sword, col: 1, row: 2 }]
|
||||
|
||||
const pickRes = panels.clickStashCell(col, row, null)
|
||||
expect(pickRes.handled).toBe(true)
|
||||
expect(pickRes.newCursorItem).toBeDefined()
|
||||
expect(pickRes.newCursorItem?.id).toBe('sword-pick')
|
||||
expect(panels.stashItems).toHaveLength(0)
|
||||
|
||||
// All 6 cells must now be vacant
|
||||
for (const [c, r] of constituentCells) {
|
||||
expect(panels.canPlaceInStash(c, r, 1, 1)).toBe(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Clicking empty cell returns handled: false
|
||||
const emptyRes = panels.clickStashCell(0, 0, null)
|
||||
expect(emptyRes.handled).toBe(false)
|
||||
expect(emptyRes.newCursorItem).toBeNull()
|
||||
})
|
||||
|
||||
it('drops item from cursor onto empty cells', () => {
|
||||
const helm = createItem('helm-drop', 'cap', 2, 2, '军帽')
|
||||
const placeRes = panels.clickStashCell(3, 4, helm)
|
||||
expect(placeRes.handled).toBe(true)
|
||||
expect(placeRes.newCursorItem).toBeNull()
|
||||
expect(panels.stashItems).toHaveLength(1)
|
||||
expect(panels.stashItems[0]!.item.id).toBe('helm-drop')
|
||||
expect(panels.stashItems[0]!.col).toBe(3)
|
||||
expect(panels.stashItems[0]!.row).toBe(4)
|
||||
})
|
||||
|
||||
it('swaps items of equal size (2x2 with 2x2)', () => {
|
||||
const helmA = createItem('helm-a', 'cap', 2, 2, '暗金军帽')
|
||||
const helmB = createItem('helm-b', 'ghm', 2, 2, '野蛮人头盔')
|
||||
panels.stashItems.push({ item: helmA, col: 1, row: 1 })
|
||||
|
||||
// Click at (1, 1) holding helmB
|
||||
const swapRes = panels.clickStashCell(1, 1, helmB)
|
||||
expect(swapRes.handled).toBe(true)
|
||||
expect(swapRes.newCursorItem?.id).toBe('helm-a')
|
||||
expect(panels.stashItems).toHaveLength(1)
|
||||
expect(panels.stashItems[0]!.item.id).toBe('helm-b')
|
||||
expect(panels.stashItems[0]!.col).toBe(1)
|
||||
expect(panels.stashItems[0]!.row).toBe(1)
|
||||
})
|
||||
|
||||
it('swaps items of different sizes (1x1 with 2x2) when surrounding cells are free', () => {
|
||||
const ring = createItem('ring-swap', 'rin', 1, 1, '乔丹之石')
|
||||
const shield = createItem('shield-swap', 'kit', 2, 2, '暴风之盾')
|
||||
panels.stashItems.push({ item: ring, col: 2, row: 2 })
|
||||
|
||||
// Shield (2x2) dropped on (2, 2). Only ring is in that 2x2 area.
|
||||
const swapRes = panels.clickStashCell(2, 2, shield)
|
||||
expect(swapRes.handled).toBe(true)
|
||||
expect(swapRes.newCursorItem?.id).toBe('ring-swap')
|
||||
expect(panels.stashItems).toHaveLength(1)
|
||||
expect(panels.stashItems[0]!.item.id).toBe('shield-swap')
|
||||
expect(panels.stashItems[0]!.col).toBe(2)
|
||||
expect(panels.stashItems[0]!.row).toBe(2)
|
||||
|
||||
// Reverse swap: drop ring (1x1) on shield (2x2) at (2, 2)
|
||||
const reverseSwap = panels.clickStashCell(2, 2, swapRes.newCursorItem)
|
||||
expect(reverseSwap.handled).toBe(true)
|
||||
expect(reverseSwap.newCursorItem?.id).toBe('shield-swap')
|
||||
expect(panels.stashItems).toHaveLength(1)
|
||||
expect(panels.stashItems[0]!.item.id).toBe('ring-swap')
|
||||
expect(panels.stashItems[0]!.col).toBe(2)
|
||||
expect(panels.stashItems[0]!.row).toBe(2)
|
||||
})
|
||||
|
||||
it('safely prevents swap if placement collides with a third item (multiple overlaps)', () => {
|
||||
const ringA = createItem('ring-a', 'rin', 1, 1, '戒指 A')
|
||||
const ringC = createItem('ring-c', 'rin', 1, 1, '戒指 C')
|
||||
const shield = createItem('shield-multi', 'kit', 2, 2, '大盾牌')
|
||||
|
||||
// Place ringA at (2, 2) and ringC at (3, 2)
|
||||
panels.stashItems.push({ item: ringA, col: 2, row: 2 })
|
||||
panels.stashItems.push({ item: ringC, col: 3, row: 2 })
|
||||
|
||||
// Attempt to place shield (2x2) at (2, 2). It overlaps BOTH ringA and ringC.
|
||||
const failSwap = panels.clickStashCell(2, 2, shield)
|
||||
expect(failSwap.handled).toBe(false)
|
||||
expect(failSwap.newCursorItem?.id).toBe('shield-multi') // Still held on cursor!
|
||||
|
||||
// Both rings remain completely untouched
|
||||
expect(panels.stashItems).toHaveLength(2)
|
||||
expect(panels.stashItems.map(p => p.item.id).sort()).toEqual(['ring-a', 'ring-c'])
|
||||
|
||||
// Add a third item: ringD at (2, 3) -> 3 overlaps
|
||||
const ringD = createItem('ring-d', 'rin', 1, 1, '戒指 D')
|
||||
panels.stashItems.push({ item: ringD, col: 2, row: 3 })
|
||||
const failSwap3 = panels.clickStashCell(2, 2, shield)
|
||||
expect(failSwap3.handled).toBe(false)
|
||||
expect(failSwap3.newCursorItem?.id).toBe('shield-multi')
|
||||
expect(panels.stashItems).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('safely prevents swap or drop if placement would cause out-of-bounds', () => {
|
||||
const ring = createItem('ring-corner', 'rin', 1, 1, '角隅戒指')
|
||||
const shield = createItem('shield-oob', 'kit', 2, 2, '盾牌')
|
||||
panels.stashItems.push({ item: ring, col: 5, row: 7 })
|
||||
|
||||
// Attempt to drop shield at (5, 7) -> 5+2=7 > 6, 7+2=9 > 8
|
||||
const failRes = panels.clickStashCell(5, 7, shield)
|
||||
expect(failRes.handled).toBe(false)
|
||||
expect(failRes.newCursorItem?.id).toBe('shield-oob')
|
||||
expect(panels.stashItems).toHaveLength(1)
|
||||
expect(panels.stashItems[0]!.item.id).toBe('ring-corner')
|
||||
|
||||
// Negative coordinates with held item
|
||||
const negRes = panels.clickStashCell(-1, 0, shield)
|
||||
expect(negRes.handled).toBe(false)
|
||||
expect(negRes.newCursorItem?.id).toBe('shield-oob')
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Quick Transfer (Shift+Click / Right-Click) Stress Testing', () => {
|
||||
it('rapidly transfers 20 items from inventory to stash with zero loss and zero duplication', () => {
|
||||
// 10 1x1 items + 10 1x2 items = 30 cells (fits comfortably in 10x4 inventory)
|
||||
const generatedItems: UiInventoryItem[] = []
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const item1x1 = createItem(`burst-1x1-${i}`, 'rin', 1, 1, `戒指 ${i}`)
|
||||
generatedItems.push(item1x1)
|
||||
expect(inv.autoPlaceInGrid(item1x1)).toBe(true)
|
||||
|
||||
const item1x2 = createItem(`burst-1x2-${i}`, 'hp1', 1, 2, `药剂 ${i}`)
|
||||
generatedItems.push(item1x2)
|
||||
expect(inv.autoPlaceInGrid(item1x2)).toBe(true)
|
||||
}
|
||||
|
||||
expect(inv.gridItems).toHaveLength(20)
|
||||
expect(panels.stashItems).toHaveLength(0)
|
||||
|
||||
// Rapidly transfer all 20 items to stash
|
||||
for (const item of generatedItems) {
|
||||
const ok = panels.quickTransferToStash(item, inv)
|
||||
expect(ok, `Item ${item.id} should successfully transfer`).toBe(true)
|
||||
}
|
||||
|
||||
// Assert inventory is completely empty, stash has all 20 items
|
||||
expect(inv.gridItems).toHaveLength(0)
|
||||
expect(panels.stashItems).toHaveLength(20)
|
||||
|
||||
// Invariance check: verify exact set of item IDs
|
||||
const stashItemIds = panels.stashItems.map(p => p.item.id).sort()
|
||||
const expectedIds = generatedItems.map(i => i.id).sort()
|
||||
expect(stashItemIds).toEqual(expectedIds)
|
||||
expect(new Set(stashItemIds).size).toBe(20) // Zero duplicates
|
||||
})
|
||||
|
||||
it('rejects larger items (2x2) when stash has only 1x1 slots free, while 1x1 items successfully transfer', () => {
|
||||
// Create a fragmented stash layout:
|
||||
// Fill cols 0..3 for all rows 0..7 (32 cells) with four 2x4 pikes
|
||||
panels.stashItems.push({ item: createItem('fill-p1', 'pik', 2, 4, '长枪 1'), col: 0, row: 0 })
|
||||
panels.stashItems.push({ item: createItem('fill-p2', 'pik', 2, 4, '长枪 2'), col: 0, row: 4 })
|
||||
panels.stashItems.push({ item: createItem('fill-p3', 'pik', 2, 4, '长枪 3'), col: 2, row: 0 })
|
||||
panels.stashItems.push({ item: createItem('fill-p4', 'pik', 2, 4, '长枪 4'), col: 2, row: 4 })
|
||||
|
||||
// Now cols 4..5 are remaining (2 cols * 8 rows = 16 cells).
|
||||
// We fill rows 0..5 with three 2x2 shields at row 0, row 2, row 4
|
||||
panels.stashItems.push({ item: createItem('fill-s1', 'kit', 2, 2, '盾牌 1'), col: 4, row: 0 })
|
||||
panels.stashItems.push({ item: createItem('fill-s2', 'kit', 2, 2, '盾牌 2'), col: 4, row: 2 })
|
||||
panels.stashItems.push({ item: createItem('fill-s3', 'kit', 2, 2, '盾牌 3'), col: 4, row: 4 })
|
||||
|
||||
// Now only rows 6 and 7 in cols 4..5 are left (4 cells: (4,6), (5,6), (4,7), (5,7)).
|
||||
// Place two 1x1 gems at (4,6) and (5,7) in a checkerboard pattern!
|
||||
panels.stashItems.push({ item: createItem('gem-4-6', 'gem', 1, 1, '宝石 1'), col: 4, row: 6 })
|
||||
panels.stashItems.push({ item: createItem('gem-5-7', 'gem', 1, 1, '宝石 2'), col: 5, row: 7 })
|
||||
|
||||
// Stash has only TWO isolated 1x1 cells free: (5, 6) and (4, 7).
|
||||
// Verify no 2x2 or 1x2 or 2x1 item can fit!
|
||||
expect(panels.canPlaceInStash(5, 6, 1, 1)).toBe(true)
|
||||
expect(panels.canPlaceInStash(4, 7, 1, 1)).toBe(true)
|
||||
expect(panels.canPlaceInStash(4, 6, 2, 2)).toBe(false)
|
||||
expect(panels.canPlaceInStash(5, 6, 1, 2)).toBe(false)
|
||||
expect(panels.canPlaceInStash(4, 7, 2, 1)).toBe(false)
|
||||
|
||||
// Setup inventory with:
|
||||
// 1 2x2 shield
|
||||
// 2 1x1 rings (fit perfectly into remaining 2 slots)
|
||||
// 1 1x1 rune (will overflow once those 2 slots are filled)
|
||||
const bigShield = createItem('big-shield', 'kit', 2, 2, '巨大盾牌')
|
||||
const ring1 = createItem('ring-fit-1', 'rin', 1, 1, '契合戒指 1')
|
||||
const ring2 = createItem('ring-fit-2', 'rin', 1, 1, '契合戒指 2')
|
||||
const ringOverflow = createItem('ring-overflow', 'rin', 1, 1, '溢出戒指')
|
||||
|
||||
inv.autoPlaceInGrid(bigShield)
|
||||
inv.autoPlaceInGrid(ring1)
|
||||
inv.autoPlaceInGrid(ring2)
|
||||
inv.autoPlaceInGrid(ringOverflow)
|
||||
expect(inv.gridItems).toHaveLength(4)
|
||||
|
||||
const initialStashCount = panels.stashItems.length
|
||||
|
||||
// 1. Attempt to quick-transfer 2x2 shield: MUST be rejected!
|
||||
const shieldTransfer = panels.quickTransferToStash(bigShield, inv)
|
||||
expect(shieldTransfer).toBe(false)
|
||||
expect(inv.gridItems.some(p => p.item.id === 'big-shield')).toBe(true)
|
||||
expect(panels.stashItems).toHaveLength(initialStashCount)
|
||||
|
||||
// 2. Transfer first 1x1 ring: MUST succeed
|
||||
const ring1Transfer = panels.quickTransferToStash(ring1, inv)
|
||||
expect(ring1Transfer).toBe(true)
|
||||
expect(inv.gridItems.some(p => p.item.id === 'ring-fit-1')).toBe(false)
|
||||
expect(panels.stashItems).toHaveLength(initialStashCount + 1)
|
||||
|
||||
// 3. Transfer second 1x1 ring: MUST succeed
|
||||
const ring2Transfer = panels.quickTransferToStash(ring2, inv)
|
||||
expect(ring2Transfer).toBe(true)
|
||||
expect(inv.gridItems.some(p => p.item.id === 'ring-fit-2')).toBe(false)
|
||||
expect(panels.stashItems).toHaveLength(initialStashCount + 2)
|
||||
|
||||
// 4. Stash is now 100% full (48/48 cells).
|
||||
// Attempt to transfer third 1x1 ring: MUST fail!
|
||||
const overflowTransfer = panels.quickTransferToStash(ringOverflow, inv)
|
||||
expect(overflowTransfer).toBe(false)
|
||||
expect(inv.gridItems.some(p => p.item.id === 'ring-overflow')).toBe(true)
|
||||
expect(panels.stashItems).toHaveLength(initialStashCount + 2)
|
||||
|
||||
// Zero duplication, zero loss check
|
||||
expect(inv.gridItems).toHaveLength(2) // bigShield and ringOverflow remain
|
||||
expect(panels.stashItems.find(p => p.item.id === 'ring-fit-1')).toBeDefined()
|
||||
expect(panels.stashItems.find(p => p.item.id === 'ring-fit-2')).toBeDefined()
|
||||
})
|
||||
|
||||
it('executes Shift+Click and Right-Click quick transfer via handleLeftDockClick', () => {
|
||||
const ring = createItem('shift-ring', 'rin', 1, 1, '快捷戒指')
|
||||
panels.stashItems.push({ item: ring, col: 0, row: 0 })
|
||||
|
||||
// Calculate pixel coordinates for stash grid (0, 0)
|
||||
const cellPx = STASH_GRID_ORIGIN.cellPx
|
||||
const clickX = STASH_GRID_ORIGIN.x + cellPx / 2
|
||||
const clickY = STASH_GRID_ORIGIN.y + cellPx / 2
|
||||
|
||||
// 1. Shift+Click transfers from stash to inventory
|
||||
const shiftHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
|
||||
onClose: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
inventory: inv,
|
||||
isShiftClick: true,
|
||||
isRightClick: false,
|
||||
})
|
||||
expect(shiftHandled).toBe(true)
|
||||
expect(panels.stashItems).toHaveLength(0)
|
||||
expect(inv.gridItems).toHaveLength(1)
|
||||
expect(inv.gridItems[0]!.item.id).toBe('shift-ring')
|
||||
|
||||
// Put it back in stash for Right-Click test
|
||||
panels.quickTransferToStash(ring, inv)
|
||||
expect(panels.stashItems).toHaveLength(1)
|
||||
expect(inv.gridItems).toHaveLength(0)
|
||||
|
||||
// 2. Right-Click transfers from stash to inventory
|
||||
const rightHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
|
||||
onClose: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
inventory: inv,
|
||||
isShiftClick: false,
|
||||
isRightClick: true,
|
||||
})
|
||||
expect(rightHandled).toBe(true)
|
||||
expect(panels.stashItems).toHaveLength(0)
|
||||
expect(inv.gridItems).toHaveLength(1)
|
||||
expect(inv.gridItems[0]!.item.id).toBe('shift-ring')
|
||||
|
||||
// 3. Shift+Click on empty cell does nothing gracefully
|
||||
const emptyClickHandled = panels.handleLeftDockClick('stash', clickX, clickY, {
|
||||
onClose: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
inventory: inv,
|
||||
isShiftClick: true,
|
||||
isRightClick: false,
|
||||
})
|
||||
expect(emptyClickHandled).toBe(true)
|
||||
expect(panels.stashItems).toHaveLength(0)
|
||||
expect(inv.gridItems).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('performs bidirectional ping-pong transfer (15 items x 10 cycles) maintaining 100% invariance', () => {
|
||||
const items: UiInventoryItem[] = []
|
||||
// 5 1x1 + 5 1x2 + 5 2x2 = 5 + 10 + 20 = 35 cells (fits in 40-cell inventory)
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const i1 = createItem(`ping-1x1-${i}`, 'rin', 1, 1, `戒指 ${i}`)
|
||||
const i2 = createItem(`ping-1x2-${i}`, 'hp1', 1, 2, `药水 ${i}`)
|
||||
const i3 = createItem(`ping-2x2-${i}`, 'cap', 2, 2, `头盔 ${i}`)
|
||||
items.push(i1, i2, i3)
|
||||
inv.autoPlaceInGrid(i1)
|
||||
inv.autoPlaceInGrid(i2)
|
||||
inv.autoPlaceInGrid(i3)
|
||||
}
|
||||
|
||||
expect(items).toHaveLength(15)
|
||||
expect(inv.gridItems).toHaveLength(15)
|
||||
expect(panels.stashItems).toHaveLength(0)
|
||||
|
||||
const originalItemIds = items.map(it => it.id).sort()
|
||||
|
||||
// 10 Round-Trip Cycles
|
||||
for (let cycle = 1; cycle <= 10; cycle++) {
|
||||
// Transfer all from Inventory to Stash
|
||||
for (const item of items) {
|
||||
const success = panels.quickTransferToStash(item, inv)
|
||||
expect(success, `Cycle ${cycle}: Quick transfer to stash failed for ${item.id}`).toBe(true)
|
||||
}
|
||||
expect(inv.gridItems).toHaveLength(0)
|
||||
expect(panels.stashItems).toHaveLength(15)
|
||||
const stashIds = panels.stashItems.map(p => p.item.id).sort()
|
||||
expect(stashIds).toEqual(originalItemIds)
|
||||
|
||||
// Transfer all from Stash to Inventory
|
||||
for (const item of items) {
|
||||
const success = panels.quickTransferFromStash(item, inv)
|
||||
expect(success, `Cycle ${cycle}: Quick transfer from stash failed for ${item.id}`).toBe(true)
|
||||
}
|
||||
expect(panels.stashItems).toHaveLength(0)
|
||||
expect(inv.gridItems).toHaveLength(15)
|
||||
const invIds = inv.gridItems.map(p => p.item.id).sort()
|
||||
expect(invIds).toEqual(originalItemIds)
|
||||
}
|
||||
|
||||
// Final verification: 0 items lost, 0 duplicates
|
||||
expect(inv.gridItems).toHaveLength(15)
|
||||
expect(panels.stashItems).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,451 @@
|
|||
/**
|
||||
* Empirical Challenger Stress Test Suite for Milestone M6:
|
||||
* Normal Attack Icon Resolution, Dual Slot Binding Permutations, and Hotkey Switching.
|
||||
*
|
||||
* Ground Truth: Diablo II v1.13c Skill System Parity (Issue #151)
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { readFileSync, existsSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
SkillHotkeysHud,
|
||||
resolveSkillIcon,
|
||||
getAvailableSkillsForSide,
|
||||
LEFT_SKILL_BOUNDS,
|
||||
RIGHT_SKILL_BOUNDS,
|
||||
type HotkeySkillEntry,
|
||||
} from '../src/ui/hotkeys.ts'
|
||||
|
||||
describe('Empirical Challenger Stress Suite: M6 Normal Attack & Dual Slot Bindings', () => {
|
||||
const rootDir = process.cwd()
|
||||
|
||||
// =========================================================================
|
||||
// 1. Icon Resolution & Asset Existence
|
||||
// =========================================================================
|
||||
describe('1. Icon Resolution & Disk Asset Integrity', () => {
|
||||
it('verifies resolveSkillIcon(0) resolves to authentic Attack sword icon metadata', () => {
|
||||
const mapping = resolveSkillIcon(0)
|
||||
expect(mapping).toBeDefined()
|
||||
expect(mapping.skillId).toBe(0)
|
||||
expect(mapping.name).toBe('Attack')
|
||||
expect(mapping.iconPath).toBe('/skills/icon_0.png')
|
||||
expect(mapping.isGeneric).toBe(true)
|
||||
expect(mapping.dc6Path).toBe('data/global/ui/SPELLS/Skillicon.DC6')
|
||||
expect(mapping.dc6Source).toBe('data/global/ui/SPELLS/Skillicon.DC6')
|
||||
expect(mapping.frameIndex).toBe(2)
|
||||
expect(mapping.atlasRect).toEqual({
|
||||
x: 96,
|
||||
y: 0,
|
||||
width: 48,
|
||||
height: 48,
|
||||
w: 48,
|
||||
h: 48,
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies resolveSkillIcon handles numeric variations consistently', () => {
|
||||
const zeroPos = resolveSkillIcon(+0)
|
||||
const zeroNeg = resolveSkillIcon(-0)
|
||||
const zeroNum = resolveSkillIcon(Number(0))
|
||||
|
||||
expect(zeroPos).toEqual(zeroNeg)
|
||||
expect(zeroPos).toEqual(zeroNum)
|
||||
expect(zeroPos.skillId).toBe(0)
|
||||
expect(zeroPos.name).toBe('Attack')
|
||||
|
||||
// Contrast with non-zero skills
|
||||
const skill47 = resolveSkillIcon(47)
|
||||
expect(skill47.skillId).toBe(47)
|
||||
expect(skill47.isGeneric).toBe(false)
|
||||
expect(skill47.iconPath).toBe('/skills/icon_47.png')
|
||||
})
|
||||
|
||||
const targetPngs = [
|
||||
{
|
||||
path: join(rootDir, 'public', 'skills', 'icon_0.png'),
|
||||
name: 'icon_0.png (skills)',
|
||||
expectedWidth: 48,
|
||||
expectedHeight: 48,
|
||||
},
|
||||
{
|
||||
path: join(rootDir, 'public', 'skills', 'icon_0_active.png'),
|
||||
name: 'icon_0_active.png (skills)',
|
||||
expectedWidth: 48,
|
||||
expectedHeight: 48,
|
||||
},
|
||||
{
|
||||
path: join(rootDir, 'public', 'ui', 'icon_0.png'),
|
||||
name: 'icon_0.png (ui)',
|
||||
expectedWidth: 48,
|
||||
expectedHeight: 48,
|
||||
},
|
||||
]
|
||||
|
||||
for (const file of targetPngs) {
|
||||
it(`verifies ${file.name} exists on disk and is a valid 48x48 PNG with authentic headers`, () => {
|
||||
expect(existsSync(file.path)).toBe(true)
|
||||
const stat = statSync(file.path)
|
||||
expect(stat.size).toBeGreaterThan(100) // valid png is larger than dummy stub
|
||||
expect(stat.size).toBeLessThan(1024 * 1024) // sanity upper bound
|
||||
|
||||
const buf = readFileSync(file.path)
|
||||
|
||||
// 1. PNG 8-byte Magic Signature (\x89PNG\r\n\x1a\n)
|
||||
const magic = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
||||
for (let i = 0; i < 8; i++) {
|
||||
expect(buf[i]).toBe(magic[i])
|
||||
}
|
||||
|
||||
// 2. IHDR Chunk Header
|
||||
const ihdrLength = buf.readUInt32BE(8)
|
||||
expect(ihdrLength).toBe(13) // standard IHDR length
|
||||
const ihdrType = buf.toString('ascii', 12, 16)
|
||||
expect(ihdrType).toBe('IHDR')
|
||||
|
||||
// 3. Exact 48x48 Dimensions
|
||||
const width = buf.readUInt32BE(16)
|
||||
const height = buf.readUInt32BE(20)
|
||||
expect(width).toBe(file.expectedWidth)
|
||||
expect(height).toBe(file.expectedHeight)
|
||||
|
||||
// 4. Bit depth and valid color type
|
||||
const bitDepth = buf[24]
|
||||
expect([8, 16]).toContain(bitDepth)
|
||||
const colorType = buf[25]
|
||||
expect([0, 2, 3, 4, 6]).toContain(colorType)
|
||||
|
||||
// 5. IEND Chunk at file end
|
||||
const endChunkType = buf.toString('ascii', buf.length - 8, buf.length - 4)
|
||||
expect(endChunkType).toBe('IEND')
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 2. Slot Binding Permutations & Invariance
|
||||
// =========================================================================
|
||||
describe('2. Slot Binding Permutations & Invariance', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
})
|
||||
|
||||
it('verifies getAvailableSkillsForSide includes Skill 0 (Attack) in both left and right palettes', () => {
|
||||
const leftSkills = getAvailableSkillsForSide('left')
|
||||
const rightSkills = getAvailableSkillsForSide('right')
|
||||
|
||||
const leftAttack = leftSkills.find((s) => s.skillId === 0)
|
||||
const rightAttack = rightSkills.find((s) => s.skillId === 0)
|
||||
|
||||
expect(leftAttack).toBeDefined()
|
||||
expect(rightAttack).toBeDefined()
|
||||
|
||||
expect(leftAttack!.name).toBe('Attack')
|
||||
expect(leftAttack!.nameZh).toBe('普通攻击')
|
||||
expect(leftAttack!.leftUsable).toBe(true)
|
||||
expect(leftAttack!.rightUsable).toBe(true)
|
||||
|
||||
expect(rightAttack!.name).toBe('Attack')
|
||||
expect(rightAttack!.nameZh).toBe('普通攻击')
|
||||
expect(rightAttack!.leftUsable).toBe(true)
|
||||
expect(rightAttack!.rightUsable).toBe(true)
|
||||
})
|
||||
|
||||
it('assigns Attack to left slot and right slot simultaneously (Dual Attack)', () => {
|
||||
hud.assignSkill('left', 0)
|
||||
hud.assignSkill('right', 0)
|
||||
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
|
||||
// Test setDualSkill convenience method
|
||||
hud.assignSkill('left', 47)
|
||||
hud.assignSkill('right', 64)
|
||||
expect(hud.leftSkillId).toBe(47)
|
||||
expect(hud.rightSkillId).toBe(64)
|
||||
|
||||
hud.setDualSkill(0)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
})
|
||||
|
||||
it('tests asymmetric permutations: Attack left / Spells right, and Spells left / Attack right', () => {
|
||||
const availableLeft = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
|
||||
const availableRight = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
|
||||
|
||||
// Permutation A: Attack on Left, cycle every available Right skill
|
||||
hud.assignSkill('left', 0)
|
||||
for (const rSkill of availableRight) {
|
||||
hud.assignSkill('right', rSkill)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(rSkill)
|
||||
}
|
||||
|
||||
// Permutation B: Attack on Right, cycle every available Left skill
|
||||
hud.assignSkill('right', 0)
|
||||
for (const lSkill of availableLeft) {
|
||||
hud.assignSkill('left', lSkill)
|
||||
expect(hud.leftSkillId).toBe(lSkill)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('rigorously verifies slot state orthogonality across full Cartesian product of skills', () => {
|
||||
const leftSkills = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
|
||||
const rightSkills = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
|
||||
|
||||
for (const l of leftSkills) {
|
||||
for (const r of rightSkills) {
|
||||
hud.assignSkill('left', l)
|
||||
expect(hud.leftSkillId).toBe(l)
|
||||
|
||||
hud.assignSkill('right', r)
|
||||
// Crucial check: assigning to right slot MUST NOT mutate left slot
|
||||
expect(hud.leftSkillId).toBe(l)
|
||||
expect(hud.rightSkillId).toBe(r)
|
||||
|
||||
// Re-assign left slot: assigning left MUST NOT mutate right slot
|
||||
hud.assignSkill('left', 0)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(r)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('stress tests 1000 randomized slot mutations against an independent reference oracle', () => {
|
||||
const leftCandidates = hud.getAvailableSkillsForSide('left').map((s) => s.skillId)
|
||||
const rightCandidates = hud.getAvailableSkillsForSide('right').map((s) => s.skillId)
|
||||
|
||||
let oracleLeft = hud.leftSkillId
|
||||
let oracleRight = hud.rightSkillId
|
||||
|
||||
// Pseudo-random deterministic LCG generator
|
||||
let seed = 1337
|
||||
function nextRand(): number {
|
||||
seed = (seed * 1664525 + 1013904223) >>> 0
|
||||
return seed / 4294967296
|
||||
}
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const action = Math.floor(nextRand() * 3)
|
||||
if (action === 0) {
|
||||
// Mutate left
|
||||
const idx = Math.floor(nextRand() * leftCandidates.length)
|
||||
const chosen = leftCandidates[idx]
|
||||
hud.assignSkill('left', chosen)
|
||||
oracleLeft = chosen
|
||||
} else if (action === 1) {
|
||||
// Mutate right
|
||||
const idx = Math.floor(nextRand() * rightCandidates.length)
|
||||
const chosen = rightCandidates[idx]
|
||||
hud.assignSkill('right', chosen)
|
||||
oracleRight = chosen
|
||||
} else {
|
||||
// Dual assign (frequently attack = 0)
|
||||
const chosen = nextRand() > 0.5 ? 0 : leftCandidates[Math.floor(nextRand() * leftCandidates.length)]
|
||||
hud.setDualSkill(chosen)
|
||||
oracleLeft = chosen
|
||||
oracleRight = chosen
|
||||
}
|
||||
|
||||
expect(hud.leftSkillId).toBe(oracleLeft)
|
||||
expect(hud.rightSkillId).toBe(oracleRight)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 3. Hotkey Assignment, Rapid Switching, & Speedbar Selection
|
||||
// =========================================================================
|
||||
describe('3. Hotkey Assignment & Rapid Switching Stress', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
})
|
||||
|
||||
it('binds F1 through F8 to Attack (0) and verifies instant key activation', () => {
|
||||
const keys = ['F1', 'F2', 'F3', 'F4', 'F5', 'F6', 'F7', 'F8']
|
||||
|
||||
for (const key of keys) {
|
||||
// Set F-key to Attack for right slot
|
||||
hud.setHotkey(key, 0, 'right')
|
||||
expect(hud.getHotkey(key)).toBe(0)
|
||||
|
||||
// Set right skill to something else first
|
||||
hud.assignSkill('right', 64)
|
||||
expect(hud.rightSkillId).toBe(64)
|
||||
|
||||
// Press hotkey
|
||||
const handled = hud.handleKeyDown(key)
|
||||
expect(handled).toBe(true)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
}
|
||||
|
||||
// Also test left slot binding
|
||||
for (const key of keys) {
|
||||
hud.bindHotkey(key, 'left', 0)
|
||||
hud.assignSkill('left', 47)
|
||||
expect(hud.leftSkillId).toBe(47)
|
||||
|
||||
const handled = hud.handleKeyDown(key)
|
||||
expect(handled).toBe(true)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies slot state invariance during hotkey trigger', () => {
|
||||
// Bind F1 to right Attack (0) and F2 to left Attack (0)
|
||||
hud.bindHotkey('F1', 'right', 0)
|
||||
hud.bindHotkey('F2', 'left', 0)
|
||||
|
||||
hud.assignSkill('left', 47)
|
||||
hud.assignSkill('right', 64)
|
||||
|
||||
// Triggering F1 (right attack) must not alter left skill
|
||||
hud.handleKeyDown('F1')
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
expect(hud.leftSkillId).toBe(47)
|
||||
|
||||
// Triggering F2 (left attack) must not alter right skill
|
||||
hud.handleKeyDown('F2')
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
})
|
||||
|
||||
it('asserts leftUsable: true and rightUsable: true remain immutable on Skill 0', () => {
|
||||
const attackDef = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
expect(attackDef).toBeDefined()
|
||||
expect(attackDef!.leftUsable).toBe(true)
|
||||
expect(attackDef!.rightUsable).toBe(true)
|
||||
|
||||
// Perform a flurry of mutations, speedbar interactions, and bindings
|
||||
for (let i = 0; i < 200; i++) {
|
||||
hud.openSpeedbar('left')
|
||||
hud.handleClick(LEFT_SKILL_BOUNDS.x, LEFT_SKILL_BOUNDS.y)
|
||||
hud.openSpeedbar('right')
|
||||
hud.handleClick(RIGHT_SKILL_BOUNDS.x, RIGHT_SKILL_BOUNDS.y)
|
||||
hud.assignSkill('left', 0)
|
||||
hud.assignSkill('right', 0)
|
||||
hud.bindHotkey('F1', 'left', 0)
|
||||
hud.handleKeyDown('F1')
|
||||
}
|
||||
|
||||
// Check DEFAULT_SORCERESS_SKILLS
|
||||
const attackDefAfter = DEFAULT_SORCERESS_SKILLS.find((s) => s.skillId === 0)
|
||||
expect(attackDefAfter!.leftUsable).toBe(true)
|
||||
expect(attackDefAfter!.rightUsable).toBe(true)
|
||||
|
||||
// Check hud.availableSkills
|
||||
const hudAttack = hud.availableSkills.find((s) => s.skillId === 0)
|
||||
expect(hudAttack!.leftUsable).toBe(true)
|
||||
expect(hudAttack!.rightUsable).toBe(true)
|
||||
})
|
||||
|
||||
it('stress tests rapid hotkey switching with 5000 key events simulating hyper APM', () => {
|
||||
// Configure F1..F8 layout
|
||||
const config: Array<{ key: string; side: 'left' | 'right'; skillId: number }> = [
|
||||
{ key: 'F1', side: 'right', skillId: 0 }, // Attack
|
||||
{ key: 'F2', side: 'right', skillId: 64 }, // Frozen Orb
|
||||
{ key: 'F3', side: 'right', skillId: 59 }, // Blizzard
|
||||
{ key: 'F4', side: 'right', skillId: 54 }, // Teleport
|
||||
{ key: 'F5', side: 'left', skillId: 0 }, // Attack
|
||||
{ key: 'F6', side: 'left', skillId: 47 }, // Fire Ball
|
||||
{ key: 'F7', side: 'left', skillId: 36 }, // Fire Bolt
|
||||
{ key: 'F8', side: 'right', skillId: 40 }, // Frozen Armor
|
||||
]
|
||||
|
||||
for (const item of config) {
|
||||
hud.bindHotkey(item.key, item.side, item.skillId)
|
||||
}
|
||||
|
||||
let expectedLeft = hud.leftSkillId
|
||||
let expectedRight = hud.rightSkillId
|
||||
|
||||
let seed = 424242
|
||||
function randInt(max: number): number {
|
||||
seed = (seed * 1103515245 + 12345) & 0x7fffffff
|
||||
return seed % max
|
||||
}
|
||||
|
||||
for (let step = 0; step < 5000; step++) {
|
||||
const choice = config[randInt(config.length)]
|
||||
const res = hud.triggerFunctionKey(choice.key)
|
||||
|
||||
expect(res).not.toBeNull()
|
||||
expect(res!.skillId).toBe(choice.skillId)
|
||||
expect(res!.side).toBe(choice.side)
|
||||
|
||||
if (choice.side === 'left') {
|
||||
expectedLeft = choice.skillId
|
||||
} else {
|
||||
expectedRight = choice.skillId
|
||||
}
|
||||
|
||||
expect(hud.leftSkillId).toBe(expectedLeft)
|
||||
expect(hud.rightSkillId).toBe(expectedRight)
|
||||
}
|
||||
})
|
||||
|
||||
it('stress tests speedbar popup selection for Normal Attack', () => {
|
||||
// 1. Left Speedbar
|
||||
hud.openSpeedbar('left')
|
||||
expect(hud.selectionOpen).toBe('left')
|
||||
|
||||
const leftGrid = hud.getSpeedbarSkills('left')
|
||||
const leftAttack = leftGrid.find((cell) => cell.skill.skillId === 0)
|
||||
expect(leftAttack).toBeDefined()
|
||||
|
||||
// Click within Attack cell bounds
|
||||
const leftClicked = hud.handleClick(leftAttack!.x + 16, leftAttack!.y + 16)
|
||||
expect(leftClicked).toBe(true)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.selectionOpen).toBeNull() // closed automatically
|
||||
|
||||
// 2. Right Speedbar
|
||||
hud.openSpeedbar('right')
|
||||
expect(hud.selectionOpen).toBe('right')
|
||||
|
||||
const rightGrid = hud.getSpeedbarSkills('right')
|
||||
const rightAttack = rightGrid.find((cell) => cell.skill.skillId === 0)
|
||||
expect(rightAttack).toBeDefined()
|
||||
|
||||
// Click within Attack cell bounds
|
||||
const rightClicked = hud.handleClick(rightAttack!.x + 16, rightAttack!.y + 16)
|
||||
expect(rightClicked).toBe(true)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
expect(hud.selectionOpen).toBeNull()
|
||||
})
|
||||
|
||||
it('tests speedbar interactive hover and in-palette hotkey rebinding', () => {
|
||||
hud.openSpeedbar('right')
|
||||
const rightGrid = hud.getSpeedbarSkills('right')
|
||||
const attackCell = rightGrid.find((cell) => cell.skill.skillId === 0)!
|
||||
|
||||
// Move mouse over Attack in speedbar
|
||||
hud.handleMouseMove(attackCell.x + 10, attackCell.y + 10)
|
||||
expect(hud.hoverSpeedbarSkillId).toBe(0)
|
||||
expect(hud.hoveredPopupSkill).toEqual({ side: 'right', skillId: 0 })
|
||||
|
||||
// Press F5 while hovering -> rebinds F5 to Attack on right
|
||||
const boundRes = hud.triggerFunctionKey('F5')
|
||||
expect(boundRes).toEqual({ side: 'right', skillId: 0 })
|
||||
expect(hud.getHotkey('F5')).toBe(0)
|
||||
|
||||
// Close speedbar
|
||||
hud.handleClick(10, 10)
|
||||
expect(hud.selectionOpen).toBeNull()
|
||||
|
||||
// Reset right skill to Blizzard (59)
|
||||
hud.assignSkill('right', 59)
|
||||
expect(hud.rightSkillId).toBe(59)
|
||||
|
||||
// Trigger F5
|
||||
hud.handleKeyDown('F5')
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,488 @@
|
|||
/**
|
||||
* Diablo II v1.13c Skill Formula & Preview Adversarial Stress Test Suite.
|
||||
*
|
||||
* Authored by: teamwork_preview_challenger
|
||||
* Milestone: M7 (Issue #152 — 技能 Tooltip 未对齐原版)
|
||||
*
|
||||
* Adversarially challenges:
|
||||
* 1. Attack Rating (ToHit) bonus scaling: Jab, Sacrifice, Zeal (1.13c ground truth vs worker claims).
|
||||
* 2. Duration scaling: Shout (defense vs duration disambiguation), Battle Orders (base vs synergized duration),
|
||||
* and 3-band duration progression (piecewise scaling across bands 1-8, 9-16, 17+).
|
||||
* 3. Next-Level Preview Invariant: unlearned (null next), intermediate (eff + 1), and capped at 20 (null next).
|
||||
* 4. 1.13c Marrowwalk Fix Synergy Isolation: hard points vs soft points (+skills / charges).
|
||||
* 5. Differential fuzzing & property validation across 1000 generated inputs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
createInitialState,
|
||||
getEffectiveLevel,
|
||||
allocatePoint,
|
||||
evaluateSynergies,
|
||||
evaluateMasteries,
|
||||
calculateManaCost,
|
||||
calculateDetailedDamage,
|
||||
buildTooltipViewModel,
|
||||
evaluateSkillEffects,
|
||||
evalSkillDescFormula,
|
||||
type SkillCalcState,
|
||||
} from '../src/game/skill-calc-engine.ts'
|
||||
import { compute3BandDuration } from '../src/game/engine/calc-ast.ts'
|
||||
import { SKILLS_CATALOG, SKILLS_BY_ID } from '../src/data/skills-catalog.ts'
|
||||
import { SkillTreePanel } from '../src/ui/skill-tree-panel.ts'
|
||||
|
||||
describe('Challenger M7: 1.13c Skill Formulas, Scaling & Tooltip Stress Tests', () => {
|
||||
|
||||
// ==========================================================================
|
||||
// 1. Attack Rating Bonus Scaling
|
||||
// ==========================================================================
|
||||
describe('1. Attack Rating (ToHit) Bonus Scaling', () => {
|
||||
it('verifies Jab (id 10) scaling: level 1 (+10%), level 10 (+91%), level 20 (+181%)', () => {
|
||||
const jab = SKILLS_BY_ID[10]!
|
||||
expect(jab).toBeDefined()
|
||||
expect(jab.toHit).toBe(10)
|
||||
expect(jab.toHitLev).toBe(9)
|
||||
|
||||
// Level 1: 10 + (1 - 1) * 9 = 10%
|
||||
const state1 = createInitialState('ama')
|
||||
state1.hardPoints[10] = 1
|
||||
const vm1 = buildTooltipViewModel(state1, 10, 99)
|
||||
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(ar1).toBeDefined()
|
||||
expect(ar1?.value).toBe(10)
|
||||
expect(ar1?.textZh).toBe('準確率:+10%')
|
||||
|
||||
// Level 10: 10 + (10 - 1) * 9 = 91%
|
||||
const state10 = createInitialState('ama')
|
||||
state10.hardPoints[10] = 10
|
||||
const vm10 = buildTooltipViewModel(state10, 10, 99)
|
||||
const ar10 = vm10?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(ar10).toBeDefined()
|
||||
expect(ar10?.value).toBe(91)
|
||||
expect(ar10?.textZh).toBe('準確率:+91%')
|
||||
|
||||
// Level 20: 10 + (20 - 1) * 9 = 181%
|
||||
const state20 = createInitialState('ama')
|
||||
state20.hardPoints[10] = 20
|
||||
const vm20 = buildTooltipViewModel(state20, 10, 99)
|
||||
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(ar20).toBeDefined()
|
||||
expect(ar20?.value).toBe(181)
|
||||
expect(ar20?.textZh).toBe('準確率:+181%')
|
||||
})
|
||||
|
||||
it('verifies Sacrifice (id 96) scaling: level 1 (+20%), level 10 (+83%), level 20 (+153%)', () => {
|
||||
const sacrifice = SKILLS_BY_ID[96]!
|
||||
expect(sacrifice).toBeDefined()
|
||||
expect(sacrifice.toHit).toBe(20)
|
||||
expect(sacrifice.toHitLev).toBe(7)
|
||||
|
||||
// Level 1: 20 + (1 - 1) * 7 = 20%
|
||||
const state1 = createInitialState('pal')
|
||||
state1.hardPoints[96] = 1
|
||||
const vm1 = buildTooltipViewModel(state1, 96, 99)
|
||||
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('準確率'))
|
||||
expect(ar1).toBeDefined()
|
||||
expect(ar1?.value).toBe(20)
|
||||
expect(ar1?.textZh).toBe('變動準確率:+20%')
|
||||
|
||||
// Level 10: 20 + (10 - 1) * 7 = 83%
|
||||
const state10 = createInitialState('pal')
|
||||
state10.hardPoints[96] = 10
|
||||
const vm10 = buildTooltipViewModel(state10, 96, 99)
|
||||
const ar10 = vm10?.current.effects.find(e => e.textZh.includes('準確率'))
|
||||
expect(ar10).toBeDefined()
|
||||
expect(ar10?.value).toBe(83)
|
||||
expect(ar10?.textZh).toBe('變動準確率:+83%')
|
||||
|
||||
// Level 20: 20 + (20 - 1) * 7 = 153%
|
||||
const state20 = createInitialState('pal')
|
||||
state20.hardPoints[96] = 20
|
||||
const vm20 = buildTooltipViewModel(state20, 96, 99)
|
||||
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('準確率'))
|
||||
expect(ar20).toBeDefined()
|
||||
expect(ar20?.value).toBe(153)
|
||||
expect(ar20?.textZh).toBe('變動準確率:+153%')
|
||||
})
|
||||
|
||||
it('adversarially challenges Zeal (id 106): tests 1.13c MPQ ground truth (lvl 1=+10%, lvl 20=+200%, lvl 21=+210%) vs worker handoff claim (lvl 1=0%, lvl 21=+200%)', () => {
|
||||
const zeal = SKILLS_BY_ID[106]!
|
||||
expect(zeal).toBeDefined()
|
||||
// Authentic 1.13c Skills.txt row 106 ground truth: ToHit = 10, LevToHit = 10
|
||||
expect(zeal.toHit).toBe(10)
|
||||
expect(zeal.toHitLev).toBe(10)
|
||||
|
||||
// Empirical Level 1: 10 + (1 - 1) * 10 = +10%
|
||||
const state1 = createInitialState('pal')
|
||||
state1.hardPoints[106] = 1
|
||||
const vm1 = buildTooltipViewModel(state1, 106, 99)
|
||||
const ar1 = vm1?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
|
||||
expect(ar1).toBeDefined()
|
||||
expect(ar1?.value).toBe(10)
|
||||
expect(ar1?.textZh).toBe('攻擊加成:+10%')
|
||||
|
||||
// Empirical Level 20: 10 + (20 - 1) * 10 = +200%
|
||||
const state20 = createInitialState('pal')
|
||||
state20.hardPoints[106] = 20
|
||||
const vm20 = buildTooltipViewModel(state20, 106, 99)
|
||||
const ar20 = vm20?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
|
||||
expect(ar20).toBeDefined()
|
||||
expect(ar20?.value).toBe(200)
|
||||
expect(ar20?.textZh).toBe('攻擊加成:+200%')
|
||||
|
||||
// Empirical Level 21: 10 + (21 - 1) * 10 = +210%
|
||||
const state21 = createInitialState('pal')
|
||||
state21.hardPoints[106] = 20
|
||||
state21.allSkillsSoftPoints = 1 // 20 hard + 1 soft = eff 21
|
||||
const vm21 = buildTooltipViewModel(state21, 106, 99)
|
||||
const ar21 = vm21?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
|
||||
expect(ar21).toBeDefined()
|
||||
expect(ar21?.value).toBe(210)
|
||||
expect(ar21?.textZh).toBe('攻擊加成:+210%')
|
||||
|
||||
// Note on Challenger Finding:
|
||||
// Worker M7's handoff text stated: "Zeal (id 106) with lineType 2 StrSkill30 starts at 0 and adds +10%/lvl, giving +200% at level 21."
|
||||
// That was a documentation typo in Worker M7's report. The actual code extracted from 1.13c MPQ has ToHit=10,
|
||||
// which yields +10% at lvl 1, +200% at lvl 20, and +210% at lvl 21.
|
||||
expect(ar1?.value).not.toBe(0)
|
||||
expect(ar21?.value).not.toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 2. Duration Scaling
|
||||
// ==========================================================================
|
||||
describe('2. Duration Scaling & Formula Disambiguation', () => {
|
||||
it('verifies Shout (id 138) duration and defense are NOT conflated: level 1 dur=20s def=+100%, level 20 dur=210s def=+290%', () => {
|
||||
const shout = SKILLS_BY_ID[138]!
|
||||
expect(shout).toBeDefined()
|
||||
// Param1/Param2 (ln12) = Defense bonus: 100 + (lvl - 1) * 10
|
||||
// Param3/Param4 (ln34) = Duration frames: 500 + (lvl - 1) * 250
|
||||
expect(shout.params.par1).toBe(100)
|
||||
expect(shout.params.par2).toBe(10)
|
||||
expect(shout.params.par3).toBe(500)
|
||||
expect(shout.params.par4).toBe(250)
|
||||
|
||||
// Level 1
|
||||
const state1 = createInitialState('bar')
|
||||
state1.hardPoints[138] = 1
|
||||
const vm1 = buildTooltipViewModel(state1, 138, 99)
|
||||
const dur1 = vm1?.current.effects.find(e => e.lineType === 12)
|
||||
const def1 = vm1?.current.effects.find(e => e.textZh.includes('防禦'))
|
||||
expect(dur1).toBeDefined()
|
||||
expect(dur1?.value).toBe(500) // 500 frames
|
||||
expect(dur1?.textZh).toBe('持續:20 秒') // 500 / 25 = 20s
|
||||
expect(def1).toBeDefined()
|
||||
expect(def1?.value).toBe(100)
|
||||
expect(def1?.textZh).toBe('防禦:+100%')
|
||||
|
||||
// Level 20
|
||||
const state20 = createInitialState('bar')
|
||||
state20.hardPoints[138] = 20
|
||||
const vm20 = buildTooltipViewModel(state20, 138, 99)
|
||||
const dur20 = vm20?.current.effects.find(e => e.lineType === 12)
|
||||
const def20 = vm20?.current.effects.find(e => e.textZh.includes('防禦'))
|
||||
expect(dur20).toBeDefined()
|
||||
// 500 + 19 * 250 = 5250 frames
|
||||
expect(dur20?.value).toBe(5250)
|
||||
expect(dur20?.textZh).toBe('持續:210 秒') // 5250 / 25 = 210s
|
||||
expect(def20).toBeDefined()
|
||||
// 100 + 19 * 10 = 290%
|
||||
expect(def20?.value).toBe(290)
|
||||
expect(def20?.textZh).toBe('防禦:+290%')
|
||||
})
|
||||
|
||||
it('verifies Battle Orders (id 149) duration scaling: level 1 = 30s, level 20 base = 220s, level 20 with Shout synergy = 240s', () => {
|
||||
const bo = SKILLS_BY_ID[149]!
|
||||
expect(bo).toBeDefined()
|
||||
// Param1 = 750 frames (30s), Param2 = 250 frames (10s), Param8 = 125 frames (5s) per synergy point
|
||||
expect(bo.params.par1).toBe(750)
|
||||
expect(bo.params.par2).toBe(250)
|
||||
expect(bo.params.par8).toBe(125)
|
||||
|
||||
// Level 1: 750 frames = 30 seconds
|
||||
const state1 = createInitialState('bar')
|
||||
state1.hardPoints[149] = 1
|
||||
const vm1 = buildTooltipViewModel(state1, 149, 99)
|
||||
const dur1 = vm1?.current.effects.find(e => e.lineType === 12)
|
||||
expect(dur1).toBeDefined()
|
||||
expect(dur1?.value).toBe(750)
|
||||
expect(dur1?.textZh).toBe('持續:30 秒')
|
||||
|
||||
// Level 20 (base without synergy): 750 + 19 * 250 = 5500 frames = 220 seconds
|
||||
const state20 = createInitialState('bar')
|
||||
state20.hardPoints[149] = 20
|
||||
const vm20 = buildTooltipViewModel(state20, 149, 99)
|
||||
const dur20 = vm20?.current.effects.find(e => e.lineType === 12)
|
||||
expect(dur20).toBeDefined()
|
||||
expect(dur20?.value).toBe(5500)
|
||||
expect(dur20?.textZh).toBe('持續:220 秒')
|
||||
|
||||
// Level 20 with 4 points in Shout synergy (4 * 125 = 500 frames = 20s): 5500 + 500 = 6000 frames = 240s!
|
||||
const state20Syn = createInitialState('bar')
|
||||
state20Syn.hardPoints[149] = 20
|
||||
state20Syn.hardPoints[138] = 4 // 4 points in Shout
|
||||
const vm20Syn = buildTooltipViewModel(state20Syn, 149, 99)
|
||||
const dur20Syn = vm20Syn?.current.effects.find(e => e.lineType === 12)
|
||||
expect(dur20Syn).toBeDefined()
|
||||
expect(dur20Syn?.value).toBe(6000)
|
||||
expect(dur20Syn?.textZh).toBe('持續:240 秒')
|
||||
|
||||
// Level 22 (base without synergy): 750 + 21 * 250 = 6000 frames = 240 seconds
|
||||
const state22 = createInitialState('bar')
|
||||
state22.hardPoints[149] = 20
|
||||
state22.allSkillsSoftPoints = 2
|
||||
const vm22 = buildTooltipViewModel(state22, 149, 99)
|
||||
const dur22 = vm22?.current.effects.find(e => e.lineType === 12)
|
||||
expect(dur22?.value).toBe(6000)
|
||||
expect(dur22?.textZh).toBe('持續:240 秒')
|
||||
})
|
||||
|
||||
it('verifies 3-band duration progression across levels 1-8, 9-16, and 17+ on authentic elemental skills', () => {
|
||||
// 1. Shiver Armor (id 50): eLen = 100, eLevLen = [0, 25, 50]
|
||||
// Band 1 (slvl 1-8): rate +0
|
||||
expect(compute3BandDuration(1, 100, 0, 25, 50)).toBe(100)
|
||||
expect(compute3BandDuration(8, 100, 0, 25, 50)).toBe(100)
|
||||
// Band 2 (slvl 9-16): rate +25
|
||||
expect(compute3BandDuration(9, 100, 0, 25, 50)).toBe(125)
|
||||
expect(compute3BandDuration(16, 100, 0, 25, 50)).toBe(300)
|
||||
// Band 3 (slvl 17+): rate +50
|
||||
expect(compute3BandDuration(17, 100, 0, 25, 50)).toBe(350)
|
||||
expect(compute3BandDuration(20, 100, 0, 25, 50)).toBe(500)
|
||||
expect(compute3BandDuration(30, 100, 0, 25, 50)).toBe(1000)
|
||||
|
||||
// 2. Stun (id 139): eLen = 30, eLevLen = [5, 5, 2]
|
||||
// Band 1 (slvl 1-8): rate +5
|
||||
expect(compute3BandDuration(1, 30, 5, 5, 2)).toBe(30)
|
||||
expect(compute3BandDuration(8, 30, 5, 5, 2)).toBe(30 + 7 * 5) // 65
|
||||
// Band 2 (slvl 9-16): rate +5
|
||||
expect(compute3BandDuration(9, 30, 5, 5, 2)).toBe(65 + 1 * 5) // 70
|
||||
expect(compute3BandDuration(16, 30, 5, 5, 2)).toBe(65 + 8 * 5) // 105
|
||||
// Band 3 (slvl 17+): rate +2
|
||||
expect(compute3BandDuration(17, 30, 5, 5, 2)).toBe(105 + 1 * 2) // 107
|
||||
expect(compute3BandDuration(20, 30, 5, 5, 2)).toBe(105 + 4 * 2) // 113
|
||||
|
||||
// 3. Poison Javelin (id 15): eLen = 200, eLevLen = [50, 50, 50]
|
||||
expect(compute3BandDuration(1, 200, 50, 50, 50)).toBe(200)
|
||||
expect(compute3BandDuration(8, 200, 50, 50, 50)).toBe(550)
|
||||
expect(compute3BandDuration(9, 200, 50, 50, 50)).toBe(600)
|
||||
expect(compute3BandDuration(16, 200, 50, 50, 50)).toBe(950)
|
||||
expect(compute3BandDuration(17, 200, 50, 50, 50)).toBe(1000)
|
||||
expect(compute3BandDuration(20, 200, 50, 50, 50)).toBe(1150)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 3. Next-Level Preview Invariant
|
||||
// ==========================================================================
|
||||
describe('3. Next-Level Preview Invariant', () => {
|
||||
it('enforces slvl === 0 invariant: nextLevel === null, isLearned === false, previewing level 1 in current stats', () => {
|
||||
const testSkills = [
|
||||
{ code: 'sor', id: 47 }, // Fire Ball
|
||||
{ code: 'ama', id: 10 }, // Jab
|
||||
{ code: 'pal', id: 106 }, // Zeal
|
||||
{ code: 'bar', id: 149 }, // Battle Orders
|
||||
{ code: 'nec', id: 84 }, // Bone Spear
|
||||
{ code: 'dru', id: 245 }, // Tornado
|
||||
{ code: 'ass', id: 271 }, // Lightning Sentry
|
||||
] as const
|
||||
|
||||
for (const { code, id } of testSkills) {
|
||||
const state = createInitialState(code)
|
||||
const vm = buildTooltipViewModel(state, id, 99)
|
||||
expect(vm, `Skill ${id} VM must not be null`).not.toBeNull()
|
||||
expect(vm!.isLearned).toBe(false)
|
||||
expect(vm!.effectiveLevel).toBe(0)
|
||||
expect(vm!.hardPoints).toBe(0)
|
||||
expect(vm!.current.level).toBe(1)
|
||||
expect(vm!.nextLevel).toBeNull()
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces 1 <= hardPoints < 20 invariant: nextLevel !== null, level === effectiveLevel + 1', () => {
|
||||
const state = createInitialState('sor')
|
||||
state.hardPoints[36] = 1 // Fire Bolt req
|
||||
state.hardPoints[47] = 5 // Fire Ball 5 hard points
|
||||
state.allSkillsSoftPoints = 3 // +3 soft skills -> eff 8
|
||||
|
||||
const vm = buildTooltipViewModel(state, 47, 99)
|
||||
expect(vm).not.toBeNull()
|
||||
expect(vm!.isLearned).toBe(true)
|
||||
expect(vm!.hardPoints).toBe(5)
|
||||
expect(vm!.effectiveLevel).toBe(8)
|
||||
expect(vm!.current.level).toBe(8)
|
||||
expect(vm!.nextLevel).not.toBeNull()
|
||||
expect(vm!.nextLevel!.level).toBe(9)
|
||||
expect(vm!.nextLevel!.damage.finalMin).toBeGreaterThan(vm!.current.damage.finalMin)
|
||||
})
|
||||
|
||||
it('enforces hardPoints === 20 invariant: nextLevel === null regardless of soft points', () => {
|
||||
const state = createInitialState('sor')
|
||||
state.hardPoints[36] = 1
|
||||
state.hardPoints[47] = 20 // Max base points
|
||||
|
||||
// Check with 0 soft points
|
||||
let vm = buildTooltipViewModel(state, 47, 99)
|
||||
expect(vm).not.toBeNull()
|
||||
expect(vm!.isLearned).toBe(true)
|
||||
expect(vm!.hardPoints).toBe(20)
|
||||
expect(vm!.effectiveLevel).toBe(20)
|
||||
expect(vm!.nextLevel).toBeNull()
|
||||
|
||||
// Check with +15 soft points
|
||||
state.allSkillsSoftPoints = 15
|
||||
vm = buildTooltipViewModel(state, 47, 99)
|
||||
expect(vm!.hardPoints).toBe(20)
|
||||
expect(vm!.effectiveLevel).toBe(35)
|
||||
expect(vm!.current.level).toBe(35)
|
||||
expect(vm!.nextLevel).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 4. 1.13c Marrowwalk Fix Synergy Isolation
|
||||
// ==========================================================================
|
||||
describe('4. 1.13c Marrowwalk Fix Synergy Isolation', () => {
|
||||
it('verifies Fire Ball (id 47) receives exactly +140% from 10 hard points in Fire Bolt, and soft points do NOT increase multiplier', () => {
|
||||
const state = createInitialState('sor')
|
||||
const fb = SKILLS_BY_ID[47]!
|
||||
|
||||
// 0 hard points -> 0% synergy
|
||||
let syn = evaluateSynergies(state, fb)
|
||||
expect(syn.totalPercent).toBe(0)
|
||||
expect(syn.multiplier).toBe(1.0)
|
||||
|
||||
// Allocate 10 hard points to Fire Bolt (id 36) -> 10 * 14% = 140%
|
||||
allocatePoint(state, 36, 10)
|
||||
syn = evaluateSynergies(state, fb)
|
||||
expect(syn.totalPercent).toBe(140)
|
||||
expect(syn.multiplier).toBe(2.4)
|
||||
|
||||
// Add soft points: +10 all skills, +10 specific soft points, +10 tab soft points
|
||||
state.allSkillsSoftPoints = 10
|
||||
state.specificSoftPoints[36] = 10
|
||||
state.tabSoftPoints[0] = 10
|
||||
|
||||
// Effective level of Fire Bolt is now 10 + 10 + 10 + 10 = 40!
|
||||
expect(getEffectiveLevel(state, 36)).toBe(40)
|
||||
|
||||
// BUT synergy multiplier MUST remain strictly +140% (multiplier 2.4)
|
||||
syn = evaluateSynergies(state, fb)
|
||||
expect(syn.totalPercent).toBe(140)
|
||||
expect(syn.multiplier).toBe(2.4)
|
||||
|
||||
// Verify via buildTooltipViewModel
|
||||
allocatePoint(state, 47, 1)
|
||||
const vm = buildTooltipViewModel(state, 47, 99)
|
||||
const synInfo = vm?.synergiesReceived.find(s => s.providerId === 36)
|
||||
expect(synInfo).toBeDefined()
|
||||
expect(synInfo?.hardPoints).toBe(10)
|
||||
expect(synInfo?.currentBonusPct).toBe(140)
|
||||
})
|
||||
|
||||
it('verifies Bone Spear (id 84) synergies are isolated from item charges and soft skill points', () => {
|
||||
const state = createInitialState('nec')
|
||||
const bs = SKILLS_BY_ID[84]!
|
||||
|
||||
// Teeth (67), Bone Wall (78), Bone Prison (88), Bone Spirit (93)
|
||||
// 7% magic damage per level
|
||||
allocatePoint(state, 67, 20) // Teeth 20
|
||||
allocatePoint(state, 68, 1) // Bone Armor prerequisite for Bone Wall
|
||||
allocatePoint(state, 78, 20) // Bone Wall 20
|
||||
let syn = evaluateSynergies(state, bs)
|
||||
// 20 * 7% + 20 * 7% = 140% + 140% = 280%
|
||||
expect(syn.totalPercent).toBe(280)
|
||||
expect(syn.multiplier).toBe(3.8)
|
||||
|
||||
// Add Marrowwalk pseudo-charges (+33 Bone Prison charges via soft points)
|
||||
state.specificSoftPoints[88] = 33 // Bone Prison is id 88
|
||||
state.allSkillsSoftPoints = 10
|
||||
syn = evaluateSynergies(state, bs)
|
||||
// Synergy MUST remain exactly 280%, ignoring soft points on Bone Prison
|
||||
expect(syn.totalPercent).toBe(280)
|
||||
expect(syn.multiplier).toBe(3.8)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 5. Differential Oracles & Property Fuzzing
|
||||
// ==========================================================================
|
||||
describe('5. Differential Oracles & Property Fuzzing', () => {
|
||||
it('fuzzes 1000 random inputs for 3-band duration against independent mathematical oracle', () => {
|
||||
// Independent naive oracle
|
||||
function oracle3Band(lvl: number, eLen: number, l1: number, l2: number, l3: number): number {
|
||||
if (lvl <= 0 || (eLen === 0 && l1 === 0 && l2 === 0 && l3 === 0)) return 0
|
||||
let res = eLen
|
||||
for (let l = 2; l <= lvl; l++) {
|
||||
if (l <= 8) res += l1
|
||||
else if (l <= 16) res += l2
|
||||
else res += l3
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Pseudo-random LCG generator for reproducible fuzzing
|
||||
let seed = 1337
|
||||
function lcg(): number {
|
||||
seed = (seed * 1664525 + 1013904223) % 4294967296
|
||||
return seed / 4294967296
|
||||
}
|
||||
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const lvl = Math.floor(lcg() * 60) + 1 // 1..60
|
||||
const eLen = Math.floor(lcg() * 200) // 0..199
|
||||
const l1 = Math.floor(lcg() * 50)
|
||||
const l2 = Math.floor(lcg() * 50)
|
||||
const l3 = Math.floor(lcg() * 50)
|
||||
|
||||
const expected = oracle3Band(lvl, eLen, l1, l2, l3)
|
||||
const actual = compute3BandDuration(lvl, eLen, l1, l2, l3)
|
||||
expect(actual, `Mismatch at lvl=${lvl}, eLen=${eLen}, l1=${l1}, l2=${l2}, l3=${l3}`).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('validates mana cost floor constraint: calculateManaCost >= minmana across all catalog skills', () => {
|
||||
for (const skill of SKILLS_CATALOG) {
|
||||
for (let lvl = 1; lvl <= 30; lvl++) {
|
||||
const cost = calculateManaCost(skill, lvl)
|
||||
const minmana = skill.minmana ?? 0
|
||||
if (skill.mana > 0 || skill.lvlmana > 0) {
|
||||
expect(cost, `Skill ${skill.id} at lvl ${lvl} cost ${cost} < minmana ${minmana}`).toBeGreaterThanOrEqual(minmana)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies tooltip placement bounding box invariants: always inside canvas [8, 800] x [8, 552]', () => {
|
||||
const mockFont = {
|
||||
measureText: (str: string) => str.length * 7,
|
||||
} as any
|
||||
|
||||
const dummyPositions = [
|
||||
{ x: 30, y: 30, w: 48, h: 48 },
|
||||
{ x: 400, y: 300, w: 48, h: 48 },
|
||||
{ x: 750, y: 500, w: 48, h: 48 },
|
||||
{ x: 10, y: 550, w: 48, h: 48 },
|
||||
]
|
||||
|
||||
for (const pos of dummyPositions) {
|
||||
// Test varying line counts from 5 to 25
|
||||
for (let lineCount = 5; lineCount <= 25; lineCount++) {
|
||||
const lines = Array.from({ length: lineCount }, (_, i) => ({
|
||||
text: `Line ${i + 1}: Sample Tooltip Text Description`,
|
||||
color: 'white' as const,
|
||||
font: 'font8' as const,
|
||||
}))
|
||||
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(pos, lines, mockFont)
|
||||
expect(placement.w).toBeGreaterThanOrEqual(240)
|
||||
expect(placement.h).toBe(lineCount * 17 + 24)
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(800)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,544 @@
|
|||
/**
|
||||
* Empirical Stress Test Suite for Milestone M7 (Issue #152)
|
||||
*
|
||||
* Adversarially tests:
|
||||
* 1. SkillTreePanel tooltip placement and canvas boundary clamping across all 18 skill grid nodes (rows 1..6, cols 1..3).
|
||||
* 2. Multi-line formatting, authentic color coding, text measurement, and hierarchy.
|
||||
* 3. Non-damaging skills (Warmth, Teleport) suppressing undefined/blank damage lines.
|
||||
* 4. Maxed skill point allocation (20/20) omitting next-level preview and showing gold max notice.
|
||||
* 5. Unlearned skill requirement warnings (red highlighting for player level < reqlevel and missing prereqs).
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
SkillTreePanel,
|
||||
SORCERESS_SKILL_TREE,
|
||||
SKILL_PANEL_ORIGIN,
|
||||
SKILL_TREE_COL_OFFSETS_X,
|
||||
SKILL_TREE_ROW_OFFSETS_Y,
|
||||
type FormattedTooltipLine,
|
||||
type SkillNodeDef,
|
||||
} from '../src/ui/skill-tree-panel.ts'
|
||||
import {
|
||||
buildTooltipViewModel,
|
||||
createInitialState,
|
||||
allocatePoint,
|
||||
} from '../src/game/skill-calc-engine.ts'
|
||||
import { D2FontRenderer, type D2ColorCode } from '../src/ui/font.ts'
|
||||
|
||||
const VALID_D2_COLOR_CODES: readonly D2ColorCode[] = [
|
||||
'gold',
|
||||
'white',
|
||||
'blue',
|
||||
'green',
|
||||
'red',
|
||||
'tan',
|
||||
'gray',
|
||||
]
|
||||
|
||||
describe('Milestone M7 Challenger Stress Tests: Tooltip Clamping & Line Formatting (Issue #152)', () => {
|
||||
const mockFont = {
|
||||
measureText: (txt: string, font?: string) => {
|
||||
// Exocet is wider than standard font8
|
||||
const charW = font === 'fontexocet10' ? 11 : 8
|
||||
return txt.length * charW
|
||||
},
|
||||
} as unknown as D2FontRenderer
|
||||
|
||||
// Helper to construct synthetic grid node at row (1..6) and col (1..3)
|
||||
function makeGridNode(row: 1 | 2 | 3 | 4 | 5 | 6, col: 1 | 2 | 3): SkillNodeDef {
|
||||
return {
|
||||
skillId: 9000 + row * 10 + col,
|
||||
tab: 0,
|
||||
row,
|
||||
col,
|
||||
reqLevel: row === 1 ? 1 : (row - 1) * 6,
|
||||
prereqs: [],
|
||||
nameEn: `Test Skill R${row}C${col}`,
|
||||
nameZh: `测试技能R${row}C${col}`,
|
||||
descZh: `测试描述行`,
|
||||
baseMana: 10,
|
||||
baseDmgMin: 10,
|
||||
baseDmgMax: 20,
|
||||
dmgPerLevel: 5,
|
||||
}
|
||||
}
|
||||
|
||||
describe('1. Placement & Boundary Clamping across All 18 Skill Grid Nodes', () => {
|
||||
const gridRows: readonly (1 | 2 | 3 | 4 | 5 | 6)[] = [1, 2, 3, 4, 5, 6]
|
||||
const gridCols: readonly (1 | 2 | 3)[] = [1, 2, 3]
|
||||
|
||||
it('enforces authentic grid origin and exact spacing per D2Client.dll', () => {
|
||||
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
||||
|
||||
// Verify exact screen coordinates for all 18 grid combinations
|
||||
for (const row of gridRows) {
|
||||
for (const col of gridCols) {
|
||||
const node = makeGridNode(row, col)
|
||||
const pos = SkillTreePanel.nodeScreenPos(node)
|
||||
|
||||
const expectedX = 400 + SKILL_TREE_COL_OFFSETS_X[col - 1]!
|
||||
const expectedY = 60 + SKILL_TREE_ROW_OFFSETS_Y[row - 1]!
|
||||
|
||||
expect(pos.x).toBe(expectedX)
|
||||
expect(pos.y).toBe(expectedY)
|
||||
expect(pos.w).toBe(48)
|
||||
expect(pos.h).toBe(48)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('clamps tooltip within canvas viewport [8, 800 - 8] and strictly above control bar (y <= 552) across all 18 nodes with real skill lines', () => {
|
||||
// Prepare a realistic, rich skill tooltip (Fire Ball with synergies and next level)
|
||||
const state = createInitialState('sor')
|
||||
allocatePoint(state, 36, 5) // Fire Bolt (synergy)
|
||||
allocatePoint(state, 47, 10) // Fire Ball (main)
|
||||
allocatePoint(state, 56, 1) // Meteor (synergy)
|
||||
const vm = buildTooltipViewModel(state, 47, 85)
|
||||
expect(vm).not.toBeNull()
|
||||
const realLines = SkillTreePanel.formatSkillTooltipLines(vm!)
|
||||
expect(realLines.length).toBeGreaterThanOrEqual(10)
|
||||
|
||||
let testedNodeCount = 0
|
||||
|
||||
for (const row of gridRows) {
|
||||
for (const col of gridCols) {
|
||||
testedNodeCount++
|
||||
const node = makeGridNode(row, col)
|
||||
const nodePos = SkillTreePanel.nodeScreenPos(node)
|
||||
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, realLines, mockFont)
|
||||
|
||||
// 1. Box width invariants
|
||||
expect(placement.w).toBeGreaterThanOrEqual(240)
|
||||
expect(placement.w).toBeLessThanOrEqual(800 - 16) // <= 784
|
||||
|
||||
// 2. Horizontal clamping: tx >= 8 and tx + boxW <= 800 - 8 (792)
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(800 - 8)
|
||||
|
||||
// 3. Vertical clamping: ty >= 8 and ty + boxH <= 552 (strictly above control bar)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
|
||||
// 4. Box height matches exact line formula
|
||||
expect(placement.h).toBe(realLines.length * 17 + 24)
|
||||
}
|
||||
}
|
||||
|
||||
expect(testedNodeCount).toBe(18)
|
||||
})
|
||||
|
||||
it('guarantees clamping for short tooltips (min-width 240px clamp) across all 18 nodes', () => {
|
||||
const shortLines: FormattedTooltipLine[] = [
|
||||
{ text: '火弹', color: 'gold', font: 'fontexocet10', align: 'center' },
|
||||
{ text: '当前等级: 1', color: 'white', font: 'font8', align: 'center' },
|
||||
]
|
||||
|
||||
for (const row of gridRows) {
|
||||
for (const col of gridCols) {
|
||||
const node = makeGridNode(row, col)
|
||||
const nodePos = SkillTreePanel.nodeScreenPos(node)
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, shortLines, mockFont)
|
||||
|
||||
expect(placement.w).toBe(240)
|
||||
expect(placement.h).toBe(2 * 17 + 24) // 58px
|
||||
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('guarantees bottom boundary containment for high-line-count tooltips (up to 25 lines) on row 6', () => {
|
||||
// 25 lines generates boxH = 25 * 17 + 24 = 449px
|
||||
const tallLines: FormattedTooltipLine[] = Array.from({ length: 25 }, (_, i) => ({
|
||||
text: `测试详尽技能描述行 ${i + 1}: 增加各项抗性与效果`,
|
||||
color: 'white' as const,
|
||||
font: 'font8' as const,
|
||||
align: 'center' as const,
|
||||
}))
|
||||
|
||||
for (const col of gridCols) {
|
||||
const node = makeGridNode(6, col)
|
||||
const nodePos = SkillTreePanel.nodeScreenPos(node)
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, tallLines, mockFont)
|
||||
|
||||
expect(placement.h).toBe(25 * 17 + 24)
|
||||
expect(placement.w).toBeGreaterThanOrEqual(240)
|
||||
expect(placement.w).toBeLessThanOrEqual(784)
|
||||
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
|
||||
// Row 6 is near the bottom; verify it does not push below control bar (y=552)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
// Verify it pinned to the bottom limit
|
||||
expect(placement.y + placement.h).toBe(552)
|
||||
}
|
||||
})
|
||||
|
||||
it('guarantees top boundary containment for high-line-count tooltips on row 1', () => {
|
||||
const tallLines: FormattedTooltipLine[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
text: `测试顶部技能描述行 ${i + 1}`,
|
||||
color: 'white' as const,
|
||||
font: 'font8' as const,
|
||||
align: 'center' as const,
|
||||
}))
|
||||
|
||||
for (const col of gridCols) {
|
||||
const node = makeGridNode(1, col)
|
||||
const nodePos = SkillTreePanel.nodeScreenPos(node)
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, tallLines, mockFont)
|
||||
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
// Row 1 node y is 78. Center is 102. rawY would be 102 - 364/2 = -80.
|
||||
// Clamping must pin ty to 8
|
||||
expect(placement.y).toBe(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles font-less fallback measurement cleanly', () => {
|
||||
const lines: FormattedTooltipLine[] = [
|
||||
{ text: '测试无字体对象降级宽度计算', color: 'gold', font: 'fontexocet10', align: 'center' },
|
||||
]
|
||||
const nodePos = SkillTreePanel.nodeScreenPos(makeGridNode(2, 2))
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(nodePos, lines, null as unknown as D2FontRenderer)
|
||||
|
||||
// Fallback is text.length * 8 = 13 * 8 = 104 -> clamped to min 240
|
||||
expect(placement.w).toBe(240)
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Text Measurement & Multi-Line Hierarchy for Diverse Skills', () => {
|
||||
it('verifies formatted lines for Fire Ball, Warmth, Blizzard, Meteor, and Shiver Armor contain valid color codes', () => {
|
||||
const targetSkills = [
|
||||
{ id: 47, name: 'Fire Ball', prereqs: [36] },
|
||||
{ id: 37, name: 'Warmth', prereqs: [] },
|
||||
{ id: 59, name: 'Blizzard', prereqs: [39, 44, 45, 55] },
|
||||
{ id: 56, name: 'Meteor', prereqs: [36, 41, 46, 47, 51] },
|
||||
{ id: 50, name: 'Shiver Armor', prereqs: [39, 40] },
|
||||
]
|
||||
|
||||
for (const target of targetSkills) {
|
||||
const state = createInitialState('sor')
|
||||
// Allocate prerequisites
|
||||
for (const prereq of target.prereqs) {
|
||||
allocatePoint(state, prereq, 1)
|
||||
}
|
||||
allocatePoint(state, target.id, 5)
|
||||
|
||||
const vm = buildTooltipViewModel(state, target.id, 85)
|
||||
expect(vm, `ViewModel for ${target.name} should exist`).not.toBeNull()
|
||||
|
||||
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
|
||||
expect(lines.length, `${target.name} should have formatted lines`).toBeGreaterThan(0)
|
||||
|
||||
// Title must be gold fontexocet10 centered
|
||||
expect(lines[0]!.color).toBe('gold')
|
||||
expect(lines[0]!.font).toBe('fontexocet10')
|
||||
expect(lines[0]!.align).toBe('center')
|
||||
|
||||
// Every line must use a valid D2 color code, valid font, and have non-empty text
|
||||
for (const line of lines) {
|
||||
expect(
|
||||
VALID_D2_COLOR_CODES,
|
||||
`Line "${line.text}" in ${target.name} has invalid color: ${line.color}`,
|
||||
).toContain(line.color)
|
||||
|
||||
expect(['font8', 'fontexocet10']).toContain(line.font)
|
||||
expect(line.text.trim().length).toBeGreaterThan(0)
|
||||
expect(line.text).not.toContain('undefined')
|
||||
expect(line.text).not.toContain('NaN')
|
||||
expect(line.text).not.toContain('null')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies all 30 skills in SORCERESS_SKILL_TREE produce valid colors across unlearned, learned, and maxed levels', () => {
|
||||
for (const node of SORCERESS_SKILL_TREE) {
|
||||
for (const lvl of [0, 1, 10, 20]) {
|
||||
const state = createInitialState('sor')
|
||||
if (lvl > 0) {
|
||||
// Allocate prerequisites so it can be learned
|
||||
for (const p of node.prereqs) {
|
||||
state.hardPoints[p] = 1
|
||||
}
|
||||
state.hardPoints[node.skillId] = lvl
|
||||
}
|
||||
|
||||
const vm = buildTooltipViewModel(state, node.skillId, 85)
|
||||
if (!vm) continue
|
||||
|
||||
const lines = SkillTreePanel.formatSkillTooltipLines(vm)
|
||||
for (const line of lines) {
|
||||
expect(
|
||||
VALID_D2_COLOR_CODES,
|
||||
`Skill ${node.nameEn} (lvl ${lvl}) has invalid color code "${line.color}"`,
|
||||
).toContain(line.color)
|
||||
expect(line.text).not.toContain('undefined')
|
||||
expect(line.text).not.toContain('NaN')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies non-damaging skills (Warmth, Teleport) do NOT output blank or undefined damage lines', () => {
|
||||
// 1. Warmth (passive mana regeneration)
|
||||
const warmthState = createInitialState('sor')
|
||||
allocatePoint(warmthState, 37, 5)
|
||||
const warmthVm = buildTooltipViewModel(warmthState, 37, 85)
|
||||
expect(warmthVm).not.toBeNull()
|
||||
expect(warmthVm!.current.damage.hasDamage).toBe(false)
|
||||
expect(warmthVm!.nextLevel?.damage.hasDamage).toBe(false)
|
||||
|
||||
const warmthLines = SkillTreePanel.formatSkillTooltipLines(warmthVm!)
|
||||
for (const line of warmthLines) {
|
||||
expect(line.text).not.toContain('undefined')
|
||||
expect(line.text).not.toContain('NaN')
|
||||
expect(line.text.trim().length).toBeGreaterThan(0)
|
||||
// Must not contain damage lines
|
||||
expect(line.text).not.toMatch(/^(火焰|冰冷|闪电|魔法|物理|毒素)?伤害:/)
|
||||
expect(line.text).not.toMatch(/Damage:/)
|
||||
}
|
||||
// Must contain mana regeneration rate in blue (distinguish effect stat line from narrative description)
|
||||
const regenLine = warmthLines.find(l => l.text.startsWith('法力回復速度:+'))
|
||||
expect(regenLine).toBeDefined()
|
||||
expect(regenLine?.color).toBe('blue')
|
||||
|
||||
// 2. Teleport (movement spell)
|
||||
const teleState = createInitialState('sor')
|
||||
allocatePoint(teleState, 43, 1) // Telekinesis
|
||||
allocatePoint(teleState, 54, 5) // Teleport
|
||||
const teleVm = buildTooltipViewModel(teleState, 54, 85)
|
||||
expect(teleVm).not.toBeNull()
|
||||
expect(teleVm!.current.damage.hasDamage).toBe(false)
|
||||
expect(teleVm!.nextLevel?.damage.hasDamage).toBe(false)
|
||||
|
||||
const teleLines = SkillTreePanel.formatSkillTooltipLines(teleVm!)
|
||||
for (const line of teleLines) {
|
||||
expect(line.text).not.toContain('undefined')
|
||||
expect(line.text).not.toContain('NaN')
|
||||
expect(line.text.trim().length).toBeGreaterThan(0)
|
||||
expect(line.text).not.toMatch(/^(火焰|冰冷|闪电|魔法|物理|毒素)?伤害:/)
|
||||
}
|
||||
// Must contain mana cost
|
||||
const manaLine = teleLines.find(l => l.text.includes('法力消耗'))
|
||||
expect(manaLine).toBeDefined()
|
||||
expect(manaLine?.color).toBe('white')
|
||||
})
|
||||
|
||||
it('verifies maxed skill (20 base points) contains max points notice [已达技能点投入上限 (20/20)] and NO green next level section', () => {
|
||||
const testSkills = [
|
||||
{ id: 47, name: 'Fire Ball', prereqs: [36] },
|
||||
{ id: 37, name: 'Warmth', prereqs: [] },
|
||||
{ id: 59, name: 'Blizzard', prereqs: [39, 44, 45, 55] },
|
||||
{ id: 56, name: 'Meteor', prereqs: [36, 41, 46, 47, 51] },
|
||||
{ id: 50, name: 'Shiver Armor', prereqs: [39, 40] },
|
||||
]
|
||||
|
||||
for (const t of testSkills) {
|
||||
const state = createInitialState('sor')
|
||||
for (const p of t.prereqs) state.hardPoints[p] = 1
|
||||
state.hardPoints[t.id] = 20 // 20 hard points
|
||||
|
||||
const vm = buildTooltipViewModel(state, t.id, 85)
|
||||
expect(vm, `${t.name} ViewModel should exist`).not.toBeNull()
|
||||
expect(vm!.hardPoints).toBe(20)
|
||||
expect(vm!.nextLevel).toBeNull() // Engine must suppress nextLevel
|
||||
|
||||
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
|
||||
|
||||
// 1. Must contain gold max notice
|
||||
const maxNotice = lines.find(l => l.text === '[已达技能点投入上限 (20/20)]')
|
||||
expect(maxNotice, `${t.name} must show max points notice`).toBeDefined()
|
||||
expect(maxNotice?.color).toBe('gold')
|
||||
expect(maxNotice?.font).toBe('font8')
|
||||
|
||||
// 2. Must NOT contain any next-level header
|
||||
expect(lines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
|
||||
// 3. Must NOT contain any green lines
|
||||
const greenLines = lines.filter(l => l.color === 'green')
|
||||
expect(greenLines.length, `${t.name} must not have any green lines when maxed`).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies maxed skill invariant holds when soft points (+skills gear) are present', () => {
|
||||
const state = createInitialState('sor')
|
||||
state.hardPoints[47] = 20 // 20 base points in Fire Ball
|
||||
state.allSkillsSoftPoints = 8 // +8 all skills from gear
|
||||
state.tabSoftPoints[0] = 3 // +3 fire tab
|
||||
|
||||
const vm = buildTooltipViewModel(state, 47, 85)
|
||||
expect(vm).not.toBeNull()
|
||||
expect(vm!.effectiveLevel).toBe(31) // 20 + 8 + 3
|
||||
expect(vm!.hardPoints).toBe(20)
|
||||
|
||||
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
|
||||
|
||||
// Current level line displays breakdown in blue
|
||||
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 31'))
|
||||
expect(lvlLine).toBeDefined()
|
||||
expect(lvlLine?.color).toBe('blue')
|
||||
expect(lvlLine?.text).toContain('(基础 20 + 装备 +11)')
|
||||
|
||||
// Max notice is still present in gold
|
||||
const maxNotice = lines.find(l => l.text === '[已达技能点投入上限 (20/20)]')
|
||||
expect(maxNotice).toBeDefined()
|
||||
expect(maxNotice?.color).toBe('gold')
|
||||
|
||||
// Next level section is completely omitted
|
||||
expect(lines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
expect(lines.filter(l => l.color === 'green').length).toBe(0)
|
||||
})
|
||||
|
||||
it('verifies unlearned skill contains red warning when player level < reqlevel', () => {
|
||||
const state = createInitialState('sor')
|
||||
|
||||
// 1. Level 1 Sorceress inspecting Frozen Orb (id 64, reqlevel 30)
|
||||
const orbVm = buildTooltipViewModel(state, 64, 1)
|
||||
expect(orbVm).not.toBeNull()
|
||||
expect(orbVm!.levelMet).toBe(false)
|
||||
|
||||
const orbLines = SkillTreePanel.formatSkillTooltipLines(orbVm!)
|
||||
const orbReqLevel = orbLines.find(l => l.text === '需要角色等级: 30')
|
||||
expect(orbReqLevel).toBeDefined()
|
||||
expect(orbReqLevel?.color).toBe('red')
|
||||
|
||||
// 2. Level 10 Sorceress inspecting Blizzard (id 59, reqlevel 24)
|
||||
const blizzVm = buildTooltipViewModel(state, 59, 10)
|
||||
expect(blizzVm).not.toBeNull()
|
||||
expect(blizzVm!.levelMet).toBe(false)
|
||||
|
||||
const blizzLines = SkillTreePanel.formatSkillTooltipLines(blizzVm!)
|
||||
const blizzReqLevel = blizzLines.find(l => l.text === '需要角色等级: 24')
|
||||
expect(blizzReqLevel).toBeDefined()
|
||||
expect(blizzReqLevel?.color).toBe('red')
|
||||
|
||||
// 3. Level 1 Sorceress inspecting Fire Bolt (id 36, reqlevel 1): requirements met
|
||||
const boltVm = buildTooltipViewModel(state, 36, 1)
|
||||
expect(boltVm).not.toBeNull()
|
||||
expect(boltVm!.levelMet).toBe(true)
|
||||
|
||||
const boltLines = SkillTreePanel.formatSkillTooltipLines(boltVm!)
|
||||
expect(boltLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
|
||||
})
|
||||
|
||||
it('verifies unlearned skill contains red warning when missing prerequisites', () => {
|
||||
const state = createInitialState('sor')
|
||||
|
||||
// Sorceress lvl 35 inspecting Frozen Orb (id 64) with 0 points in Blizzard
|
||||
const orbVm = buildTooltipViewModel(state, 64, 35)
|
||||
expect(orbVm).not.toBeNull()
|
||||
expect(orbVm!.levelMet).toBe(true)
|
||||
expect(orbVm!.prereqs.some(p => !p.met)).toBe(true)
|
||||
|
||||
const orbLines = SkillTreePanel.formatSkillTooltipLines(orbVm!)
|
||||
// Level is met, so no level warning
|
||||
expect(orbLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
|
||||
|
||||
// Prerequisite warning is red
|
||||
const prereqWarning = orbLines.find(l => l.text.includes('需要前置技能: 暴風雪'))
|
||||
expect(prereqWarning).toBeDefined()
|
||||
expect(prereqWarning?.color).toBe('red')
|
||||
|
||||
// Unlearned status line is gray
|
||||
const unlearnedNotice = orbLines.find(l => l.text === '未习得 (预览 1 级效果)')
|
||||
expect(unlearnedNotice).toBeDefined()
|
||||
expect(unlearnedNotice?.color).toBe('gray')
|
||||
|
||||
// Unlearned skill MUST NOT display [下一等级] preview
|
||||
expect(orbLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
})
|
||||
|
||||
it('verifies unlearned skill displays BOTH red warnings when level < reqlevel AND prerequisites are missing', () => {
|
||||
const state = createInitialState('sor')
|
||||
|
||||
// Level 5 Sorceress inspecting Meteor (id 56, reqlevel 24, prereqs: Fire Bolt, Inferno, Blaze, Fire Ball, Fire Wall)
|
||||
const meteorVm = buildTooltipViewModel(state, 56, 5)
|
||||
expect(meteorVm).not.toBeNull()
|
||||
expect(meteorVm!.levelMet).toBe(false)
|
||||
expect(meteorVm!.prereqs.some(p => !p.met)).toBe(true)
|
||||
|
||||
const lines = SkillTreePanel.formatSkillTooltipLines(meteorVm!)
|
||||
|
||||
const reqLvlLine = lines.find(l => l.text === '需要角色等级: 24')
|
||||
expect(reqLvlLine).toBeDefined()
|
||||
expect(reqLvlLine?.color).toBe('red')
|
||||
|
||||
const redPrereqs = lines.filter(l => l.color === 'red' && l.text.includes('需要前置技能'))
|
||||
expect(redPrereqs.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
// Unlearned status line is gray
|
||||
expect(lines.some(l => l.text === '未习得 (预览 1 级效果)' && l.color === 'gray')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Adversarial Edge Cases: Pathological Coordinates & Extreme Strings', () => {
|
||||
it('clamps gracefully even if node coordinates are placed outside canvas viewport', () => {
|
||||
const dummyLines: FormattedTooltipLine[] = [
|
||||
{ text: '测试极端坐标边界截断', color: 'gold', font: 'fontexocet10', align: 'center' },
|
||||
{ text: '数值测试行', color: 'white', font: 'font8', align: 'center' },
|
||||
]
|
||||
|
||||
// Test extreme positions: top-left (-100, -100), far-right (1000, 200), bottom-right (900, 700)
|
||||
const pathologicalPositions = [
|
||||
{ x: -100, y: -100, w: 48, h: 48 },
|
||||
{ x: 0, y: 0, w: 48, h: 48 },
|
||||
{ x: 1000, y: 200, w: 48, h: 48 },
|
||||
{ x: 500, y: 700, w: 48, h: 48 },
|
||||
{ x: 900, y: 900, w: 48, h: 48 },
|
||||
]
|
||||
|
||||
for (const pos of pathologicalPositions) {
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(pos, dummyLines, mockFont)
|
||||
|
||||
expect(placement.w).toBeGreaterThanOrEqual(240)
|
||||
expect(placement.w).toBeLessThanOrEqual(784)
|
||||
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
}
|
||||
})
|
||||
|
||||
it('correctly styles active vs inactive synergies in multi-line formatting', () => {
|
||||
const state = createInitialState('sor')
|
||||
allocatePoint(state, 36, 10) // Fire Bolt +10 (active synergy)
|
||||
// Meteor has 0 points (inactive synergy)
|
||||
allocatePoint(state, 47, 1) // Fire Ball
|
||||
|
||||
const vm = buildTooltipViewModel(state, 47, 85)
|
||||
expect(vm).not.toBeNull()
|
||||
|
||||
const lines = SkillTreePanel.formatSkillTooltipLines(vm!)
|
||||
|
||||
const synHeader = lines.find(l => l.text === '[协同技能加成]')
|
||||
expect(synHeader).toBeDefined()
|
||||
expect(synHeader?.color).toBe('blue')
|
||||
expect(synHeader?.font).toBe('fontexocet10')
|
||||
|
||||
// Active synergy for Fire Bolt: blue, shows (当前 +X%)
|
||||
const activeSyn = lines.find(l => l.text.includes('火彈') && l.text.includes('(当前 +'))
|
||||
expect(activeSyn).toBeDefined()
|
||||
expect(activeSyn?.color).toBe('blue')
|
||||
|
||||
// Inactive synergy for Meteor: gray, does NOT show (当前 +X%)
|
||||
const inactiveSyn = lines.find(l => l.text.includes('隕石') && !l.text.includes('(当前 +'))
|
||||
expect(inactiveSyn).toBeDefined()
|
||||
expect(inactiveSyn?.color).toBe('gray')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,360 @@
|
|||
/**
|
||||
* Empirical Challenger Stress Test Suite for Milestone M8:
|
||||
* Widescreen Docking Mathematics, Boundary Conditions, and Resolution Scaling.
|
||||
*
|
||||
* @author teamwork_preview_challenger
|
||||
* Target: `src/ui/hud-manager.ts` (`computeDockingLayout`, `computeHudLayout`)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
computeDockingLayout,
|
||||
computeHudLayout,
|
||||
type DockingLayout,
|
||||
} from '../src/ui/hud-manager.ts'
|
||||
|
||||
// Mock minimal window/DOM globals if running in headless node environment
|
||||
class MockWindow extends EventTarget {
|
||||
innerWidth = 1920
|
||||
innerHeight = 1080
|
||||
devicePixelRatio = 1
|
||||
}
|
||||
|
||||
if (typeof globalThis.window === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.window = new MockWindow()
|
||||
}
|
||||
if (typeof globalThis.HTMLInputElement === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.HTMLInputElement = class HTMLInputElement {}
|
||||
}
|
||||
if (typeof globalThis.HTMLSelectElement === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
||||
}
|
||||
|
||||
// Deterministic 32-bit PRNG (Mulberry32) for reproducible fuzzing
|
||||
function createPrng(seed: number = 0xdeadbeef) {
|
||||
let s = seed >>> 0
|
||||
return function nextFloat(): number {
|
||||
s = (s + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
// Independent Reference Oracle for Differential Testing
|
||||
function referenceDockingOracle(w: number, h: number, dpr: number = 1): DockingLayout {
|
||||
const safeW = Number.isFinite(w) && w > 0 ? w : 1
|
||||
const safeH = Number.isFinite(h) && h > 0 ? h : 1
|
||||
const safeDpr = Number.isFinite(dpr) && dpr > 0 ? dpr : 1
|
||||
|
||||
const uiScale = Math.min(safeW / 800, safeH / 600)
|
||||
const offsetX = Math.max(0, (safeW - 800 * uiScale) / 2)
|
||||
const offsetY = Math.max(0, safeH - 600 * uiScale)
|
||||
|
||||
const marginW = uiScale > 0 ? offsetX / uiScale : 0
|
||||
const leftDockX = -marginW || 0
|
||||
const rightDockX = 400 + marginW
|
||||
const channelLeft = 400 - marginW
|
||||
const channelRight = 400 + marginW
|
||||
const channelWidthLogical = 2 * marginW
|
||||
const channelWidthCss = 2 * offsetX
|
||||
const isWidescreen = marginW > 0
|
||||
|
||||
return {
|
||||
uiScale,
|
||||
offsetX,
|
||||
offsetY,
|
||||
dpr: safeDpr,
|
||||
physicalScale: uiScale * safeDpr,
|
||||
physicalOffsetX: Math.round(offsetX * safeDpr),
|
||||
physicalOffsetY: Math.round(offsetY * safeDpr),
|
||||
marginW,
|
||||
leftDockX,
|
||||
rightDockX,
|
||||
channelLeft,
|
||||
channelRight,
|
||||
channelWidthLogical,
|
||||
channelWidthCss,
|
||||
isWidescreen,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Challenger M8: Widescreen Docking Mathematics Stress Test Suite', () => {
|
||||
describe('1. Full Spectrum Resolution Matrix Stress Testing', () => {
|
||||
const STANDARD_RESOLUTIONS: Array<{
|
||||
name: string
|
||||
w: number
|
||||
h: number
|
||||
expectedAspect: string
|
||||
expectedWidescreen: boolean
|
||||
}> = [
|
||||
{ name: 'SVGA 4:3 (Classic)', w: 800, h: 600, expectedAspect: '4:3', expectedWidescreen: false },
|
||||
{ name: 'XGA 4:3', w: 1024, h: 768, expectedAspect: '4:3', expectedWidescreen: false },
|
||||
{ name: 'HD 16:9', w: 1280, h: 720, expectedAspect: '16:9', expectedWidescreen: true },
|
||||
{ name: 'WXGA 16:10', w: 1280, h: 800, expectedAspect: '16:10', expectedWidescreen: true },
|
||||
{ name: 'HD+ 16:9', w: 1366, h: 768, expectedAspect: '16:9', expectedWidescreen: true },
|
||||
{ name: 'WSXGA 16:10', w: 1440, h: 900, expectedAspect: '16:10', expectedWidescreen: true },
|
||||
{ name: 'HD+ 16:9', w: 1600, h: 900, expectedAspect: '16:9', expectedWidescreen: true },
|
||||
{ name: 'FHD 1080p 16:9', w: 1920, h: 1080, expectedAspect: '16:9', expectedWidescreen: true },
|
||||
{ name: 'QHD 1440p 16:9', w: 2560, h: 1440, expectedAspect: '16:9', expectedWidescreen: true },
|
||||
{ name: 'UWQHD 21:9', w: 3440, h: 1440, expectedAspect: '21:9', expectedWidescreen: true },
|
||||
{ name: '4K UHD 16:9', w: 3840, h: 2160, expectedAspect: '16:9', expectedWidescreen: true },
|
||||
{ name: 'Super Ultrawide 32:9', w: 5120, h: 1440, expectedAspect: '32:9', expectedWidescreen: true },
|
||||
]
|
||||
|
||||
it('evaluates computeDockingLayout accurately across all 12 standard resolutions', () => {
|
||||
for (const res of STANDARD_RESOLUTIONS) {
|
||||
const layout = computeDockingLayout(res.w, res.h)
|
||||
|
||||
expect(layout.isWidescreen, `${res.name} (${res.w}x${res.h}) widescreen flag`).toBe(
|
||||
res.expectedWidescreen,
|
||||
)
|
||||
|
||||
// Basic sanity
|
||||
expect(layout.uiScale).toBeGreaterThan(0)
|
||||
expect(layout.marginW).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Invariant: channelRight >= channelLeft and channelWidthLogical >= 0 (zero panel overlap)
|
||||
expect(layout.channelRight).toBeGreaterThanOrEqual(layout.channelLeft)
|
||||
expect(layout.channelWidthLogical).toBeGreaterThanOrEqual(0)
|
||||
const overlap = Math.max(0, layout.channelLeft - layout.channelRight)
|
||||
expect(overlap, `${res.name} panel overlap`).toBe(0)
|
||||
|
||||
// Invariant: leftDockX == -marginW and rightDockX == 400 + marginW
|
||||
expect(layout.leftDockX).toBeCloseTo(-layout.marginW, 6)
|
||||
expect(layout.rightDockX).toBeCloseTo(400 + layout.marginW, 6)
|
||||
|
||||
// Invariant: channelWidthCss == 2 * offsetX == cssW - 800 * uiScale
|
||||
if (res.w / res.h >= 4 / 3 - 1e-6) {
|
||||
expect(layout.channelWidthCss).toBeCloseTo(2 * layout.offsetX, 6)
|
||||
expect(layout.channelWidthCss).toBeCloseTo(res.w - 800 * layout.uiScale, 6)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('fuzzes across 300 randomly generated resolutions (widths 320 to 7680, heights 240 to 4320)', () => {
|
||||
const prng = createPrng(0x1337c0de)
|
||||
const FUZZ_COUNT = 300
|
||||
|
||||
for (let i = 0; i < FUZZ_COUNT; i++) {
|
||||
// Generate diverse viewport sizes: widths 320..7680, heights 240..4320
|
||||
const cssW = Math.round(320 + prng() * (7680 - 320))
|
||||
const cssH = Math.round(240 + prng() * (4320 - 240))
|
||||
const dpr = 0.5 + prng() * 3.5
|
||||
|
||||
const layout = computeDockingLayout(cssW, cssH, dpr)
|
||||
const oracle = referenceDockingOracle(cssW, cssH, dpr)
|
||||
|
||||
// Differential assertion against oracle
|
||||
expect(layout.uiScale).toBeCloseTo(oracle.uiScale, 8)
|
||||
expect(layout.offsetX).toBeCloseTo(oracle.offsetX, 8)
|
||||
expect(layout.offsetY).toBeCloseTo(oracle.offsetY, 8)
|
||||
expect(layout.marginW).toBeCloseTo(oracle.marginW, 8)
|
||||
expect(layout.leftDockX).toBeCloseTo(oracle.leftDockX, 8)
|
||||
expect(layout.rightDockX).toBeCloseTo(oracle.rightDockX, 8)
|
||||
expect(layout.channelLeft).toBeCloseTo(oracle.channelLeft, 8)
|
||||
expect(layout.channelRight).toBeCloseTo(oracle.channelRight, 8)
|
||||
expect(layout.channelWidthLogical).toBeCloseTo(oracle.channelWidthLogical, 8)
|
||||
expect(layout.channelWidthCss).toBeCloseTo(oracle.channelWidthCss, 8)
|
||||
expect(layout.isWidescreen).toBe(oracle.isWidescreen)
|
||||
|
||||
// Invariant 1: channelRight >= channelLeft and channelWidthLogical >= 0 (zero overlap)
|
||||
expect(layout.channelRight).toBeGreaterThanOrEqual(layout.channelLeft)
|
||||
expect(layout.channelWidthLogical).toBeGreaterThanOrEqual(0)
|
||||
const overlap = Math.max(0, layout.channelLeft - layout.channelRight)
|
||||
expect(overlap).toBe(0)
|
||||
|
||||
// Invariant 2: channelWidthCss == 2 * offsetX == cssW - 800 * uiScale for aspect >= 4:3
|
||||
const aspectRatio = cssW / cssH
|
||||
if (aspectRatio >= 4 / 3) {
|
||||
expect(Math.abs(layout.channelWidthCss - 2 * layout.offsetX)).toBeLessThan(1e-6)
|
||||
expect(Math.abs(layout.channelWidthCss - (cssW - 800 * layout.uiScale))).toBeLessThan(1e-6)
|
||||
}
|
||||
|
||||
// Invariant 3: leftDockX == -marginW and rightDockX == 400 + marginW
|
||||
expect(Math.abs(layout.leftDockX - (-layout.marginW))).toBeLessThan(1e-6)
|
||||
expect(Math.abs(layout.rightDockX - (400 + layout.marginW))).toBeLessThan(1e-6)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Classic 800x600 & 4:3 Bit-Exact Parity', () => {
|
||||
it('verifies bit-exact D2 classic parity at 800x600', () => {
|
||||
const docking = computeDockingLayout(800, 600)
|
||||
|
||||
// Bit-exact value assertions per spec
|
||||
expect(docking.marginW).toBe(0)
|
||||
expect(docking.leftDockX).toBe(0)
|
||||
expect(docking.rightDockX).toBe(400)
|
||||
expect(docking.channelWidthLogical).toBe(0)
|
||||
expect(docking.channelLeft).toBe(400)
|
||||
expect(docking.channelRight).toBe(400)
|
||||
expect(docking.channelWidthCss).toBe(0)
|
||||
expect(docking.isWidescreen).toBe(false)
|
||||
expect(docking.uiScale).toBe(1.0)
|
||||
expect(docking.offsetX).toBe(0)
|
||||
expect(docking.offsetY).toBe(0)
|
||||
})
|
||||
|
||||
it('verifies bit-exact 4:3 scaling parity at 1024x768', () => {
|
||||
const docking = computeDockingLayout(1024, 768)
|
||||
|
||||
// Bit-exact value assertions per spec
|
||||
expect(docking.marginW).toBe(0)
|
||||
expect(docking.leftDockX).toBe(0)
|
||||
expect(docking.rightDockX).toBe(400)
|
||||
expect(docking.channelWidthLogical).toBe(0)
|
||||
expect(docking.channelLeft).toBe(400)
|
||||
expect(docking.channelRight).toBe(400)
|
||||
expect(docking.channelWidthCss).toBe(0)
|
||||
expect(docking.isWidescreen).toBe(false)
|
||||
expect(docking.uiScale).toBe(1.28)
|
||||
expect(docking.offsetX).toBe(0)
|
||||
expect(docking.offsetY).toBe(0)
|
||||
})
|
||||
|
||||
it('verifies 4:3 aspect ratio maintains zero margin across multiple standard 4:3 resolutions', () => {
|
||||
const fourByThreeList = [
|
||||
[640, 480],
|
||||
[800, 600],
|
||||
[1024, 768],
|
||||
[1152, 864],
|
||||
[1280, 960],
|
||||
[1400, 1050],
|
||||
[1600, 1200],
|
||||
[2048, 1536],
|
||||
]
|
||||
|
||||
for (const [w, h] of fourByThreeList) {
|
||||
const d = computeDockingLayout(w, h)
|
||||
expect(d.marginW).toBe(0)
|
||||
expect(d.leftDockX).toBe(0)
|
||||
expect(d.rightDockX).toBe(400)
|
||||
expect(d.channelLeft).toBe(400)
|
||||
expect(d.channelRight).toBe(400)
|
||||
expect(d.channelWidthLogical).toBe(0)
|
||||
expect(d.channelWidthCss).toBe(0)
|
||||
expect(d.offsetX).toBe(0)
|
||||
expect(d.isWidescreen).toBe(false)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Extreme Viewport Boundary Cases', () => {
|
||||
it('handles narrow and portrait viewports without negative marginW or NaN', () => {
|
||||
const portraitViewports = [
|
||||
{ name: '600x800', w: 600, h: 800 },
|
||||
{ name: 'Mobile 360x640', w: 360, h: 640 },
|
||||
{ name: 'Mobile FHD 1080x1920', w: 1080, h: 1920 },
|
||||
{ name: 'Mobile HD 720x1280', w: 720, h: 1280 },
|
||||
{ name: 'iPhone 390x844', w: 390, h: 844 },
|
||||
{ name: 'Ultra-thin column 100x1000', w: 100, h: 1000 },
|
||||
]
|
||||
|
||||
for (const vp of portraitViewports) {
|
||||
const layout = computeDockingLayout(vp.w, vp.h)
|
||||
|
||||
expect(Number.isNaN(layout.marginW), `${vp.name} marginW is NaN`).toBe(false)
|
||||
expect(Number.isFinite(layout.marginW), `${vp.name} marginW is finite`).toBe(true)
|
||||
expect(layout.marginW, `${vp.name} marginW === 0`).toBe(0)
|
||||
expect(layout.leftDockX, `${vp.name} leftDockX === 0`).toBe(0)
|
||||
expect(layout.rightDockX, `${vp.name} rightDockX === 400`).toBe(400)
|
||||
expect(layout.channelLeft, `${vp.name} channelLeft === 400`).toBe(400)
|
||||
expect(layout.channelRight, `${vp.name} channelRight === 400`).toBe(400)
|
||||
expect(layout.channelWidthLogical, `${vp.name} channelWidthLogical === 0`).toBe(0)
|
||||
expect(layout.channelWidthCss, `${vp.name} channelWidthCss === 0`).toBe(0)
|
||||
expect(layout.isWidescreen, `${vp.name} isWidescreen is false`).toBe(false)
|
||||
expect(layout.offsetX, `${vp.name} offsetX === 0`).toBe(0)
|
||||
expect(layout.offsetY, `${vp.name} offsetY >= 0`).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles zero, negative, and invalid numerical inputs gracefully without crashes or division by zero', () => {
|
||||
const adversarialInputs = [
|
||||
{ name: '0x0', w: 0, h: 0 },
|
||||
{ name: '-100x600', w: -100, h: 600 },
|
||||
{ name: '800x-600', w: 800, h: -600 },
|
||||
{ name: '-500x-500', w: -500, h: -500 },
|
||||
{ name: 'NaN x 600', w: NaN, h: 600 },
|
||||
{ name: '800 x NaN', w: 800, h: NaN },
|
||||
{ name: 'Infinity x 600', w: Infinity, h: 600 },
|
||||
{ name: '800 x -Infinity', w: 800, h: -Infinity },
|
||||
]
|
||||
|
||||
for (const adv of adversarialInputs) {
|
||||
expect(() => {
|
||||
const layout = computeDockingLayout(adv.w, adv.h)
|
||||
|
||||
expect(Number.isFinite(layout.uiScale), `${adv.name} uiScale is finite`).toBe(true)
|
||||
expect(layout.uiScale, `${adv.name} uiScale > 0`).toBeGreaterThan(0)
|
||||
expect(Number.isFinite(layout.marginW), `${adv.name} marginW is finite`).toBe(true)
|
||||
expect(layout.marginW, `${adv.name} marginW >= 0`).toBeGreaterThanOrEqual(0)
|
||||
expect(Number.isFinite(layout.leftDockX), `${adv.name} leftDockX is finite`).toBe(true)
|
||||
expect(Number.isFinite(layout.rightDockX), `${adv.name} rightDockX is finite`).toBe(true)
|
||||
expect(Number.isFinite(layout.channelLeft), `${adv.name} channelLeft is finite`).toBe(true)
|
||||
expect(Number.isFinite(layout.channelRight), `${adv.name} channelRight is finite`).toBe(true)
|
||||
expect(layout.channelRight).toBeGreaterThanOrEqual(layout.channelLeft)
|
||||
}).not.toThrow()
|
||||
}
|
||||
})
|
||||
|
||||
it('scales layout dimensions cleanly across fractional DPIs (1.25, 1.5, 1.75, 2.0, 2.625)', () => {
|
||||
const fractionalDprs = [1.25, 1.5, 1.75, 2.0, 2.625]
|
||||
const baseResolutions = [
|
||||
[800, 600],
|
||||
[1920, 1080],
|
||||
[2560, 1440],
|
||||
[3440, 1440],
|
||||
]
|
||||
|
||||
for (const [w, h] of baseResolutions) {
|
||||
for (const dpr of fractionalDprs) {
|
||||
const layout = computeDockingLayout(w, h, dpr)
|
||||
|
||||
// Verification of physical scaling metrics
|
||||
expect(layout.dpr).toBe(dpr)
|
||||
expect(layout.physicalScale).toBeCloseTo(layout.uiScale * dpr, 6)
|
||||
expect(layout.physicalOffsetX).toBe(Math.round(layout.offsetX * dpr))
|
||||
expect(layout.physicalOffsetY).toBe(Math.round(layout.offsetY * dpr))
|
||||
|
||||
// DPR should not alter logical docking metrics
|
||||
const baseLayout = computeDockingLayout(w, h, 1)
|
||||
expect(layout.marginW).toBeCloseTo(baseLayout.marginW, 8)
|
||||
expect(layout.leftDockX).toBeCloseTo(baseLayout.leftDockX, 8)
|
||||
expect(layout.rightDockX).toBeCloseTo(baseLayout.rightDockX, 8)
|
||||
expect(layout.channelLeft).toBeCloseTo(baseLayout.channelLeft, 8)
|
||||
expect(layout.channelRight).toBeCloseTo(baseLayout.channelRight, 8)
|
||||
expect(layout.channelWidthLogical).toBeCloseTo(baseLayout.channelWidthLogical, 8)
|
||||
expect(layout.channelWidthCss).toBeCloseTo(baseLayout.channelWidthCss, 8)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies extreme gargantuan (8K, 16K) and microscopic viewports', () => {
|
||||
// 8K UHD: 7680x4320 (16:9)
|
||||
const uhd8k = computeDockingLayout(7680, 4320)
|
||||
expect(uhd8k.uiScale).toBeCloseTo(7.2, 4)
|
||||
expect(uhd8k.offsetX).toBe(960)
|
||||
expect(uhd8k.marginW).toBeCloseTo(133.333333, 4)
|
||||
expect(uhd8k.channelWidthCss).toBe(1920)
|
||||
expect(uhd8k.channelWidthLogical).toBeCloseTo(266.666667, 4)
|
||||
|
||||
// 16K: 15360x8640 (16:9)
|
||||
const uhd16k = computeDockingLayout(15360, 8640)
|
||||
expect(uhd16k.uiScale).toBeCloseTo(14.4, 4)
|
||||
expect(uhd16k.offsetX).toBe(1920)
|
||||
expect(uhd16k.marginW).toBeCloseTo(133.333333, 4)
|
||||
expect(uhd16k.channelWidthCss).toBe(3840)
|
||||
|
||||
// Microscopic viewports
|
||||
const micro = computeDockingLayout(1, 1)
|
||||
expect(micro.uiScale).toBeGreaterThan(0)
|
||||
expect(micro.marginW).toBe(0)
|
||||
expect(micro.channelWidthLogical).toBe(0)
|
||||
expect(micro.channelWidthCss).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,834 @@
|
|||
/**
|
||||
* Challenger M8 Stress Test Suite: Pointer Hit-Testing, Coordinate Translation,
|
||||
* Quick Transfer, and HUD Interception under Widescreen Docking.
|
||||
*
|
||||
* Implements empirical challenger verification following the solution-stress-testing
|
||||
* methodology (generator, oracle, property invariants, edge-case probing).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
HudManager,
|
||||
computeDockingLayout,
|
||||
computeHudLayout,
|
||||
clientToLogical,
|
||||
logicalToClient,
|
||||
CHAR_PANEL_ORIGIN,
|
||||
INV_PANEL_ORIGIN,
|
||||
INV_GRID_ORIGIN,
|
||||
SKILL_PANEL_ORIGIN,
|
||||
} from '../src/ui/hud-manager.ts'
|
||||
import {
|
||||
InventoryPanel,
|
||||
EQUIP_SLOTS_LAYOUT,
|
||||
INV_CLOSE_BTN_BOUNDS,
|
||||
type EquipSlotId,
|
||||
type UiInventoryItem,
|
||||
} from '../src/ui/inventory.ts'
|
||||
import { CHAR_CLOSE_BTN_BOUNDS } from '../src/ui/character-sheet.ts'
|
||||
import { STASH_GRID_ORIGIN, STASH_PANEL_ORIGIN } from '../src/ui/world-panels.ts'
|
||||
|
||||
class MockWindow extends EventTarget {
|
||||
innerWidth = 1920
|
||||
innerHeight = 1080
|
||||
devicePixelRatio = 1
|
||||
}
|
||||
|
||||
if (typeof (globalThis as any).window === 'undefined') {
|
||||
;(globalThis as any).window = new MockWindow()
|
||||
}
|
||||
if (typeof (globalThis as any).HTMLInputElement === 'undefined') {
|
||||
;(globalThis as any).HTMLInputElement = class HTMLInputElement {}
|
||||
}
|
||||
if (typeof (globalThis as any).HTMLSelectElement === 'undefined') {
|
||||
;(globalThis as any).HTMLSelectElement = class HTMLSelectElement {}
|
||||
}
|
||||
|
||||
class MockCanvas extends EventTarget {
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
width: number
|
||||
height: number
|
||||
style = { pointerEvents: 'none' }
|
||||
|
||||
constructor(width: number, height: number) {
|
||||
super()
|
||||
this.clientWidth = width
|
||||
this.clientHeight = height
|
||||
this.width = width
|
||||
this.height = height
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }
|
||||
}
|
||||
|
||||
getContext() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class SyntheticMouseEvent extends Event {
|
||||
readonly clientX: number
|
||||
readonly clientY: number
|
||||
readonly button: number
|
||||
readonly shiftKey: boolean
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
init: { clientX: number; clientY: number; button?: number; shiftKey?: boolean },
|
||||
) {
|
||||
super(type, { cancelable: true, bubbles: true })
|
||||
this.clientX = init.clientX
|
||||
this.clientY = init.clientY
|
||||
this.button = init.button ?? 0
|
||||
this.shiftKey = init.shiftKey ?? false
|
||||
}
|
||||
}
|
||||
|
||||
function createDummyItem(
|
||||
id: string,
|
||||
code: string,
|
||||
invWidth: number,
|
||||
invHeight: number,
|
||||
nameZh: string,
|
||||
allowedSlots: readonly EquipSlotId[] = [],
|
||||
): UiInventoryItem {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
invFile: code,
|
||||
name: nameZh,
|
||||
nameZh,
|
||||
baseNameZh: nameZh,
|
||||
quality: 'normal',
|
||||
invWidth,
|
||||
invHeight,
|
||||
allowedSlots,
|
||||
stats: [],
|
||||
}
|
||||
}
|
||||
|
||||
const WIDESCREEN_RESOLUTIONS = [
|
||||
{ name: '1280x720 (16:9)', width: 1280, height: 720 },
|
||||
{ name: '1920x1080 (16:9 Full HD)', width: 1920, height: 1080 },
|
||||
{ name: '2560x1440 (16:9 QHD)', width: 2560, height: 1440 },
|
||||
{ name: '3440x1440 (21:9 Ultrawide)', width: 3440, height: 1440 },
|
||||
] as const
|
||||
|
||||
describe('Challenger M8: Pointer Hit-Testing & Coordinate Translation under Widescreen Docking', () => {
|
||||
describe('1. Pointer Hit-Testing Round-Trip Accuracy Across Widescreen Resolutions', () => {
|
||||
for (const res of WIDESCREEN_RESOLUTIONS) {
|
||||
describe(`Resolution ${res.name}`, () => {
|
||||
let canvas: MockCanvas
|
||||
let hud: HudManager
|
||||
|
||||
beforeEach(() => {
|
||||
const win = new MockWindow()
|
||||
win.innerWidth = res.width
|
||||
win.innerHeight = res.height
|
||||
;(globalThis as any).window = win
|
||||
|
||||
canvas = new MockCanvas(res.width, res.height)
|
||||
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
hud['bindEvents']()
|
||||
hud.rightPanel = 'inv'
|
||||
hud.inventory.visible = true
|
||||
})
|
||||
|
||||
it(`tests Inventory Grid (10x4) hit-testing at all 40 cells with dynamic offset (rightDockX + 40 + col * 29)`, () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
expect(docking.isWidescreen).toBe(true)
|
||||
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
for (let col = 0; col < INV_GRID_ORIGIN.cols; col++) {
|
||||
for (let row = 0; row < INV_GRID_ORIGIN.rows; row++) {
|
||||
// Formula specified by user request: rightDockX + 40 + col * 29
|
||||
const dynamicLogicalX = docking.rightDockX + 40 + col * INV_GRID_ORIGIN.cellPx
|
||||
const dynamicLogicalY = INV_GRID_ORIGIN.y + 14.5 + row * INV_GRID_ORIGIN.cellPx
|
||||
|
||||
// Convert logical to client coordinates
|
||||
const clientPos = logicalToClient(dynamicLogicalX, dynamicLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
// Test 1: Mathematical round-trip translation
|
||||
const pt = clientToLogical(clientPos.x, clientPos.y, canvas.getBoundingClientRect())
|
||||
const translatedRightX = pt.x - deltaRight
|
||||
const translatedLogicalY = pt.y
|
||||
|
||||
const hitCol = Math.floor((translatedRightX - INV_GRID_ORIGIN.x) / INV_GRID_ORIGIN.cellPx)
|
||||
const hitRow = Math.floor((translatedLogicalY - INV_GRID_ORIGIN.y) / INV_GRID_ORIGIN.cellPx)
|
||||
|
||||
// Assert translated rightX hits exact corresponding col and row with 0 pixel drift
|
||||
expect(hitCol).toBe(col)
|
||||
expect(hitRow).toBe(row)
|
||||
|
||||
const cellBaseX = INV_GRID_ORIGIN.x + col * INV_GRID_ORIGIN.cellPx
|
||||
const offsetInCell = translatedRightX - cellBaseX
|
||||
// Since rightDockX = 400 + marginW and INV_GRID_ORIGIN.x = 418:
|
||||
// translatedRightX - INV_GRID_ORIGIN.x = (400 + 40 + col*29) - 418 = 22 + col*29
|
||||
// So offsetInCell must be exactly 22px, with zero drift
|
||||
expect(offsetInCell).toBeCloseTo(22, 5)
|
||||
|
||||
// Test 2: Empirical click round-trip (pick up and put back item at this cell)
|
||||
const testItem = createDummyItem(`item-${col}-${row}`, 'rin', 1, 1, `TestRing-${col}-${row}`)
|
||||
hud.inventory.gridItems = [{ item: testItem, col, row }]
|
||||
hud.inventory.cursorItem = null
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
button: 0,
|
||||
shiftKey: false,
|
||||
}),
|
||||
)
|
||||
|
||||
// Item must have been picked up from exact cell into cursorItem
|
||||
expect(hud.inventory.cursorItem).not.toBeNull()
|
||||
expect((hud.inventory.cursorItem as UiInventoryItem | null)?.id).toBe(`item-${col}-${row}`)
|
||||
expect(hud.inventory.gridItems).toHaveLength(0)
|
||||
|
||||
// Click again at same cell to drop it back
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
button: 0,
|
||||
shiftKey: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.inventory.cursorItem).toBeNull()
|
||||
expect(hud.inventory.gridItems).toHaveLength(1)
|
||||
expect(hud.inventory.gridItems[0]!.col).toBe(col)
|
||||
expect(hud.inventory.gridItems[0]!.row).toBe(row)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('tests Equipment Slots (all 10 slots) bounding box hit-testing accuracy', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
const slotIds: EquipSlotId[] = [
|
||||
'helm',
|
||||
'amulet',
|
||||
'weapon1',
|
||||
'armor',
|
||||
'weapon2',
|
||||
'gloves',
|
||||
'ring1',
|
||||
'belt',
|
||||
'ring2',
|
||||
'boots',
|
||||
]
|
||||
|
||||
for (const slotId of slotIds) {
|
||||
const rect = EQUIP_SLOTS_LAYOUT[slotId]
|
||||
const canonicalCenterX = rect.x + rect.w / 2
|
||||
const canonicalCenterY = rect.y + rect.h / 2
|
||||
|
||||
// Visual position in widescreen right dock
|
||||
const visualX = canonicalCenterX + deltaRight
|
||||
const visualY = canonicalCenterY
|
||||
const clientPos = logicalToClient(visualX, visualY, canvas.getBoundingClientRect())
|
||||
|
||||
// Mathematical translation check
|
||||
const pt = clientToLogical(clientPos.x, clientPos.y, canvas.getBoundingClientRect())
|
||||
const rightX = pt.x - deltaRight
|
||||
expect(rightX).toBeGreaterThanOrEqual(rect.x)
|
||||
expect(rightX).toBeLessThanOrEqual(rect.x + rect.w)
|
||||
expect(pt.y).toBeGreaterThanOrEqual(rect.y)
|
||||
expect(pt.y).toBeLessThanOrEqual(rect.y + rect.h)
|
||||
|
||||
// Empirical click interaction check
|
||||
const eqItem = createDummyItem(`eq-${slotId}`, 'cap', 2, 2, `Item-${slotId}`, [slotId])
|
||||
hud.inventory.equipped[slotId] = eqItem
|
||||
hud.inventory.cursorItem = null
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
button: 0,
|
||||
shiftKey: false,
|
||||
}),
|
||||
)
|
||||
|
||||
// Item unequipped to cursor
|
||||
expect((hud.inventory.cursorItem as UiInventoryItem | null)?.id).toBe(`eq-${slotId}`)
|
||||
expect(hud.inventory.equipped[slotId]).toBeUndefined()
|
||||
|
||||
// Click slot again to re-equip
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
button: 0,
|
||||
shiftKey: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.inventory.cursorItem).toBeNull()
|
||||
expect(hud.inventory.equipped[slotId]?.id).toBe(`eq-${slotId}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('tests Character Sheet close button (left bezel) and Inventory close button (right bezel)', () => {
|
||||
hud.leftPanel = 'char'
|
||||
hud.charSheet.visible = true
|
||||
hud.rightPanel = 'inv'
|
||||
hud.inventory.visible = true
|
||||
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
// 1. Character Sheet Close Button
|
||||
const charCloseCenterLogicalX = CHAR_CLOSE_BTN_BOUNDS.x + CHAR_CLOSE_BTN_BOUNDS.w / 2 + deltaLeft
|
||||
const charCloseCenterLogicalY = CHAR_CLOSE_BTN_BOUNDS.y + CHAR_CLOSE_BTN_BOUNDS.h / 2
|
||||
const charClientPos = logicalToClient(charCloseCenterLogicalX, charCloseCenterLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
const charPt = clientToLogical(charClientPos.x, charClientPos.y, canvas.getBoundingClientRect())
|
||||
const translatedLeftX = charPt.x - deltaLeft
|
||||
expect(translatedLeftX).toBeGreaterThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.x)
|
||||
expect(translatedLeftX).toBeLessThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.x + CHAR_CLOSE_BTN_BOUNDS.w)
|
||||
expect(charPt.y).toBeGreaterThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.y)
|
||||
expect(charPt.y).toBeLessThanOrEqual(CHAR_CLOSE_BTN_BOUNDS.y + CHAR_CLOSE_BTN_BOUNDS.h)
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: charClientPos.x,
|
||||
clientY: charClientPos.y,
|
||||
button: 0,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
expect(hud.charSheet.visible).toBe(false)
|
||||
|
||||
// 2. Inventory Close Button
|
||||
const invCloseCenterLogicalX = INV_CLOSE_BTN_BOUNDS.x + INV_CLOSE_BTN_BOUNDS.w / 2 + deltaRight
|
||||
const invCloseCenterLogicalY = INV_CLOSE_BTN_BOUNDS.y + INV_CLOSE_BTN_BOUNDS.h / 2
|
||||
const invClientPos = logicalToClient(invCloseCenterLogicalX, invCloseCenterLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
const invPt = clientToLogical(invClientPos.x, invClientPos.y, canvas.getBoundingClientRect())
|
||||
const translatedRightX = invPt.x - deltaRight
|
||||
expect(translatedRightX).toBeGreaterThanOrEqual(INV_CLOSE_BTN_BOUNDS.x)
|
||||
expect(translatedRightX).toBeLessThanOrEqual(INV_CLOSE_BTN_BOUNDS.x + INV_CLOSE_BTN_BOUNDS.w)
|
||||
expect(invPt.y).toBeGreaterThanOrEqual(INV_CLOSE_BTN_BOUNDS.y)
|
||||
expect(invPt.y).toBeLessThanOrEqual(INV_CLOSE_BTN_BOUNDS.y + INV_CLOSE_BTN_BOUNDS.h)
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: invClientPos.x,
|
||||
clientY: invClientPos.y,
|
||||
button: 0,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.rightPanel).toBe('none')
|
||||
expect(hud.inventory.visible).toBe(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('2. Central Vision Corridor Passthrough Stress Testing (1920x1080, marginW = 133.33)', () => {
|
||||
let canvas: MockCanvas
|
||||
let hud: HudManager
|
||||
|
||||
beforeEach(() => {
|
||||
const win = new MockWindow()
|
||||
win.innerWidth = 1920
|
||||
win.innerHeight = 1080
|
||||
;(globalThis as any).window = win
|
||||
|
||||
canvas = new MockCanvas(1920, 1080)
|
||||
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
hud['bindEvents']()
|
||||
})
|
||||
|
||||
const panelPairs: { left: 'char' | 'stash' | 'quest'; right: 'inv' | 'skill' }[] = [
|
||||
{ left: 'char', right: 'inv' },
|
||||
{ left: 'stash', right: 'inv' },
|
||||
{ left: 'quest', right: 'skill' },
|
||||
]
|
||||
|
||||
for (const pair of panelPairs) {
|
||||
describe(`Dual Panels: '${pair.left}' + '${pair.right}'`, () => {
|
||||
beforeEach(() => {
|
||||
hud.leftPanel = pair.left
|
||||
if (pair.left === 'char') hud.charSheet.visible = true
|
||||
hud.rightPanel = pair.right
|
||||
if (pair.right === 'inv') hud.inventory.visible = true
|
||||
if (pair.right === 'skill') hud.skillTree.visible = true
|
||||
})
|
||||
|
||||
it('probes points strictly inside central corridor (400 - marginW, 400 + marginW): returns false', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const marginW = docking.marginW // 133.333...
|
||||
const leftBound = 400 - marginW
|
||||
const rightBound = 400 + marginW
|
||||
|
||||
// Dense sweep of points across corridor width
|
||||
const testXs = [
|
||||
leftBound + 0.5,
|
||||
leftBound + 5,
|
||||
leftBound + 20,
|
||||
300,
|
||||
350,
|
||||
400, // Screen midpoint
|
||||
450,
|
||||
500,
|
||||
rightBound - 20,
|
||||
rightBound - 5,
|
||||
rightBound - 0.5,
|
||||
]
|
||||
|
||||
// Sweep across heights in normal gameplay viewport (above bottom control bar)
|
||||
const testYs = [50, 150, 250, 300, 350, 450, 490]
|
||||
|
||||
for (const x of testXs) {
|
||||
for (const y of testYs) {
|
||||
expect(
|
||||
hud.isPointInterceptedByHud(x, y),
|
||||
`Expected isPointInterceptedByHud(${x}, ${y}) to be false in central corridor`,
|
||||
).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('probes points inside left panel [-marginW, 400 - marginW]: returns true', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const marginW = docking.marginW
|
||||
const leftPanelRightEdge = 400 - marginW
|
||||
|
||||
const testXs = [
|
||||
-marginW,
|
||||
-marginW + 5,
|
||||
-marginW + 50,
|
||||
0,
|
||||
50,
|
||||
100,
|
||||
200,
|
||||
leftPanelRightEdge - 10,
|
||||
leftPanelRightEdge - 1,
|
||||
leftPanelRightEdge,
|
||||
]
|
||||
|
||||
for (const x of testXs) {
|
||||
expect(
|
||||
hud.isPointInterceptedByHud(x, 300),
|
||||
`Expected isPointInterceptedByHud(${x}, 300) to be true on left panel`,
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('probes points inside right panel [400 + marginW, 800 + marginW]: returns true', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const marginW = docking.marginW
|
||||
const rightPanelLeftEdge = 400 + marginW
|
||||
const rightPanelRightEdge = 800 + marginW
|
||||
|
||||
const testXs = [
|
||||
rightPanelLeftEdge,
|
||||
rightPanelLeftEdge + 1,
|
||||
rightPanelLeftEdge + 10,
|
||||
600,
|
||||
700,
|
||||
800,
|
||||
rightPanelRightEdge - 20,
|
||||
rightPanelRightEdge - 1,
|
||||
rightPanelRightEdge,
|
||||
]
|
||||
|
||||
for (const x of testXs) {
|
||||
expect(
|
||||
hud.isPointInterceptedByHud(x, 300),
|
||||
`Expected isPointInterceptedByHud(${x}, 300) to be true on right panel`,
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('probes points out of horizontal bounds (< -marginW or > 800 + marginW): returns false', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const marginW = docking.marginW
|
||||
|
||||
expect(hud.isPointInterceptedByHud(-marginW - 1, 300)).toBe(false)
|
||||
expect(hud.isPointInterceptedByHud(-marginW - 100, 300)).toBe(false)
|
||||
expect(hud.isPointInterceptedByHud(800 + marginW + 1, 300)).toBe(false)
|
||||
expect(hud.isPointInterceptedByHud(800 + marginW + 200, 300)).toBe(false)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('3. Quick Transfer Mechanics Under Widescreen Docking (1920x1080)', () => {
|
||||
let canvas: MockCanvas
|
||||
let hud: HudManager
|
||||
|
||||
beforeEach(() => {
|
||||
const win = new MockWindow()
|
||||
win.innerWidth = 1920
|
||||
win.innerHeight = 1080
|
||||
;(globalThis as any).window = win
|
||||
|
||||
canvas = new MockCanvas(1920, 1080)
|
||||
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
hud['bindEvents']()
|
||||
hud.leftPanel = 'stash'
|
||||
hud.rightPanel = 'inv'
|
||||
hud.inventory.visible = true
|
||||
hud.inventory.gridItems = []
|
||||
hud.worldPanels.stashItems = []
|
||||
})
|
||||
|
||||
it('transfers multiple varied-size items cleanly from Inventory to Stash via Shift+Click with 0 loss and 0 dupes', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
// Setup 5 varied-dimension items in Inventory grid
|
||||
const itemsToTest = [
|
||||
createDummyItem('item-1x1', 'rin', 1, 1, '乔丹之石'),
|
||||
createDummyItem('item-1x2', 'amu', 1, 2, '马拉的万花筒'),
|
||||
createDummyItem('item-2x2', 'cap', 2, 2, '军帽 (Shako)'),
|
||||
createDummyItem('item-2x3', 'crs', 2, 3, '水晶剑 (Crystal Sword)'),
|
||||
createDummyItem('item-2x4', 'gpa', 2, 4, '无限 (Infinity)'),
|
||||
]
|
||||
|
||||
hud.inventory.gridItems = [
|
||||
{ item: itemsToTest[0]!, col: 0, row: 0 },
|
||||
{ item: itemsToTest[1]!, col: 1, row: 0 },
|
||||
{ item: itemsToTest[2]!, col: 2, row: 0 },
|
||||
{ item: itemsToTest[3]!, col: 4, row: 0 },
|
||||
{ item: itemsToTest[4]!, col: 6, row: 0 },
|
||||
]
|
||||
|
||||
expect(hud.inventory.gridItems).toHaveLength(5)
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(0)
|
||||
|
||||
// Shift+Click each item in Inventory grid
|
||||
for (const targetItem of itemsToTest) {
|
||||
const placement = hud.inventory.gridItems.find(p => p.item.id === targetItem.id)
|
||||
expect(placement).toBeDefined()
|
||||
|
||||
const visualLogicalX = INV_GRID_ORIGIN.x + (placement!.col + 0.5) * INV_GRID_ORIGIN.cellPx + deltaRight
|
||||
const visualLogicalY = INV_GRID_ORIGIN.y + (placement!.row + 0.5) * INV_GRID_ORIGIN.cellPx
|
||||
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Assertions: 0 item duplication, 0 item loss
|
||||
expect(hud.inventory.gridItems).toHaveLength(0)
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(5)
|
||||
|
||||
const stashItemIds = hud.worldPanels.stashItems.map(p => p.item.id)
|
||||
expect(new Set(stashItemIds).size).toBe(5)
|
||||
for (const expected of itemsToTest) {
|
||||
expect(stashItemIds).toContain(expected.id)
|
||||
}
|
||||
|
||||
// Assert zero coordinate translation errors: all items properly placed in 6x8 stash grid
|
||||
for (const p of hud.worldPanels.stashItems) {
|
||||
expect(p.col).toBeGreaterThanOrEqual(0)
|
||||
expect(p.col + p.item.invWidth).toBeLessThanOrEqual(STASH_GRID_ORIGIN.cols)
|
||||
expect(p.row).toBeGreaterThanOrEqual(0)
|
||||
expect(p.row + p.item.invHeight).toBeLessThanOrEqual(STASH_GRID_ORIGIN.rows)
|
||||
}
|
||||
|
||||
// Assert no overlapping items in Stash
|
||||
for (let i = 0; i < hud.worldPanels.stashItems.length; i++) {
|
||||
for (let j = i + 1; j < hud.worldPanels.stashItems.length; j++) {
|
||||
const a = hud.worldPanels.stashItems[i]!
|
||||
const b = hud.worldPanels.stashItems[j]!
|
||||
const xOverlap = a.col < b.col + b.item.invWidth && a.col + a.item.invWidth > b.col
|
||||
const yOverlap = a.row < b.row + b.item.invHeight && a.row + a.item.invHeight > b.row
|
||||
expect(xOverlap && yOverlap).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('transfers multiple items cleanly from Stash to Inventory via Shift+Click with 0 loss and 0 dupes', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
|
||||
const itemsInStash = [
|
||||
createDummyItem('stash-1x1', 'rin', 1, 1, '布尔凯索婚戒'),
|
||||
createDummyItem('stash-1x2', 'tbk', 1, 2, '回城之书'),
|
||||
createDummyItem('stash-2x2', 'box', 2, 2, '赫拉迪克方块'),
|
||||
createDummyItem('stash-2x3', 'crs', 2, 3, '橡树之心'),
|
||||
]
|
||||
|
||||
hud.worldPanels.stashItems = [
|
||||
{ item: itemsInStash[0]!, col: 0, row: 0 },
|
||||
{ item: itemsInStash[1]!, col: 1, row: 0 },
|
||||
{ item: itemsInStash[2]!, col: 2, row: 0 },
|
||||
{ item: itemsInStash[3]!, col: 4, row: 0 },
|
||||
]
|
||||
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(4)
|
||||
expect(hud.inventory.gridItems).toHaveLength(0)
|
||||
|
||||
for (const targetItem of itemsInStash) {
|
||||
const placement = hud.worldPanels.stashItems.find(p => p.item.id === targetItem.id)
|
||||
expect(placement).toBeDefined()
|
||||
|
||||
const visualLogicalX = STASH_GRID_ORIGIN.x + (placement!.col + 0.5) * STASH_GRID_ORIGIN.cellPx + deltaLeft
|
||||
const visualLogicalY = STASH_GRID_ORIGIN.y + (placement!.row + 0.5) * STASH_GRID_ORIGIN.cellPx
|
||||
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Assertions: 0 item duplication, 0 item loss
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(0)
|
||||
expect(hud.inventory.gridItems).toHaveLength(4)
|
||||
|
||||
const invItemIds = hud.inventory.gridItems.map(p => p.item.id)
|
||||
expect(new Set(invItemIds).size).toBe(4)
|
||||
for (const expected of itemsInStash) {
|
||||
expect(invItemIds).toContain(expected.id)
|
||||
}
|
||||
|
||||
// Assert all items legally positioned in 10x4 inventory grid without overlap
|
||||
for (const p of hud.inventory.gridItems) {
|
||||
expect(p.col).toBeGreaterThanOrEqual(0)
|
||||
expect(p.col + p.item.invWidth).toBeLessThanOrEqual(INV_GRID_ORIGIN.cols)
|
||||
expect(p.row).toBeGreaterThanOrEqual(0)
|
||||
expect(p.row + p.item.invHeight).toBeLessThanOrEqual(INV_GRID_ORIGIN.rows)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles destination full gracefully without item loss or state corruption', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
// Completely pack stash 6x8 grid with 48 1x1 items
|
||||
for (let col = 0; col < STASH_GRID_ORIGIN.cols; col++) {
|
||||
for (let row = 0; row < STASH_GRID_ORIGIN.rows; row++) {
|
||||
hud.worldPanels.stashItems.push({
|
||||
item: createDummyItem(`fill-${col}-${row}`, 'rin', 1, 1, 'Filler'),
|
||||
col,
|
||||
row,
|
||||
})
|
||||
}
|
||||
}
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(48)
|
||||
|
||||
// Place 1 item in inventory
|
||||
const extraItem = createDummyItem('overflow-ring', 'rin', 1, 1, '溢出之戒')
|
||||
hud.inventory.gridItems = [{ item: extraItem, col: 0, row: 0 }]
|
||||
|
||||
const visualLogicalX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
|
||||
const visualLogicalY = INV_GRID_ORIGIN.y + 14.5
|
||||
const clientPos = logicalToClient(visualLogicalX, visualLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
}),
|
||||
)
|
||||
|
||||
// Transfer must fail gracefully: item stays in inventory, stash remains full
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(48)
|
||||
expect(hud.inventory.gridItems).toHaveLength(1)
|
||||
expect(hud.inventory.gridItems[0]!.item.id).toBe('overflow-ring')
|
||||
})
|
||||
|
||||
it('transfers items cleanly from Inventory to Stash and Stash to Inventory via Right-Click (button: 2)', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.marginW
|
||||
const deltaLeft = -docking.marginW
|
||||
|
||||
const rightClickItem = createDummyItem('rc-item', 'rin', 1, 1, '右键转移戒指')
|
||||
hud.inventory.gridItems = [{ item: rightClickItem, col: 0, row: 0 }]
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(0)
|
||||
|
||||
// Right click item in Inventory (button: 2)
|
||||
const visualX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
|
||||
const visualY = INV_GRID_ORIGIN.y + 14.5
|
||||
const clientPos = logicalToClient(visualX, visualY, canvas.getBoundingClientRect())
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
button: 2,
|
||||
shiftKey: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.inventory.gridItems).toHaveLength(0)
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(1)
|
||||
expect(hud.worldPanels.stashItems[0]!.item.id).toBe('rc-item')
|
||||
|
||||
// Right click item back in Stash (button: 2)
|
||||
const stashP = hud.worldPanels.stashItems[0]!
|
||||
const stashVisualX = STASH_GRID_ORIGIN.x + (stashP.col + 0.5) * STASH_GRID_ORIGIN.cellPx + deltaLeft
|
||||
const stashVisualY = STASH_GRID_ORIGIN.y + (stashP.row + 0.5) * STASH_GRID_ORIGIN.cellPx
|
||||
const stashClientPos = logicalToClient(stashVisualX, stashVisualY, canvas.getBoundingClientRect())
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: stashClientPos.x,
|
||||
clientY: stashClientPos.y,
|
||||
button: 2,
|
||||
shiftKey: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(0)
|
||||
expect(hud.inventory.gridItems).toHaveLength(1)
|
||||
expect(hud.inventory.gridItems[0]!.item.id).toBe('rc-item')
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Tooltip Anchoring Compensation Across Resolutions', () => {
|
||||
for (const res of WIDESCREEN_RESOLUTIONS) {
|
||||
describe(`Resolution ${res.name}`, () => {
|
||||
let canvas: MockCanvas
|
||||
let hud: HudManager
|
||||
|
||||
beforeEach(() => {
|
||||
const win = new MockWindow()
|
||||
win.innerWidth = res.width
|
||||
win.innerHeight = res.height
|
||||
;(globalThis as any).window = win
|
||||
|
||||
canvas = new MockCanvas(res.width, res.height)
|
||||
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
hud['bindEvents']()
|
||||
hud.leftPanel = 'stash'
|
||||
hud.rightPanel = 'inv'
|
||||
hud.inventory.visible = true
|
||||
})
|
||||
|
||||
it('asserts hovered item tooltip on Inventory item has visual anchor properly compensated by +deltaRight', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
const item = createDummyItem('inv-tooltip-test', 'cap', 2, 2, '军帽')
|
||||
hud.inventory.gridItems = [{ item, col: 3, row: 1 }]
|
||||
|
||||
// Canonical center anchor for (col: 3, row: 1, w: 2, h: 2):
|
||||
// x = INV_GRID_ORIGIN.x + (col + w/2) * cellPx = 418 + (3 + 1) * 29 = 418 + 116 = 534
|
||||
// y = INV_GRID_ORIGIN.y + row * cellPx = 316 + 29 = 345
|
||||
const canonicalAnchorX = INV_GRID_ORIGIN.x + (3 + 2 / 2) * INV_GRID_ORIGIN.cellPx
|
||||
const canonicalAnchorY = INV_GRID_ORIGIN.y + 1 * INV_GRID_ORIGIN.cellPx
|
||||
const expectedVisualAnchorX = canonicalAnchorX + deltaRight
|
||||
|
||||
// Mouse is over this item at visual position
|
||||
const visualMouseLogicalX = canonicalAnchorX + deltaRight
|
||||
const visualMouseLogicalY = canonicalAnchorY + 15
|
||||
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
;(globalThis as any).window.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousemove', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.inventory.hoveredItem).not.toBeNull()
|
||||
expect(hud.inventory.hoveredItem?.item.id).toBe('inv-tooltip-test')
|
||||
// Assert visual anchor x is compensated by deltaRight
|
||||
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
|
||||
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
|
||||
})
|
||||
|
||||
it('asserts hovered item tooltip on Stash item has visual anchor properly compensated by +deltaLeft', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
|
||||
const item = createDummyItem('stash-tooltip-test', 'amu', 1, 1, '马拉万花筒')
|
||||
hud.worldPanels.stashItems = [{ item, col: 2, row: 3 }]
|
||||
|
||||
// Canonical center anchor for (col: 2, row: 3, w: 1, h: 1):
|
||||
// x = STASH_GRID_ORIGIN.x + (col + w/2) * cellPx = 154 + (2 + 0.5) * 29 = 154 + 72.5 = 226.5
|
||||
// y = STASH_GRID_ORIGIN.y + row * cellPx = 142 + 3 * 29 = 142 + 87 = 229
|
||||
const canonicalAnchorX = STASH_GRID_ORIGIN.x + (2 + 0.5) * STASH_GRID_ORIGIN.cellPx
|
||||
const canonicalAnchorY = STASH_GRID_ORIGIN.y + 3 * STASH_GRID_ORIGIN.cellPx
|
||||
const expectedVisualAnchorX = canonicalAnchorX + deltaLeft
|
||||
|
||||
const visualMouseLogicalX = canonicalAnchorX + deltaLeft
|
||||
const visualMouseLogicalY = canonicalAnchorY + 14.5
|
||||
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
;(globalThis as any).window.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousemove', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.inventory.hoveredItem).not.toBeNull()
|
||||
expect(hud.inventory.hoveredItem?.item.id).toBe('stash-tooltip-test')
|
||||
// Assert visual anchor x is compensated by deltaLeft
|
||||
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
|
||||
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
|
||||
})
|
||||
|
||||
it('asserts hovered item tooltip on Equipment slot has visual anchor compensated by +deltaRight', () => {
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
const helmItem = createDummyItem('eq-helm-tooltip', 'cap', 2, 2, '暗金头盔', ['helm'])
|
||||
hud.inventory.equipped.helm = helmItem
|
||||
|
||||
const rect = EQUIP_SLOTS_LAYOUT.helm
|
||||
const canonicalAnchorX = rect.x + rect.w / 2
|
||||
const canonicalAnchorY = rect.y
|
||||
const expectedVisualAnchorX = canonicalAnchorX + deltaRight
|
||||
|
||||
const visualMouseLogicalX = canonicalAnchorX + deltaRight
|
||||
const visualMouseLogicalY = canonicalAnchorY + rect.h / 2
|
||||
const clientPos = logicalToClient(visualMouseLogicalX, visualMouseLogicalY, canvas.getBoundingClientRect())
|
||||
|
||||
;(globalThis as any).window.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousemove', {
|
||||
clientX: clientPos.x,
|
||||
clientY: clientPos.y,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.inventory.hoveredItem).not.toBeNull()
|
||||
expect(hud.inventory.hoveredItem?.item.id).toBe('eq-helm-tooltip')
|
||||
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(expectedVisualAnchorX, 2)
|
||||
expect(hud.inventory.hoveredItem?.y).toBeCloseTo(canonicalAnchorY, 2)
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
CLASS_EXCLUSIVE_SETS,
|
||||
STARTER_STASH_ITEMS,
|
||||
CLASS_STARTER_ATTRS,
|
||||
CLASS_STARTER_HARD_POINTS,
|
||||
CLASS_DEFAULT_SKILLS,
|
||||
createStarterProfileForClass,
|
||||
} from '../src/game/class-starter-profiles.ts'
|
||||
import { HudManager } from '../src/ui/hud-manager.ts'
|
||||
import { SkillTreePanel, CLASS_VERTICAL_TABS, CLASS_SKILL_TREES } from '../src/ui/skill-tree-panel.ts'
|
||||
import { resolveItemSpriteRect, type EquipSlotId } from '../src/ui/inventory.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
import type { CharacterClassCode } from '../src/game/classes.ts'
|
||||
|
||||
describe('Issue #379: 7-Class Starter Profiles, Exclusive Sets, Stash Treasures & Class Switching', () => {
|
||||
const CLASSES: readonly CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
|
||||
const itemRects = BAKED_UI_MANIFEST.itemRects
|
||||
|
||||
describe('1. Class Exclusive Signature Sets (Body ONLY)', () => {
|
||||
it('defines authentic signature sets for all 7 classes with exclusive body slots', () => {
|
||||
// Amazon: M'avina's Battle Hymn (helm, armor, weapon1, gloves, belt)
|
||||
const ama = CLASS_EXCLUSIVE_SETS.ama
|
||||
expect(ama.helm?.name).toContain("M'avina's True Sight")
|
||||
expect(ama.armor?.name).toContain("M'avina's Embrace")
|
||||
expect(ama.weapon1?.name).toContain("M'avina's Caster")
|
||||
expect(ama.gloves?.name).toContain("M'avina's Icy Clutch")
|
||||
expect(ama.belt?.name).toContain("M'avina's Tenet")
|
||||
expect(ama.weapon2).toBeUndefined()
|
||||
expect(ama.boots).toBeUndefined()
|
||||
expect(ama.amulet).toBeUndefined()
|
||||
expect(ama.ring1).toBeUndefined()
|
||||
expect(ama.ring2).toBeUndefined()
|
||||
|
||||
// Sorceress: Tal Rasha's Wrappings (helm, armor, weapon1, belt, amulet)
|
||||
const sor = CLASS_EXCLUSIVE_SETS.sor
|
||||
expect(sor.helm?.name).toContain("Tal Rasha's Horadric Crest")
|
||||
expect(sor.armor?.name).toContain("Tal Rasha's Guardianship")
|
||||
expect(sor.weapon1?.name).toContain("Tal Rasha's Lidless Eye")
|
||||
expect(sor.belt?.name).toContain("Tal Rasha's Fine-Spun Cloth")
|
||||
expect(sor.amulet?.name).toContain("Tal Rasha's Adjudication")
|
||||
expect(sor.weapon2).toBeUndefined()
|
||||
expect(sor.gloves).toBeUndefined()
|
||||
expect(sor.boots).toBeUndefined()
|
||||
expect(sor.ring1).toBeUndefined()
|
||||
expect(sor.ring2).toBeUndefined()
|
||||
|
||||
// Necromancer: Trang-Oul's Avatar (helm, armor, weapon2/shield, gloves, belt)
|
||||
const nec = CLASS_EXCLUSIVE_SETS.nec
|
||||
expect(nec.helm?.name).toContain("Trang-Oul's Guise")
|
||||
expect(nec.armor?.name).toContain("Trang-Oul's Scales")
|
||||
expect(nec.weapon2?.name).toContain("Trang-Oul's Wing")
|
||||
expect(nec.gloves?.name).toContain("Trang-Oul's Claws")
|
||||
expect(nec.belt?.name).toContain("Trang-Oul's Girth")
|
||||
expect(nec.weapon1).toBeUndefined()
|
||||
expect(nec.boots).toBeUndefined()
|
||||
expect(nec.amulet).toBeUndefined()
|
||||
expect(nec.ring1).toBeUndefined()
|
||||
expect(nec.ring2).toBeUndefined()
|
||||
|
||||
// Paladin: Griswold's Legacy (helm, armor, weapon1, weapon2/shield)
|
||||
const pal = CLASS_EXCLUSIVE_SETS.pal
|
||||
expect(pal.helm?.name).toContain("Griswold's Valor")
|
||||
expect(pal.armor?.name).toContain("Griswold's Heart")
|
||||
expect(pal.weapon1?.name).toContain("Griswold's Redemption")
|
||||
expect(pal.weapon2?.name).toContain("Griswold's Honor")
|
||||
expect(pal.gloves).toBeUndefined()
|
||||
expect(pal.belt).toBeUndefined()
|
||||
expect(pal.boots).toBeUndefined()
|
||||
expect(pal.amulet).toBeUndefined()
|
||||
expect(pal.ring1).toBeUndefined()
|
||||
expect(pal.ring2).toBeUndefined()
|
||||
|
||||
// Barbarian: Immortal King (helm, armor, weapon1, gloves, belt, boots)
|
||||
const bar = CLASS_EXCLUSIVE_SETS.bar
|
||||
expect(bar.helm?.name).toContain("Immortal King's Will")
|
||||
expect(bar.armor?.name).toContain("Immortal King's Soul Cage")
|
||||
expect(bar.weapon1?.name).toContain("Immortal King's Stone Crusher")
|
||||
expect(bar.gloves?.name).toContain("Immortal King's Forge")
|
||||
expect(bar.belt?.name).toContain("Immortal King's Detail")
|
||||
expect(bar.boots?.name).toContain("Immortal King's Pillar")
|
||||
expect(bar.weapon2).toBeUndefined()
|
||||
expect(bar.amulet).toBeUndefined()
|
||||
expect(bar.ring1).toBeUndefined()
|
||||
expect(bar.ring2).toBeUndefined()
|
||||
|
||||
// Druid: Aldur's Watchtower (helm, armor, weapon1, boots)
|
||||
const dru = CLASS_EXCLUSIVE_SETS.dru
|
||||
expect(dru.helm?.name).toContain("Aldur's Stony Gaze")
|
||||
expect(dru.armor?.name).toContain("Aldur's Deception")
|
||||
expect(dru.weapon1?.name).toContain("Aldur's Rhythm")
|
||||
expect(dru.boots?.name).toContain("Aldur's Advance")
|
||||
expect(dru.weapon2).toBeUndefined()
|
||||
expect(dru.gloves).toBeUndefined()
|
||||
expect(dru.belt).toBeUndefined()
|
||||
expect(dru.amulet).toBeUndefined()
|
||||
expect(dru.ring1).toBeUndefined()
|
||||
expect(dru.ring2).toBeUndefined()
|
||||
|
||||
// Assassin: Natalya's Odium (helm, armor, weapon1, boots)
|
||||
const ass = CLASS_EXCLUSIVE_SETS.ass
|
||||
expect(ass.helm?.name).toContain("Natalya's Totem")
|
||||
expect(ass.armor?.name).toContain("Natalya's Shadow")
|
||||
expect(ass.weapon1?.name).toContain("Natalya's Mark")
|
||||
expect(ass.boots?.name).toContain("Natalya's Soul")
|
||||
expect(ass.weapon2).toBeUndefined()
|
||||
expect(ass.gloves).toBeUndefined()
|
||||
expect(ass.belt).toBeUndefined()
|
||||
expect(ass.amulet).toBeUndefined()
|
||||
expect(ass.ring1).toBeUndefined()
|
||||
expect(ass.ring2).toBeUndefined()
|
||||
})
|
||||
|
||||
it('resolves all 33 set items across 7 classes to authentic DC6 sprite rectangles', () => {
|
||||
let totalItems = 0
|
||||
for (const cls of CLASSES) {
|
||||
const set = CLASS_EXCLUSIVE_SETS[cls]
|
||||
for (const [slot, item] of Object.entries(set)) {
|
||||
if (!item) continue
|
||||
totalItems++
|
||||
const rect = resolveItemSpriteRect(item, itemRects)
|
||||
expect(rect, `Set item ${cls} ${slot} (${item.name}) must resolve`).not.toBeNull()
|
||||
expect(rect!.w).toBeGreaterThan(0)
|
||||
expect(rect!.h).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
expect(totalItems).toBe(33) // 5 + 5 + 5 + 4 + 6 + 4 + 4 = 33
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Stash Treasures: Runes, Gems, and Jewels', () => {
|
||||
it('contains 30 authentic treasure items (12 Runes, 12 Gems/Skulls, 6 Jewels)', () => {
|
||||
expect(STARTER_STASH_ITEMS.length).toBe(30)
|
||||
|
||||
const runes = STARTER_STASH_ITEMS.filter((gp) => gp.item.quality === 'rune')
|
||||
expect(runes.length).toBe(12) // Jah, Ber, Lo, Ohm, Vex, Cham, Zod, Sur, Gul, Ist, Mal, Um
|
||||
|
||||
const gems = STARTER_STASH_ITEMS.filter((gp) => gp.item.quality === 'normal')
|
||||
expect(gems.length).toBe(12) // 6 Perfect + 6 Flawless
|
||||
|
||||
const jewels = STARTER_STASH_ITEMS.filter((gp) => gp.item.code === 'jew')
|
||||
expect(jewels.length).toBe(6) // 3 Facets, 2 Magic, 1 Rare
|
||||
})
|
||||
|
||||
it('all 30 stash items fit inside 6x8 stash grid without out-of-bounds or overlapping cells', () => {
|
||||
const occupied = new Set<string>()
|
||||
for (const gp of STARTER_STASH_ITEMS) {
|
||||
expect(gp.col).toBeGreaterThanOrEqual(0)
|
||||
expect(gp.row).toBeGreaterThanOrEqual(0)
|
||||
expect(gp.col + gp.item.invWidth).toBeLessThanOrEqual(6)
|
||||
expect(gp.row + gp.item.invHeight).toBeLessThanOrEqual(8)
|
||||
|
||||
for (let c = gp.col; c < gp.col + gp.item.invWidth; c++) {
|
||||
for (let r = gp.row; r < gp.row + gp.item.invHeight; r++) {
|
||||
const key = `${c},${r}`
|
||||
expect(occupied.has(key), `Cell (${key}) should not overlap with item ${gp.item.name}`).toBe(false)
|
||||
occupied.add(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves all 30 stash items to authentic DC6 sprite rectangles', () => {
|
||||
for (const gp of STARTER_STASH_ITEMS) {
|
||||
const rect = resolveItemSpriteRect(gp.item, itemRects)
|
||||
expect(rect, `Stash item (${gp.item.name}) must resolve`).not.toBeNull()
|
||||
expect(rect!.w).toBeGreaterThan(0)
|
||||
expect(rect!.h).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Multi-Class Skill Trees & Tabs', () => {
|
||||
it('provides exactly 30 authentic skills for each of the 7 classes', () => {
|
||||
for (const cls of CLASSES) {
|
||||
const tree = CLASS_SKILL_TREES[cls]
|
||||
expect(tree, `Skill tree for ${cls} must exist`).toBeDefined()
|
||||
expect(tree.length, `${cls} must have 30 skills`).toBe(30)
|
||||
|
||||
for (let tab = 0; tab < 3; tab++) {
|
||||
const tabSkills = tree.filter((s) => s.tab === tab)
|
||||
expect(tabSkills.length, `${cls} tab ${tab} must have 10 skills`).toBe(10)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('defines authentic 3 vertical tabs with Chinese names for all 7 classes', () => {
|
||||
for (const cls of CLASSES) {
|
||||
const tabs = CLASS_VERTICAL_TABS[cls]
|
||||
expect(tabs.length).toBe(3)
|
||||
expect(tabs[0].y).toBe(95)
|
||||
expect(tabs[1].y).toBe(195)
|
||||
expect(tabs[2].y).toBe(295)
|
||||
expect(tabs[0].titleZh).toBeTruthy()
|
||||
expect(tabs[0].subZh).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
it('SkillTreePanel dynamically switches class, tabs, and allocations', () => {
|
||||
const panel = new SkillTreePanel()
|
||||
expect(panel.characterClass).toBe('sor')
|
||||
|
||||
// Switch to Paladin
|
||||
panel.setClass('pal')
|
||||
expect(panel.characterClass).toBe('pal')
|
||||
expect(panel.getSkillTree().length).toBe(30)
|
||||
expect(panel.getVerticalTabs()[0].titleZh).toBe('作战')
|
||||
|
||||
// Load specific profile state
|
||||
const profile = createStarterProfileForClass('bar')
|
||||
panel.loadProfile(profile.classCode, profile.hardPoints, profile.unspentSkillPoints, profile.activeSkillTab)
|
||||
expect(panel.characterClass).toBe('bar')
|
||||
expect(panel.getHardPoints(151)).toBe(20) // Whirlwind 20
|
||||
expect(panel.unspentSkillPoints).toBe(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. HudManager Multi-Class State Switching & Persistence', () => {
|
||||
const mockCanvas = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
style: { cursor: '' },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getContext: () => null,
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
it('initializes with Sorceress exclusive set and stash treasures', () => {
|
||||
const hud = new HudManager(mockCanvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
hud.switchClass('sor')
|
||||
expect(hud.currentClass).toBe('sor')
|
||||
// Body has Tal Rasha set only
|
||||
expect(hud.inventory.equipped.helm?.name).toContain("Tal Rasha's Horadric Crest")
|
||||
expect(hud.inventory.equipped.armor?.name).toContain("Tal Rasha's Guardianship")
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Tal Rasha's Lidless Eye")
|
||||
expect(hud.inventory.equipped.weapon2).toBeUndefined()
|
||||
expect(hud.inventory.equipped.gloves).toBeUndefined()
|
||||
expect(hud.inventory.equipped.boots).toBeUndefined()
|
||||
|
||||
// Stash has 30 treasure items and 2.5m gold
|
||||
expect(hud.worldPanels.stashItems.length).toBe(30)
|
||||
expect(hud.worldPanels.stashGold).toBe(2_500_000)
|
||||
})
|
||||
|
||||
it('seamlessly switches between classes, updating inventory, stash, skills, and attributes', () => {
|
||||
const hud = new HudManager(mockCanvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
// Switch to Paladin
|
||||
hud.switchClass('pal')
|
||||
expect(hud.currentClass).toBe('pal')
|
||||
expect(hud.charSheet.attrs.classNameZh).toBe('圣骑士')
|
||||
expect(hud.inventory.equipped.helm?.name).toContain("Griswold's Valor")
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Griswold's Redemption")
|
||||
expect(hud.inventory.equipped.weapon2?.name).toContain("Griswold's Honor")
|
||||
expect(hud.inventory.equipped.gloves).toBeUndefined()
|
||||
expect(hud.hotkeys.leftSkillId).toBe(96) // Sacrifice
|
||||
expect(hud.hotkeys.rightSkillId).toBe(112) // Blessed Hammer
|
||||
expect(hud.worldPanels.stashItems.length).toBe(30)
|
||||
|
||||
// Switch to Barbarian
|
||||
hud.switchClass('bar')
|
||||
expect(hud.currentClass).toBe('bar')
|
||||
expect(hud.charSheet.attrs.classNameZh).toBe('野蛮人')
|
||||
expect(hud.inventory.equipped.helm?.name).toContain("Immortal King's Will")
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Immortal King's Stone Crusher")
|
||||
expect(hud.inventory.equipped.boots?.name).toContain("Immortal King's Pillar")
|
||||
expect(hud.skillTree.getHardPoints(151)).toBe(20) // Whirlwind
|
||||
|
||||
// Switch to Amazon
|
||||
hud.switchClass('ama')
|
||||
expect(hud.currentClass).toBe('ama')
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("M'avina's Caster")
|
||||
|
||||
// Switch to Necromancer
|
||||
hud.switchClass('nec')
|
||||
expect(hud.currentClass).toBe('nec')
|
||||
expect(hud.inventory.equipped.weapon2?.name).toContain("Trang-Oul's Wing")
|
||||
|
||||
// Switch to Druid
|
||||
hud.switchClass('dru')
|
||||
expect(hud.currentClass).toBe('dru')
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Aldur's Rhythm")
|
||||
|
||||
// Switch to Assassin
|
||||
hud.switchClass('ass')
|
||||
expect(hud.currentClass).toBe('ass')
|
||||
expect(hud.inventory.equipped.weapon1?.name).toContain("Natalya's Mark")
|
||||
})
|
||||
|
||||
it('preserves individual class modifications across switches', () => {
|
||||
const hud = new HudManager(mockCanvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
// Paladin: deposit gold and spend a skill point
|
||||
hud.switchClass('pal')
|
||||
hud.worldPanels.stashGold = 1_800_000
|
||||
hud.skillTree.unspentSkillPoints = 5
|
||||
|
||||
// Switch to Sorceress: has her own gold & skill points
|
||||
hud.switchClass('sor')
|
||||
expect(hud.worldPanels.stashGold).toBe(2_500_000)
|
||||
expect(hud.skillTree.unspentSkillPoints).toBe(12)
|
||||
|
||||
// Modify Sorceress
|
||||
hud.worldPanels.stashGold = 2_000_000
|
||||
hud.skillTree.unspentSkillPoints = 10
|
||||
|
||||
// Switch back to Paladin: modifications were preserved!
|
||||
hud.switchClass('pal')
|
||||
expect(hud.worldPanels.stashGold).toBe(1_800_000)
|
||||
expect(hud.skillTree.unspentSkillPoints).toBe(5)
|
||||
|
||||
// Switch back to Sorceress: modifications were preserved!
|
||||
hud.switchClass('sor')
|
||||
expect(hud.worldPanels.stashGold).toBe(2_000_000)
|
||||
expect(hud.skillTree.unspentSkillPoints).toBe(10)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,370 @@
|
|||
/**
|
||||
* Diablo II v1.13c Quickbar & Speedbar Parity Tests (Issue #382).
|
||||
*
|
||||
* Requirements:
|
||||
* 1. 技能在cd中的时候显示红色不可用遮罩 (Red unavailable overlay on cooldown)
|
||||
* 2. 光环技能图标黄色 (Aura skill icon yellow border & tint)
|
||||
* 3. 充能类技能显示数字 (Charged skills display remaining charges number)
|
||||
* 4. 被动技能不进入快捷栏 (Passive skills do not enter quickbar speedbar)
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
import {
|
||||
SkillHotkeysHud,
|
||||
LEFT_SKILL_BOUNDS,
|
||||
RIGHT_SKILL_BOUNDS,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
PASSIVE_SKILL_IDS,
|
||||
PALADIN_AURA_SKILL_IDS,
|
||||
isPassiveSkill,
|
||||
isAuraSkill,
|
||||
getAvailableSkillsForSide,
|
||||
type HotkeySkillEntry,
|
||||
} from '../src/ui/hotkeys.ts'
|
||||
import { D2FontRenderer } from '../src/ui/font.ts'
|
||||
|
||||
describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
let font: D2FontRenderer
|
||||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
font = new D2FontRenderer()
|
||||
})
|
||||
|
||||
describe('1. Passive Skills Filtering (被动技能不进入快捷栏)', () => {
|
||||
it('identifies all 23 canonical Diablo II 1.13c passive skills', () => {
|
||||
// Amazon passives
|
||||
expect(isPassiveSkill(9)).toBe(true) // Critical Strike
|
||||
expect(isPassiveSkill(14)).toBe(true) // Penetrate
|
||||
expect(isPassiveSkill(17)).toBe(true) // Dodge
|
||||
expect(isPassiveSkill(22)).toBe(true) // Avoid
|
||||
expect(isPassiveSkill(27)).toBe(true) // Evade
|
||||
expect(isPassiveSkill(29)).toBe(true) // Pierce
|
||||
|
||||
// Sorceress passives
|
||||
expect(isPassiveSkill(37)).toBe(true) // Warmth
|
||||
expect(isPassiveSkill(61)).toBe(true) // Fire Mastery
|
||||
expect(isPassiveSkill(63)).toBe(true) // Lightning Mastery
|
||||
expect(isPassiveSkill(65)).toBe(true) // Cold Mastery
|
||||
|
||||
// Necromancer passives
|
||||
expect(isPassiveSkill(69)).toBe(true) // Skeleton Mastery
|
||||
expect(isPassiveSkill(79)).toBe(true) // Golem Mastery
|
||||
expect(isPassiveSkill(89)).toBe(true) // Summon Resist
|
||||
|
||||
// Barbarian passives
|
||||
expect(isPassiveSkill(127)).toBe(true) // Sword Mastery
|
||||
expect(isPassiveSkill(141)).toBe(true) // Increased Stamina
|
||||
expect(isPassiveSkill(145)).toBe(true) // Iron Skin
|
||||
expect(isPassiveSkill(148)).toBe(true) // Increased Speed
|
||||
expect(isPassiveSkill(153)).toBe(true) // Natural Resistance
|
||||
|
||||
// Druid & Assassin passives
|
||||
expect(isPassiveSkill(224)).toBe(true) // Lycanthropy
|
||||
expect(isPassiveSkill(252)).toBe(true) // Claw Mastery
|
||||
expect(isPassiveSkill(263)).toBe(true) // Weapon Block
|
||||
|
||||
// Active skills should return false
|
||||
expect(isPassiveSkill(0)).toBe(false) // Attack
|
||||
expect(isPassiveSkill(36)).toBe(false) // Fire Bolt
|
||||
expect(isPassiveSkill(47)).toBe(false) // Fire Ball
|
||||
expect(isPassiveSkill(54)).toBe(false) // Teleport
|
||||
expect(isPassiveSkill(64)).toBe(false) // Frozen Orb
|
||||
expect(isPassiveSkill(98)).toBe(false) // Might (Aura)
|
||||
})
|
||||
|
||||
it('filters out passive skills from getAvailableSkillsForSide', () => {
|
||||
const mixedSkills: HotkeySkillEntry[] = [
|
||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 37, name: 'Warmth', nameZh: '暖气', level: 10, manaCost: 0, leftUsable: false, rightUsable: true, isPassive: true },
|
||||
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 9, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 61, name: 'Fire Mastery', nameZh: '火焰强化', level: 5, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
]
|
||||
|
||||
const leftList = getAvailableSkillsForSide('left', mixedSkills)
|
||||
const rightList = getAvailableSkillsForSide('right', mixedSkills)
|
||||
|
||||
expect(leftList.map(s => s.skillId)).toEqual([0, 47])
|
||||
expect(rightList.map(s => s.skillId)).toEqual([0, 47])
|
||||
})
|
||||
|
||||
it('strips passive skills when setAvailableSkills is called', () => {
|
||||
const skillsWithPassives: HotkeySkillEntry[] = [
|
||||
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 37, name: 'Warmth', nameZh: '暖气', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
|
||||
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
|
||||
{ skillId: 127, name: 'Sword Mastery', nameZh: '剑术支配', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
|
||||
]
|
||||
|
||||
hud.setAvailableSkills(skillsWithPassives)
|
||||
expect(hud.availableSkills.some(s => isPassiveSkill(s.skillId))).toBe(false)
|
||||
expect(hud.availableSkills.map(s => s.skillId)).toEqual([0, 47])
|
||||
|
||||
// Speedbar popup should contain 0 passives
|
||||
const speedbarLeft = hud.getSpeedbarSkills('left')
|
||||
const speedbarRight = hud.getSpeedbarSkills('right')
|
||||
expect(speedbarLeft.some(c => isPassiveSkill(c.skill.skillId))).toBe(false)
|
||||
expect(speedbarRight.some(c => isPassiveSkill(c.skill.skillId))).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects assigning passive skills to left or right action slots', () => {
|
||||
hud.leftSkillId = 47
|
||||
hud.rightSkillId = 64
|
||||
|
||||
// Attempt assigning Warmth (37)
|
||||
const leftAssigned = hud.assignSkill('left', 37)
|
||||
expect(leftAssigned).toBe(false)
|
||||
expect(hud.leftSkillId).toBe(47)
|
||||
|
||||
const rightAssigned = hud.assignSkill('right', 61)
|
||||
expect(rightAssigned).toBe(false)
|
||||
expect(hud.rightSkillId).toBe(64)
|
||||
|
||||
// Attempt setDualSkill
|
||||
const dualAssigned = hud.setDualSkill(127)
|
||||
expect(dualAssigned).toBe(false)
|
||||
expect(hud.leftSkillId).toBe(47)
|
||||
expect(hud.rightSkillId).toBe(64)
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Aura Skills Mechanics & Yellow Styling (光环技能图标黄色)', () => {
|
||||
it('identifies all 20 Paladin Defensive and Offensive Auras', () => {
|
||||
expect(PALADIN_AURA_SKILL_IDS.size).toBe(20)
|
||||
|
||||
// Defensive Auras
|
||||
expect(isAuraSkill(99)).toBe(true) // Prayer
|
||||
expect(isAuraSkill(100)).toBe(true) // Resist Fire
|
||||
expect(isAuraSkill(104)).toBe(true) // Defiance
|
||||
expect(isAuraSkill(105)).toBe(true) // Resist Cold
|
||||
expect(isAuraSkill(109)).toBe(true) // Cleansing
|
||||
expect(isAuraSkill(110)).toBe(true) // Resist Lightning
|
||||
expect(isAuraSkill(115)).toBe(true) // Vigor
|
||||
expect(isAuraSkill(120)).toBe(true) // Meditation
|
||||
expect(isAuraSkill(124)).toBe(true) // Redemption
|
||||
expect(isAuraSkill(125)).toBe(true) // Salvation
|
||||
|
||||
// Offensive Auras
|
||||
expect(isAuraSkill(98)).toBe(true) // Might
|
||||
expect(isAuraSkill(102)).toBe(true) // Holy Fire
|
||||
expect(isAuraSkill(103)).toBe(true) // Thorns
|
||||
expect(isAuraSkill(108)).toBe(true) // Blessed Aim
|
||||
expect(isAuraSkill(113)).toBe(true) // Concentration
|
||||
expect(isAuraSkill(114)).toBe(true) // Holy Freeze
|
||||
expect(isAuraSkill(118)).toBe(true) // Holy Shock
|
||||
expect(isAuraSkill(119)).toBe(true) // Sanctuary
|
||||
expect(isAuraSkill(122)).toBe(true) // Fanaticism
|
||||
expect(isAuraSkill(123)).toBe(true) // Conviction
|
||||
|
||||
// Non-auras
|
||||
expect(isAuraSkill(96)).toBe(false) // Sacrifice (Combat)
|
||||
expect(isAuraSkill(112)).toBe(false) // Blessed Hammer (Combat)
|
||||
expect(isAuraSkill(47)).toBe(false) // Fire Ball
|
||||
})
|
||||
|
||||
it('renders yellow border and yellow tint for aura skills', () => {
|
||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||
const strokeRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(function (this: any, x, y, w, h) {
|
||||
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
|
||||
}),
|
||||
strokeRect: vi.fn(function (this: any, x, y, w, h) {
|
||||
strokeRectCalls.push({ x, y, w, h, style: String(mockCtx.strokeStyle) })
|
||||
}),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 40 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.rightSkillId = 98 // Might (Aura)
|
||||
hud.draw(mockCtx, null, font)
|
||||
|
||||
// Check yellow aura tint over right skill icon (x=635)
|
||||
const yellowTint = fillRectCalls.find(
|
||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 2 && c.style.includes('rgba(255, 215, 0')
|
||||
)
|
||||
expect(yellowTint).toBeDefined()
|
||||
|
||||
// Check yellow border (#ffd700) around right skill button
|
||||
const yellowBorder = strokeRectCalls.find(
|
||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 0.5 && c.style === '#ffd700'
|
||||
)
|
||||
expect(yellowBorder).toBeDefined()
|
||||
})
|
||||
|
||||
it('includes aura narrative tag in tooltip', () => {
|
||||
hud.setAvailableSkills([
|
||||
{ skillId: 98, name: 'Might', nameZh: '力量灵气', level: 5, manaCost: 0, leftUsable: false, rightUsable: true, isAura: true },
|
||||
])
|
||||
const lines = hud.formatTooltipLines(98, 'right', false)
|
||||
const auraTag = lines.find(l => l.text.includes('灵气技能') && l.color === 'yellow')
|
||||
expect(auraTag).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Skill Cooldown (CD) Red Unavailable Mask (技能在cd中的时候显示红色不可用遮罩)', () => {
|
||||
it('manages skill cooldown durations accurately', () => {
|
||||
const now = 10000
|
||||
expect(hud.isSkillOnCooldown(56, now)).toBe(false) // Meteor
|
||||
|
||||
hud.setSkillCooldown(56, 1200, now) // 1.2s cooldown
|
||||
expect(hud.isSkillOnCooldown(56, now + 500)).toBe(true)
|
||||
expect(hud.getSkillCooldownRemaining(56, now + 500)).toBe(700)
|
||||
|
||||
// After cooldown expires
|
||||
expect(hud.isSkillOnCooldown(56, now + 1200)).toBe(false)
|
||||
expect(hud.getSkillCooldownRemaining(56, now + 1200)).toBe(0)
|
||||
})
|
||||
|
||||
it('renders red unavailable overlay (rgba(220, 20, 20, 0.45)) when skill is on cooldown', () => {
|
||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(function (this: any, x, y, w, h) {
|
||||
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
|
||||
}),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 40 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.leftSkillId = 47 // Fire Ball
|
||||
hud.rightSkillId = 56 // Meteor
|
||||
const now = 20000
|
||||
|
||||
// Put Meteor on cooldown
|
||||
hud.setSkillCooldown(56, 1200, now)
|
||||
|
||||
// Draw at now + 300ms (still on CD)
|
||||
hud.draw(mockCtx, null, font, true, now + 300)
|
||||
|
||||
// Right slot (Meteor) must have red mask
|
||||
const meteorRedMask = fillRectCalls.find(
|
||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
||||
)
|
||||
expect(meteorRedMask).toBeDefined()
|
||||
|
||||
// Left slot (Fire Ball, not on CD) must NOT have red mask
|
||||
const fireBallRedMask = fillRectCalls.find(
|
||||
c => c.x === LEFT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
||||
)
|
||||
expect(fireBallRedMask).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clears cooldown and removes red mask', () => {
|
||||
hud.setSkillCooldown(56, 2000, 1000)
|
||||
expect(hud.isSkillOnCooldown(56, 1500)).toBe(true)
|
||||
|
||||
hud.clearSkillCooldown(56)
|
||||
expect(hud.isSkillOnCooldown(56, 1500)).toBe(false)
|
||||
})
|
||||
|
||||
it('formats remaining cooldown in tooltip', () => {
|
||||
hud.setSkillCooldown(56, 1500, 5000)
|
||||
const lines = hud.formatTooltipLines(56, 'right', false)
|
||||
const cdLine = lines.find(l => l.text.includes('冷却中') && l.color === 'red')
|
||||
expect(cdLine).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Charged Skills Number Display (充能类技能显示数字)', () => {
|
||||
it('sets and retrieves skill charges and max charges', () => {
|
||||
hud.setSkillCharges(54, 20, 25) // Teleport charges 20/25
|
||||
expect(hud.getSkillCharges(54)).toBe(20)
|
||||
expect(hud.getSkillMaxCharges(54)).toBe(25)
|
||||
|
||||
hud.setSkillCharges(54, 0, 25) // depleted
|
||||
expect(hud.getSkillCharges(54)).toBe(0)
|
||||
})
|
||||
|
||||
it('draws remaining charges count on the skill icon', () => {
|
||||
const drawTextCalls: { text: string; x: number; y: number; color?: string | undefined }[] = []
|
||||
vi.spyOn(font, 'drawText').mockImplementation((ctx, text, x, y, opts) => {
|
||||
drawTextCalls.push({ text: String(text), x, y, color: opts?.color })
|
||||
return 20
|
||||
})
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 40 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.rightSkillId = 54 // Teleport
|
||||
hud.setSkillCharges(54, 18, 20)
|
||||
|
||||
hud.draw(mockCtx, null, font)
|
||||
|
||||
// Charge number '18' drawn at bottom-left corner of right slot (x = 635 + 4 = 639)
|
||||
const chargeTextCall = drawTextCalls.find(
|
||||
c => c.text === '18' && c.x === RIGHT_SKILL_BOUNDS.x + 4 && c.y === RIGHT_SKILL_BOUNDS.y + 44
|
||||
)
|
||||
expect(chargeTextCall).toBeDefined()
|
||||
expect(chargeTextCall!.color).toBe('white')
|
||||
})
|
||||
|
||||
it('renders red unavailable mask when charges reach 0', () => {
|
||||
const fillRectCalls: { x: number; y: number; w: number; h: number; style: string }[] = []
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(function (this: any, x, y, w, h) {
|
||||
fillRectCalls.push({ x, y, w, h, style: String(mockCtx.fillStyle) })
|
||||
}),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 40 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.rightSkillId = 54
|
||||
hud.setSkillCharges(54, 0, 20) // 0 charges remaining
|
||||
|
||||
hud.draw(mockCtx, null, font)
|
||||
|
||||
const redMask = fillRectCalls.find(
|
||||
c => c.x === RIGHT_SKILL_BOUNDS.x + 1 && c.style.includes('rgba(220, 20, 20')
|
||||
)
|
||||
expect(redMask).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats charge status in tooltip', () => {
|
||||
hud.setSkillCharges(54, 15, 20)
|
||||
const lines = hud.formatTooltipLines(54, 'right', false)
|
||||
const chargeLine = lines.find(l => l.text.includes('剩余充能: 15 / 20'))
|
||||
expect(chargeLine).toBeDefined()
|
||||
expect(chargeLine!.color).toBe('blue')
|
||||
|
||||
hud.setSkillCharges(54, 0, 20)
|
||||
const depletedLines = hud.formatTooltipLines(54, 'right', false)
|
||||
const depletedLine = depletedLines.find(l => l.text.includes('剩余充能: 0 / 20'))
|
||||
expect(depletedLine).toBeDefined()
|
||||
expect(depletedLine!.color).toBe('red')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* Tests for SkillHotkeysHud, speedbars, keybindings, and Attack slot binding (Issue #151).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import {
|
||||
SkillHotkeysHud,
|
||||
LEFT_SKILL_BOUNDS,
|
||||
RIGHT_SKILL_BOUNDS,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
resolveSkillIcon,
|
||||
getAvailableSkillsForSide,
|
||||
} from '../src/ui/hotkeys.ts'
|
||||
|
||||
describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
})
|
||||
|
||||
it('initializes with expected default skills and state', () => {
|
||||
expect(hud.leftSkillId).toBe(47) // Fire Ball
|
||||
expect(hud.rightSkillId).toBe(64) // Frozen Orb
|
||||
expect(hud.selectionOpen).toBeNull()
|
||||
expect(hud.availableSkills.length).toBeGreaterThanOrEqual(10)
|
||||
|
||||
// Skill 0 is available
|
||||
const attack = hud.availableSkills.find((s) => s.skillId === 0)
|
||||
expect(attack).toBeDefined()
|
||||
expect(attack!.leftUsable).toBe(true)
|
||||
expect(attack!.rightUsable).toBe(true)
|
||||
})
|
||||
|
||||
it('opens and closes left selection speedbar on click', () => {
|
||||
// Click center of left skill icon
|
||||
const leftX = LEFT_SKILL_BOUNDS.x + 24
|
||||
const leftY = LEFT_SKILL_BOUNDS.y + 24
|
||||
|
||||
const handled = hud.handleClick(leftX, leftY)
|
||||
expect(handled).toBe(true)
|
||||
expect(hud.selectionOpen).toBe('left')
|
||||
|
||||
// Click again to close
|
||||
hud.handleClick(leftX, leftY)
|
||||
expect(hud.selectionOpen).toBeNull()
|
||||
})
|
||||
|
||||
it('opens and closes right selection speedbar on click', () => {
|
||||
// Click center of right skill icon
|
||||
const rightX = RIGHT_SKILL_BOUNDS.x + 24
|
||||
const rightY = RIGHT_SKILL_BOUNDS.y + 24
|
||||
|
||||
const handled = hud.handleClick(rightX, rightY)
|
||||
expect(handled).toBe(true)
|
||||
expect(hud.selectionOpen).toBe('right')
|
||||
|
||||
// Click outside to close
|
||||
hud.handleClick(100, 100)
|
||||
expect(hud.selectionOpen).toBeNull()
|
||||
})
|
||||
|
||||
it('allows selecting Normal Attack from left speedbar palette', () => {
|
||||
hud.openSpeedbar('left')
|
||||
expect(hud.selectionOpen).toBe('left')
|
||||
|
||||
// Find Attack in speedbar layout
|
||||
const speedbarSkills = hud.getSpeedbarSkills('left')
|
||||
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
|
||||
expect(attackEntry).toBeDefined()
|
||||
|
||||
// Click on Attack icon in speedbar
|
||||
const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10)
|
||||
expect(clicked).toBe(true)
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.selectionOpen).toBeNull() // closed after selection
|
||||
})
|
||||
|
||||
it('allows selecting Normal Attack from right speedbar palette', () => {
|
||||
hud.openSpeedbar('right')
|
||||
expect(hud.selectionOpen).toBe('right')
|
||||
|
||||
const speedbarSkills = hud.getSpeedbarSkills('right')
|
||||
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
|
||||
expect(attackEntry).toBeDefined()
|
||||
|
||||
const clicked = hud.handleClick(attackEntry!.x + 10, attackEntry!.y + 10)
|
||||
expect(clicked).toBe(true)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
expect(hud.selectionOpen).toBeNull()
|
||||
})
|
||||
|
||||
it('supports dual slot binding with Attack on both slots', () => {
|
||||
hud.assignSkill('left', 0)
|
||||
hud.assignSkill('right', 0)
|
||||
|
||||
expect(hud.leftSkillId).toBe(0)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
|
||||
// Verify resolveSkillIcon works for both slots
|
||||
const leftIcon = resolveSkillIcon(hud.leftSkillId)
|
||||
const rightIcon = resolveSkillIcon(hud.rightSkillId)
|
||||
|
||||
expect(leftIcon.skillId).toBe(0)
|
||||
expect(rightIcon.skillId).toBe(0)
|
||||
expect(leftIcon.name).toBe('Attack')
|
||||
expect(rightIcon.name).toBe('Attack')
|
||||
expect(leftIcon.iconPath).toBe('/skills/icon_0.png')
|
||||
expect(rightIcon.iconPath).toBe('/skills/icon_0.png')
|
||||
})
|
||||
|
||||
it('manages F1-F8 hotkey bindings and key presses', () => {
|
||||
// Bind F1 to Attack (0)
|
||||
hud.setHotkey('F1', 0)
|
||||
expect(hud.getHotkey('F1')).toBe(0)
|
||||
|
||||
// Bind F2 to Frozen Orb (64)
|
||||
hud.setHotkey('F2', 64)
|
||||
expect(hud.getHotkey('F2')).toBe(64)
|
||||
|
||||
// Pressing F1 assigns right skill to 0
|
||||
const handledF1 = hud.handleKeyDown('F1')
|
||||
expect(handledF1).toBe(true)
|
||||
expect(hud.rightSkillId).toBe(0)
|
||||
|
||||
// Pressing F2 assigns right skill to 64
|
||||
const handledF2 = hud.handleKeyDown('F2')
|
||||
expect(handledF2).toBe(true)
|
||||
expect(hud.rightSkillId).toBe(64)
|
||||
})
|
||||
|
||||
it('handles mouse hover over skill buttons and speedbar icons', () => {
|
||||
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
|
||||
expect(hud.hoverSkillId).toBe(hud.leftSkillId)
|
||||
|
||||
hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10)
|
||||
expect(hud.hoverSkillId).toBe(hud.rightSkillId)
|
||||
|
||||
hud.openSpeedbar('left')
|
||||
const speedbarSkills = hud.getSpeedbarSkills('left')
|
||||
const attackEntry = speedbarSkills.find((e) => e.skill.skillId === 0)
|
||||
expect(attackEntry).toBeDefined()
|
||||
|
||||
hud.handleMouseMove(attackEntry!.x + 10, attackEntry!.y + 10)
|
||||
expect(hud.hoverSpeedbarSkillId).toBe(0)
|
||||
})
|
||||
|
||||
it('includes Throw and Unsummon as default universal skills with correct usability (Issue #376)', () => {
|
||||
const throwSkill = hud.availableSkills.find((s) => s.skillId === 2)
|
||||
const unsummonSkill = hud.availableSkills.find((s) => s.skillId === 3)
|
||||
|
||||
expect(throwSkill).toBeDefined()
|
||||
expect(throwSkill!.name).toBe('Throw')
|
||||
expect(throwSkill!.nameZh).toBe('投掷')
|
||||
expect(throwSkill!.leftUsable).toBe(true)
|
||||
expect(throwSkill!.rightUsable).toBe(true)
|
||||
|
||||
expect(unsummonSkill).toBeDefined()
|
||||
expect(unsummonSkill!.name).toBe('Unsummon')
|
||||
expect(unsummonSkill!.nameZh).toBe('取消召唤')
|
||||
expect(unsummonSkill!.leftUsable).toBe(false)
|
||||
expect(unsummonSkill!.rightUsable).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves authentic D2 icons: sword with weapon, fist unarmed, throw and unsummon (Issue #376)', () => {
|
||||
// Attack with weapon: sword (frame 2)
|
||||
const swordIcon = resolveSkillIcon(0, true)
|
||||
expect(swordIcon.iconPath).toBe('/skills/icon_0.png')
|
||||
expect(swordIcon.frameIndex).toBe(2)
|
||||
expect(swordIcon.atlasRect.x).toBe(96)
|
||||
|
||||
// Attack without weapon: fist (frame 0)
|
||||
const fistIcon = resolveSkillIcon(0, false)
|
||||
expect(fistIcon.iconPath).toBe('/skills/icon_0_fist.png')
|
||||
expect(fistIcon.frameIndex).toBe(0)
|
||||
expect(fistIcon.atlasRect.x).toBe(0)
|
||||
|
||||
// Throw (skill 2): frame 6
|
||||
const throwIcon = resolveSkillIcon(2)
|
||||
expect(throwIcon.iconPath).toBe('/skills/icon_2.png')
|
||||
expect(throwIcon.frameIndex).toBe(6)
|
||||
expect(throwIcon.atlasRect.x).toBe(288)
|
||||
|
||||
// Unsummon (skill 3): frame 4
|
||||
const unsummonIcon = resolveSkillIcon(3)
|
||||
expect(unsummonIcon.iconPath).toBe('/skills/icon_3.png')
|
||||
expect(unsummonIcon.frameIndex).toBe(4)
|
||||
expect(unsummonIcon.atlasRect.x).toBe(192)
|
||||
})
|
||||
|
||||
it('renders sword when weapon equipped and fist when unarmed (Issue #376)', () => {
|
||||
const drawImageCalls: any[] = []
|
||||
const ctx = {
|
||||
fillRect: () => {},
|
||||
strokeRect: () => {},
|
||||
drawImage: (...args: any[]) => {
|
||||
drawImageCalls.push(args)
|
||||
},
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockAtlas = {
|
||||
complete: true,
|
||||
naturalWidth: 1152,
|
||||
naturalHeight: 48,
|
||||
} as unknown as HTMLImageElement
|
||||
|
||||
const mockFont = { drawText: () => {} } as any
|
||||
|
||||
hud.leftSkillId = 0
|
||||
|
||||
// 1. Armed attack: should draw sword (sx = 96, frame 2)
|
||||
drawImageCalls.length = 0
|
||||
hud.draw(ctx, mockAtlas, mockFont, true)
|
||||
const armedDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 96)
|
||||
expect(armedDraw).toBeDefined()
|
||||
|
||||
// 2. Unarmed attack: should draw fist (sx = 0, frame 0)
|
||||
drawImageCalls.length = 0
|
||||
hud.draw(ctx, mockAtlas, mockFont, false)
|
||||
const unarmedDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 0)
|
||||
expect(unarmedDraw).toBeDefined()
|
||||
|
||||
// 3. Throw (skill 2): should draw throw (sx = 288, frame 6)
|
||||
hud.leftSkillId = 2
|
||||
drawImageCalls.length = 0
|
||||
hud.draw(ctx, mockAtlas, mockFont, true)
|
||||
const throwDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 288)
|
||||
expect(throwDraw).toBeDefined()
|
||||
|
||||
// 4. Unsummon (skill 3): should draw unsummon (sx = 192, frame 4)
|
||||
hud.rightSkillId = 3
|
||||
drawImageCalls.length = 0
|
||||
hud.draw(ctx, mockAtlas, mockFont, true)
|
||||
const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192)
|
||||
expect(unsummonDraw).toBeDefined()
|
||||
})
|
||||
|
||||
it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => {
|
||||
// Left speedbar should include Throw but NOT Unsummon
|
||||
const leftSkills = hud.getSpeedbarSkills('left')
|
||||
expect(leftSkills.some((e) => e.skill.skillId === 2)).toBe(true)
|
||||
expect(leftSkills.some((e) => e.skill.skillId === 3)).toBe(false)
|
||||
|
||||
// Right speedbar should include both Throw and Unsummon
|
||||
const rightSkills = hud.getSpeedbarSkills('right')
|
||||
expect(rightSkills.some((e) => e.skill.skillId === 2)).toBe(true)
|
||||
expect(rightSkills.some((e) => e.skill.skillId === 3)).toBe(true)
|
||||
|
||||
// Assign Throw to left
|
||||
hud.assignSkill('left', 2)
|
||||
expect(hud.leftSkillId).toBe(2)
|
||||
|
||||
// Assign Unsummon to right
|
||||
hud.assignSkill('right', 3)
|
||||
expect(hud.rightSkillId).toBe(3)
|
||||
})
|
||||
})
|
||||
|
|
@ -302,5 +302,75 @@ describe('Milestone M4 (Issue #149): Base item invfile audit & charm icons', ()
|
|||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('Issue #375: ItemTypes.txt VarInvGfx variants for rings, amulets, jewels, and charms', () => {
|
||||
it('bakes all ring (5), amulet (3), and jewel (6) graphic variants into itemRects', () => {
|
||||
// Rings: invrin1 to invrin5
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const key = `invrin${i}`
|
||||
expect(itemRects[key], `Expected ${key} in itemRects`).toBeDefined()
|
||||
expect(itemRects[key]!.w).toBe(28)
|
||||
expect(itemRects[key]!.h).toBe(28)
|
||||
}
|
||||
|
||||
// Amulets: invamu1 to invamu3
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const key = `invamu${i}`
|
||||
expect(itemRects[key], `Expected ${key} in itemRects`).toBeDefined()
|
||||
expect(itemRects[key]!.w).toBe(28)
|
||||
expect(itemRects[key]!.h).toBe(28)
|
||||
}
|
||||
|
||||
// Jewels: invjw1 to invjw6
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const key = `invjw${i}`
|
||||
expect(itemRects[key], `Expected ${key} in itemRects`).toBeDefined()
|
||||
expect(itemRects[key]!.w).toBe(28)
|
||||
expect(itemRects[key]!.h).toBe(28)
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves all ring variants 0..4 correctly', () => {
|
||||
for (let v = 0; v < 5; v++) {
|
||||
const rect = resolveItemSpriteRect({ code: 'rin', variant: v }, itemRects)
|
||||
expect(rect).toEqual(itemRects[`invrin${v + 1}`])
|
||||
}
|
||||
// Direct invFile
|
||||
expect(resolveItemSpriteRect({ code: 'rin', invFile: 'invrin3' }, itemRects)).toEqual(itemRects['invrin3'])
|
||||
})
|
||||
|
||||
it('resolves all amulet variants 0..2 correctly', () => {
|
||||
for (let v = 0; v < 3; v++) {
|
||||
const rect = resolveItemSpriteRect({ code: 'amu', variant: v }, itemRects)
|
||||
expect(rect).toEqual(itemRects[`invamu${v + 1}`])
|
||||
}
|
||||
// Direct invFile
|
||||
expect(resolveItemSpriteRect({ code: 'amu', invFile: 'invamu2' }, itemRects)).toEqual(itemRects['invamu2'])
|
||||
})
|
||||
|
||||
it('resolves all jewel variants 0..5 correctly', () => {
|
||||
for (let v = 0; v < 6; v++) {
|
||||
const rect = resolveItemSpriteRect({ code: 'jew', variant: v }, itemRects)
|
||||
expect(rect).toEqual(itemRects[`invjw${v + 1}`])
|
||||
}
|
||||
// Direct invFile
|
||||
expect(resolveItemSpriteRect({ code: 'jew', invFile: 'invjw5' }, itemRects)).toEqual(itemRects['invjw5'])
|
||||
})
|
||||
|
||||
it('resolves charm variants when variant property is specified', () => {
|
||||
expect(resolveItemSpriteRect({ code: 'cm1', variant: 0 }, itemRects)).toEqual(itemRects['invch1'])
|
||||
expect(resolveItemSpriteRect({ code: 'cm1', variant: 1 }, itemRects)).toEqual(itemRects['invch4'])
|
||||
expect(resolveItemSpriteRect({ code: 'cm1', variant: 2 }, itemRects)).toEqual(itemRects['invch7'])
|
||||
|
||||
expect(resolveItemSpriteRect({ code: 'cm2', variant: 0 }, itemRects)).toEqual(itemRects['invch2'])
|
||||
expect(resolveItemSpriteRect({ code: 'cm2', variant: 1 }, itemRects)).toEqual(itemRects['invch5'])
|
||||
expect(resolveItemSpriteRect({ code: 'cm2', variant: 2 }, itemRects)).toEqual(itemRects['invch8'])
|
||||
|
||||
expect(resolveItemSpriteRect({ code: 'cm3', variant: 0 }, itemRects)).toEqual(itemRects['invch3'])
|
||||
expect(resolveItemSpriteRect({ code: 'cm3', variant: 1 }, itemRects)).toEqual(itemRects['invch6'])
|
||||
expect(resolveItemSpriteRect({ code: 'cm3', variant: 2 }, itemRects)).toEqual(itemRects['invch9'])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Diablo II v1.13c Quest TBL Parity & Dynamic Resolution Tests (Issue #381).
|
||||
*
|
||||
* Verifies:
|
||||
* 1. All 27 canonical Diablo II quests across Acts 1..5 match Blizzard's
|
||||
* string.tbl / expansionstring.tbl / patchstring.tbl keys and metadata.
|
||||
* 2. WorldPanelsHud dynamic TBL lookup and string table chain resolution.
|
||||
* 3. Fallback resolution when TBL archives are not mounted.
|
||||
* 4. Multi-stage objective directives and completion speech rendering.
|
||||
* 5. Quest parchment layout bounds and text wrapping within 1.13c UI sockets.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CANONICAL_QUEST_DATA,
|
||||
CANONICAL_QUEST_BY_ID,
|
||||
CANONICAL_QUESTS_BY_ACT,
|
||||
} from '../src/data/canonical-quest-data.ts'
|
||||
import {
|
||||
WorldPanelsHud,
|
||||
ACT_QUESTS,
|
||||
ALL_QUESTS,
|
||||
type QuestEntry,
|
||||
} from '../src/ui/world-panels.ts'
|
||||
import { D2FontRenderer } from '../src/ui/font.ts'
|
||||
|
||||
describe('Diablo II v1.13c Quest TBL Ground Truth (Issue #381)', () => {
|
||||
it('contains exactly 27 canonical quests with authentic Blizzard 1.13c TBL keys', () => {
|
||||
expect(CANONICAL_QUEST_DATA.length).toBe(27)
|
||||
expect(ALL_QUESTS.length).toBe(27)
|
||||
|
||||
// Act 1: 6, Act 2: 6, Act 3: 6, Act 4: 3, Act 5: 6
|
||||
expect(CANONICAL_QUESTS_BY_ACT[1]?.length).toBe(6)
|
||||
expect(CANONICAL_QUESTS_BY_ACT[2]?.length).toBe(6)
|
||||
expect(CANONICAL_QUESTS_BY_ACT[3]?.length).toBe(6)
|
||||
expect(CANONICAL_QUESTS_BY_ACT[4]?.length).toBe(3)
|
||||
expect(CANONICAL_QUESTS_BY_ACT[5]?.length).toBe(6)
|
||||
|
||||
// Verify Blizzard 1.13c socket key ordering parity
|
||||
// Act 1: Cain is socket 2 (a1q3) -> qstsa1q4; Tools of Trade is socket 4 (a1q5) -> qstsa1q3
|
||||
const a1q1 = CANONICAL_QUEST_BY_ID['a1q1']!
|
||||
expect(a1q1.titleKey).toBe('qstsa1q1')
|
||||
expect(a1q1.objectiveKey).toBe('qstsa1q11')
|
||||
expect(a1q1.completedKey).toBe('A1Q1SuccessfulAkara')
|
||||
expect(a1q1.titleEn).toBe('Den of Evil')
|
||||
expect(a1q1.titleZh).toBe('邪惡洞穴')
|
||||
|
||||
const a1q3 = CANONICAL_QUEST_BY_ID['a1q3']!
|
||||
expect(a1q3.titleKey).toBe('qstsa1q4') // Blizzard swapped qstsa1q3 and qstsa1q4
|
||||
expect(a1q3.titleEn).toBe('The Search for Cain')
|
||||
|
||||
const a1q5 = CANONICAL_QUEST_BY_ID['a1q5']!
|
||||
expect(a1q5.titleKey).toBe('qstsa1q3') // Tools of the Trade
|
||||
expect(a1q5.titleEn).toBe('Tools of the Trade')
|
||||
|
||||
// Act 3: Golden Bird (a3q1) -> qstsa3q4; Blade (a3q2) -> qstsa3q3; Khalim (a3q3) -> qstsa3q2; Lam Esen (a3q4) -> qstsa3q1
|
||||
const a3q1 = CANONICAL_QUEST_BY_ID['a3q1']!
|
||||
expect(a3q1.titleKey).toBe('qstsa3q4')
|
||||
expect(a3q1.titleEn).toBe('The Golden Bird')
|
||||
|
||||
const a3q4 = CANONICAL_QUEST_BY_ID['a3q4']!
|
||||
expect(a3q4.titleKey).toBe('qstsa3q1')
|
||||
expect(a3q4.titleEn).toBe("Lam Esen's Tome")
|
||||
|
||||
// Act 4: Fallen Angel -> qstsa4q1, Hell's Forge -> qstsa4q3, Terror's End -> qstsa4q2
|
||||
const a4q1 = CANONICAL_QUEST_BY_ID['a4q1']!
|
||||
expect(a4q1.titleKey).toBe('qstsa4q1')
|
||||
expect(a4q1.titleEn).toBe('The Fallen Angel')
|
||||
|
||||
const a4q2 = CANONICAL_QUEST_BY_ID['a4q2']!
|
||||
expect(a4q2.titleKey).toBe('qstsa4q3')
|
||||
expect(a4q2.titleEn).toBe("Hell's Forge")
|
||||
|
||||
const a4q3 = CANONICAL_QUEST_BY_ID['a4q3']!
|
||||
expect(a4q3.titleKey).toBe('qstsa4q2')
|
||||
expect(a4q3.titleEn).toBe("Terror's End")
|
||||
|
||||
// Act 5: Siege (a5q1) -> qstsa5q1, Rescue (a5q2) -> qstsa5q2, Prison (a5q3) -> qstsa5q3,
|
||||
// Betrayal (a5q4) -> qstsa5q4, Rite (a5q5) -> qstsa5q5, Eve of Destruction (a5q6) -> qstsa5q6
|
||||
const a5q6 = CANONICAL_QUEST_BY_ID['a5q6']!
|
||||
expect(a5q6.titleKey).toBe('qstsa5q6')
|
||||
expect(a5q6.titleEn).toBe('Eve of Destruction')
|
||||
})
|
||||
|
||||
it('provides multi-stage objective progression data for all 27 quests', () => {
|
||||
for (const q of CANONICAL_QUEST_DATA) {
|
||||
expect(q.stages.length).toBeGreaterThan(0)
|
||||
for (const stage of q.stages) {
|
||||
expect(stage.key).toBeTruthy()
|
||||
expect(stage.stage).toBeGreaterThan(0)
|
||||
expect(stage.textEn.length).toBeGreaterThan(0)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves quest display text using default fallback when no TBL is provided', () => {
|
||||
const hud = new WorldPanelsHud()
|
||||
const a1q1 = ACT_QUESTS[1]![0]!
|
||||
expect(a1q1.id).toBe('a1q1')
|
||||
|
||||
// Default status is completed for a1q1
|
||||
const displayCompleted = hud.getQuestDisplayText(a1q1)
|
||||
expect(displayCompleted.status).toBe('completed')
|
||||
expect(displayCompleted.statusTag).toBe('[已完成]')
|
||||
expect(displayCompleted.title).toBe('邪惡洞穴')
|
||||
expect(displayCompleted.titleKey).toBe('qstsa1q1')
|
||||
expect(displayCompleted.body).toContain('清除')
|
||||
expect(displayCompleted.rewardText).toContain('技能点')
|
||||
|
||||
// Toggle status to active
|
||||
hud.setQuestStatus('a1q1', 'active')
|
||||
const displayActive = hud.getQuestDisplayText(a1q1)
|
||||
expect(displayActive.status).toBe('active')
|
||||
expect(displayActive.statusTag).toBe('[进行中]')
|
||||
expect(displayActive.body).toContain('找尋一個洞穴')
|
||||
|
||||
// Toggle status to locked
|
||||
hud.setQuestStatus('a1q1', 'locked')
|
||||
const displayLocked = hud.getQuestDisplayText(a1q1)
|
||||
expect(displayLocked.status).toBe('locked')
|
||||
expect(displayLocked.statusTag).toBe('[未开启]')
|
||||
expect(displayLocked.body).toContain('尚未开启')
|
||||
})
|
||||
|
||||
it('dynamically resolves strings via setQuestTblLookup', () => {
|
||||
const hud = new WorldPanelsHud()
|
||||
const customTbl = new Map<string, string>([
|
||||
['qstsa1q1', '洞穴肃清行动 (Custom TBL)'],
|
||||
['qstsa1q11', '前往鲜血荒地消灭全部恶魔。'],
|
||||
['A1Q1SuccessfulAkara', '感谢你,阿卡拉赐予你技能。'],
|
||||
])
|
||||
|
||||
hud.setQuestTblLookup(key => customTbl.get(key))
|
||||
|
||||
const a1q1 = ACT_QUESTS[1]![0]!
|
||||
hud.setQuestStatus('a1q1', 'active')
|
||||
const display = hud.getQuestDisplayText(a1q1)
|
||||
|
||||
expect(display.title).toBe('洞穴肃清行动 (Custom TBL)')
|
||||
expect(display.body).toBe('前往鲜血荒地消灭全部恶魔。')
|
||||
|
||||
hud.setQuestStatus('a1q1', 'completed')
|
||||
const completedDisplay = hud.getQuestDisplayText(a1q1)
|
||||
expect(completedDisplay.body).toBe('感谢你,阿卡拉赐予你技能。')
|
||||
})
|
||||
|
||||
it('dynamically resolves strings via setQuestStringTableChain', () => {
|
||||
const hud = new WorldPanelsHud()
|
||||
const fakeChain = {
|
||||
get(k: string) {
|
||||
if (k === 'qstsa2q1') return "Radament's Hideout"
|
||||
if (k === 'qstsa2q11') return 'Search the Sewers beneath Lut Gholein.'
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
|
||||
hud.setQuestStringTableChain(fakeChain)
|
||||
|
||||
const a2q1 = ACT_QUESTS[2]![0]!
|
||||
hud.setQuestStatus('a2q1', 'active')
|
||||
const display = hud.getQuestDisplayText(a2q1)
|
||||
|
||||
expect(display.title).toBe("Radament's Hideout")
|
||||
expect(display.body).toBe('Search the Sewers beneath Lut Gholein.')
|
||||
})
|
||||
|
||||
it('renders the quest log parchment without overlapping bottom bezels', () => {
|
||||
const hud = new WorldPanelsHud()
|
||||
const font = new D2FontRenderer()
|
||||
|
||||
const drawCalls: { text: string; x: number; y: number }[] = []
|
||||
const originalDrawText = font.drawText.bind(font)
|
||||
vi.spyOn(font, 'drawText').mockImplementation((ctx, text, x, y, opts) => {
|
||||
drawCalls.push({ text: String(text), x, y })
|
||||
return originalDrawText(ctx, text, x, y, opts)
|
||||
})
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
beginPath: vi.fn(),
|
||||
moveTo: vi.fn(),
|
||||
lineTo: vi.fn(),
|
||||
stroke: vi.fn(),
|
||||
fill: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 100 })),
|
||||
fillStyle: '#000',
|
||||
strokeStyle: '#000',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
hud.selectedActTab = 1
|
||||
hud.selectedQuestIdx = 0
|
||||
|
||||
// Render Quest Log Panel
|
||||
hud.drawLeftDockPanel(
|
||||
mockCtx,
|
||||
'quest',
|
||||
{
|
||||
borderLeftImg: null,
|
||||
questBgImg: null,
|
||||
waypointBgImg: null,
|
||||
stashBgImg: null,
|
||||
cubeBgImg: null,
|
||||
vendorBgImg: null,
|
||||
buySellBtnImg: null,
|
||||
},
|
||||
font,
|
||||
)
|
||||
|
||||
// Parchment title should be drawn around oy + 282 (60 + 282 = 342)
|
||||
const titleCall = drawCalls.find(c => c.text.includes('(Den of Evil)'))
|
||||
expect(titleCall).toBeDefined()
|
||||
expect(titleCall!.y).toBe(60 + 282)
|
||||
|
||||
// Body should start around oy + 300 (60 + 300 = 360)
|
||||
const bodyCall = drawCalls.find(c => c.y >= 60 + 300 && c.y <= 60 + 380)
|
||||
expect(bodyCall).toBeDefined()
|
||||
|
||||
// No text should be drawn below oy + 380 into the close button zone
|
||||
const outOfBoundsCall = drawCalls.find(c => c.y > 60 + 380 && c.text.includes('洞穴'))
|
||||
expect(outOfBoundsCall).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -268,7 +268,100 @@ describe('Diablo II Skill Calculator Engine (Issue #26)', () => {
|
|||
expect(vm?.prereqs[0].name).toBe('Fire Bolt')
|
||||
expect(vm?.prereqs[0].met).toBe(false)
|
||||
expect(vm?.current.damage.hasDamage).toBe(true)
|
||||
// For slvl === 0 (unlearned), nextLevel must be null while main body previews Lv 1
|
||||
expect(vm?.isLearned).toBe(false)
|
||||
expect(vm?.current.level).toBe(1)
|
||||
expect(vm?.nextLevel).toBeNull()
|
||||
})
|
||||
|
||||
it('previews nextLevel when learned (slvl >= 1) and hardPoints < 20', () => {
|
||||
allocatePoint(state, 36, 1) // Fire Bolt prerequisite
|
||||
allocatePoint(state, 47, 5) // Fire Ball lvl 5
|
||||
state.allSkillsSoftPoints = 2 // +2 from gear -> effLevel 7
|
||||
const vm = buildTooltipViewModel(state, 47, 99)
|
||||
expect(vm).not.toBeNull()
|
||||
expect(vm?.isLearned).toBe(true)
|
||||
expect(vm?.hardPoints).toBe(5)
|
||||
expect(vm?.effectiveLevel).toBe(7)
|
||||
expect(vm?.current.level).toBe(7)
|
||||
expect(vm?.nextLevel).not.toBeNull()
|
||||
expect(vm?.nextLevel?.level).toBe(8)
|
||||
expect(vm?.nextLevel?.damage.finalMin).toBeGreaterThan(vm!.current.damage.finalMin)
|
||||
})
|
||||
|
||||
it('omits nextLevel preview when skill has reached maximum base allocation (hardPoints === 20)', () => {
|
||||
allocatePoint(state, 36, 1)
|
||||
allocatePoint(state, 47, 20) // Max 20 hard points
|
||||
state.allSkillsSoftPoints = 5 // +5 gear -> effLevel 25
|
||||
const vm = buildTooltipViewModel(state, 47, 99)
|
||||
expect(vm).not.toBeNull()
|
||||
expect(vm?.isLearned).toBe(true)
|
||||
expect(vm?.hardPoints).toBe(20)
|
||||
expect(vm?.effectiveLevel).toBe(25)
|
||||
expect(vm?.current.level).toBe(25)
|
||||
expect(vm?.nextLevel).toBeNull()
|
||||
})
|
||||
|
||||
it('evaluates authentic 1.13c Attack Rating (ToHit) scaling for combat skills', () => {
|
||||
// Jab (10): ToHit = 10, LevToHit = 9 -> 10 + (lvl - 1) * 9
|
||||
const amaState = createInitialState('ama')
|
||||
amaState.hardPoints[10] = 1
|
||||
let vmLvl1 = buildTooltipViewModel(amaState, 10, 99)
|
||||
let arLine1 = vmLvl1?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(arLine1?.textZh).toBe('準確率:+10%')
|
||||
|
||||
amaState.hardPoints[10] = 10
|
||||
let vmLvl10 = buildTooltipViewModel(amaState, 10, 99)
|
||||
let arLine10 = vmLvl10?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(arLine10?.textZh).toBe('準確率:+91%')
|
||||
|
||||
amaState.hardPoints[10] = 20
|
||||
let vmLvl20 = buildTooltipViewModel(amaState, 10, 99)
|
||||
let arLine20 = vmLvl20?.current.effects.find(e => e.textZh.includes('準確率') || e.textZh.includes('准确率'))
|
||||
expect(arLine20?.textZh).toBe('準確率:+181%')
|
||||
|
||||
// Zeal (106): ToHit = 10, LevToHit = 10 -> 10 + (lvl - 1) * 10 = +200%
|
||||
const palState = createInitialState('pal')
|
||||
palState.hardPoints[106] = 20
|
||||
const vmZeal = buildTooltipViewModel(palState, 106, 99)
|
||||
const zealAr = vmZeal?.current.effects.find(e => e.textZh.includes('攻擊加成') || e.textZh.includes('準確率'))
|
||||
expect(zealAr?.textZh).toBe('攻擊加成:+200%')
|
||||
|
||||
// Sacrifice (96): ToHit = 20, LevToHit = 7 -> 20 + (lvl - 1) * 7 = +83%
|
||||
palState.hardPoints[96] = 10
|
||||
const vmSacrifice = buildTooltipViewModel(palState, 96, 99)
|
||||
const sacAr = vmSacrifice?.current.effects.find(e => e.textZh.includes('準確率'))
|
||||
expect(sacAr?.textZh).toBe('變動準確率:+83%')
|
||||
})
|
||||
|
||||
it('evaluates authentic 1.13c Shout warcry duration using Param3/Param4 (ln34)', () => {
|
||||
const barState = createInitialState('bar')
|
||||
barState.hardPoints[138] = 1 // Shout Level 1: Param3 = 500 frames = 20 seconds
|
||||
let vmShout = buildTooltipViewModel(barState, 138, 99)
|
||||
let durLine = vmShout?.current.effects.find(e => e.lineType === 12)
|
||||
expect(durLine?.textZh).toBe('持續:20 秒')
|
||||
|
||||
barState.hardPoints[138] = 20 // Shout Level 20: 500 + 19 * 250 = 5250 frames = 210 seconds
|
||||
vmShout = buildTooltipViewModel(barState, 138, 99)
|
||||
durLine = vmShout?.current.effects.find(e => e.lineType === 12)
|
||||
expect(durLine?.textZh).toBe('持續:210 秒')
|
||||
})
|
||||
|
||||
it('strictly enforces that synergies ignore soft gear points (1.13c Marrowwalk fix)', () => {
|
||||
const sorState1 = createInitialState('sor')
|
||||
sorState1.hardPoints[36] = 20 // 20 hard points in Fire Bolt
|
||||
const vmHard = buildTooltipViewModel(sorState1, 47, 99)
|
||||
const synHard = vmHard?.synergiesReceived.find(s => s.providerId === 36)
|
||||
expect(synHard?.hardPoints).toBe(20)
|
||||
expect(synHard?.currentBonusPct).toBe(280) // 20 * 14% = 280%
|
||||
|
||||
const sorState2 = createInitialState('sor')
|
||||
sorState2.hardPoints[36] = 0 // 0 hard points
|
||||
sorState2.allSkillsSoftPoints = 20 // +20 soft points from gear
|
||||
const vmSoft = buildTooltipViewModel(sorState2, 47, 99)
|
||||
const synSoft = vmSoft?.synergiesReceived.find(s => s.providerId === 36)
|
||||
expect(synSoft?.hardPoints).toBe(0)
|
||||
expect(synSoft?.currentBonusPct).toBe(0) // Soft points give 0% synergy
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,346 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
SkillHotkeysHud,
|
||||
LEFT_SKILL_BOUNDS,
|
||||
RIGHT_SKILL_BOUNDS,
|
||||
DEFAULT_SORCERESS_SKILLS,
|
||||
} from '../src/ui/hotkeys.ts'
|
||||
import { HudManager } from '../src/ui/hud-manager.ts'
|
||||
import { D2FontRenderer, type D2ColorCode } from '../src/ui/font.ts'
|
||||
import { SKILLS_BY_ID } from '../src/data/skills-catalog.ts'
|
||||
|
||||
describe('Issue #380: Authentic Diablo II 1.13c Skill Quickbar & Speedbar Tooltips', () => {
|
||||
let hud: SkillHotkeysHud
|
||||
const mockFont = {
|
||||
measureText: (txt: string, font?: string) => {
|
||||
const charW = font === 'fontexocet10' ? 11 : 8
|
||||
return txt.length * charW
|
||||
},
|
||||
drawText: () => {},
|
||||
} as unknown as D2FontRenderer
|
||||
|
||||
beforeEach(() => {
|
||||
hud = new SkillHotkeysHud()
|
||||
})
|
||||
|
||||
describe('1. Default Universal Skills (Attack 0, Throw 2, Unsummon 3)', () => {
|
||||
it('formats authentic Attack tooltip with weapon/barehanded narrative and 0 mana', () => {
|
||||
const lines = hud.formatTooltipLines(0, 'left', false)
|
||||
expect(lines.length).toBeGreaterThanOrEqual(3)
|
||||
|
||||
// Title
|
||||
expect(lines[0]!.text).toBe('普通攻击 (Attack)')
|
||||
expect(lines[0]!.color).toBe('gold')
|
||||
expect(lines[0]!.font).toBe('fontexocet10')
|
||||
|
||||
// Narrative
|
||||
const narrativeLine = lines.find(l => l.text.includes('物理攻击'))
|
||||
expect(narrativeLine).toBeDefined()
|
||||
expect(narrativeLine!.color).toBe('white')
|
||||
|
||||
// Mana
|
||||
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
|
||||
expect(manaLine).toBeDefined()
|
||||
expect(manaLine!.color).toBe('white')
|
||||
})
|
||||
|
||||
it('formats authentic Throw tooltip with throw weapon narrative and 0 mana', () => {
|
||||
const lines = hud.formatTooltipLines(2, 'left', false)
|
||||
expect(lines[0]!.text).toBe('投掷 (Throw)')
|
||||
expect(lines[0]!.color).toBe('gold')
|
||||
|
||||
const narrativeLine = lines.find(l => l.text.includes('投掷类武器'))
|
||||
expect(narrativeLine).toBeDefined()
|
||||
|
||||
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
|
||||
expect(manaLine).toBeDefined()
|
||||
})
|
||||
|
||||
it('formats authentic Unsummon tooltip with minion dismissal narrative and 0 mana', () => {
|
||||
const lines = hud.formatTooltipLines(3, 'right', false)
|
||||
expect(lines[0]!.text).toBe('取消召唤 (Unsummon)')
|
||||
expect(lines[0]!.color).toBe('gold')
|
||||
|
||||
const narrativeLine = lines.find(l => l.text.includes('解散'))
|
||||
expect(narrativeLine).toBeDefined()
|
||||
|
||||
const manaLine = lines.find(l => l.text.includes('法力消耗: 0'))
|
||||
expect(manaLine).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('2. Hotkey Binding Prompts & Labels', () => {
|
||||
it('displays hotkey binding prompt in speedbar popup when unbound', () => {
|
||||
// Skill 36 (Fire Bolt) not bound initially
|
||||
const lines = hud.formatTooltipLines(36, 'left', true)
|
||||
const hotkeyLine = lines.find(l => l.text.includes('按 F1-F8 键设置快捷键'))
|
||||
expect(hotkeyLine).toBeDefined()
|
||||
expect(hotkeyLine!.color).toBe('tan')
|
||||
})
|
||||
|
||||
it('displays bound hotkey and rebind prompt in speedbar popup when bound', () => {
|
||||
// F5 is bound to Fire Ball (47) on left side by default
|
||||
const lines = hud.formatTooltipLines(47, 'left', true)
|
||||
const hotkeyLine = lines.find(l => l.text.includes('当前快捷键: F5 (按 F1-F8 键重新绑定)'))
|
||||
expect(hotkeyLine).toBeDefined()
|
||||
expect(hotkeyLine!.color).toBe('tan')
|
||||
})
|
||||
|
||||
it('displays bound hotkey on main control bar slot', () => {
|
||||
// Left slot is Fire Ball (47) with F5
|
||||
const lines = hud.formatTooltipLines(47, 'left', false)
|
||||
const hotkeyLine = lines.find(l => l.text.includes('快捷键: F5'))
|
||||
expect(hotkeyLine).toBeDefined()
|
||||
expect(hotkeyLine!.color).toBe('tan')
|
||||
})
|
||||
})
|
||||
|
||||
describe('3. Class Skills & ViewModel Integration', () => {
|
||||
it('integrates with TooltipViewModel from skill engine displaying level, mana, damage, and synergies', () => {
|
||||
// Mock tooltip resolver
|
||||
hud.tooltipResolver = (skillId: number) => {
|
||||
if (skillId === 47) {
|
||||
return {
|
||||
skill: SKILLS_BY_ID[47]!,
|
||||
hardPoints: 15,
|
||||
effectiveLevel: 23,
|
||||
isLearned: true,
|
||||
canAllocateNow: true,
|
||||
reqlevel: 12,
|
||||
levelMet: true,
|
||||
prereqs: [],
|
||||
current: {
|
||||
level: 23,
|
||||
manaCost: 11,
|
||||
delaySeconds: 0,
|
||||
damage: {
|
||||
hasDamage: true,
|
||||
typeZh: '火焰伤害',
|
||||
finalMin: 345,
|
||||
finalMax: 388,
|
||||
},
|
||||
effects: [{ textZh: '爆炸半径: 2.6 码' }],
|
||||
},
|
||||
nextLevel: null,
|
||||
synergiesReceived: [
|
||||
{
|
||||
providerId: 36,
|
||||
providerName: 'Fire Bolt',
|
||||
providerNameZh: '火弹',
|
||||
percentPerLevel: 14,
|
||||
hardPoints: 5,
|
||||
currentBonusPct: 70,
|
||||
},
|
||||
],
|
||||
masteryInfo: null,
|
||||
} as any
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const lines = hud.formatTooltipLines(47, 'left', false)
|
||||
|
||||
// Title
|
||||
expect(lines[0]!.text).toBe('火球 (Fire Ball)')
|
||||
expect(lines[0]!.color).toBe('gold')
|
||||
|
||||
// Level with soft bonus
|
||||
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 23 (基础 15 + 装备 +8)'))
|
||||
expect(lvlLine).toBeDefined()
|
||||
expect(lvlLine!.color).toBe('blue')
|
||||
|
||||
// Mana cost
|
||||
const manaLine = lines.find(l => l.text.includes('法力消耗: 11'))
|
||||
expect(manaLine).toBeDefined()
|
||||
|
||||
// Narrative description
|
||||
const descLine = lines.find(l => l.text.includes('爆炸') || l.text.includes('火球'))
|
||||
expect(descLine).toBeDefined()
|
||||
|
||||
// Damage
|
||||
const dmgLine = lines.find(l => l.text.includes('火焰伤害: 345 - 388'))
|
||||
expect(dmgLine).toBeDefined()
|
||||
expect(dmgLine!.color).toBe('blue')
|
||||
|
||||
// Effect
|
||||
const effLine = lines.find(l => l.text.includes('爆炸半径: 2.6 码'))
|
||||
expect(effLine).toBeDefined()
|
||||
|
||||
// Synergy section header and lines
|
||||
const synHeader = lines.find(l => l.text.includes('[协同技能加成]'))
|
||||
expect(synHeader).toBeDefined()
|
||||
expect(synHeader!.color).toBe('blue')
|
||||
|
||||
const synLine = lines.find(l => l.text.includes('火弹: 每个有效技能点增加 14% 伤害 (当前 +70%)'))
|
||||
expect(synLine).toBeDefined()
|
||||
expect(synLine!.color).toBe('blue')
|
||||
})
|
||||
|
||||
it('falls back cleanly to catalog data when no TooltipViewModel is supplied', () => {
|
||||
hud.tooltipResolver = undefined
|
||||
const lines = hud.formatTooltipLines(47, 'left', false)
|
||||
expect(lines[0]!.text).toBe('火球 (Fire Ball)')
|
||||
expect(lines[0]!.color).toBe('gold')
|
||||
|
||||
const lvlLine = lines.find(l => l.text.includes('当前技能等级: 8'))
|
||||
expect(lvlLine).toBeDefined()
|
||||
|
||||
const manaLine = lines.find(l => l.text.includes('法力消耗: 8.5'))
|
||||
expect(manaLine).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('4. Tooltip Viewport Boundary Clamping & Placement', () => {
|
||||
it('centers and clamps tooltip over Left Skill Slot without spilling off screen', () => {
|
||||
const lines = hud.formatTooltipLines(47, 'left', false)
|
||||
const placement = SkillHotkeysHud.computeTooltipPlacement(LEFT_SKILL_BOUNDS, lines, mockFont)
|
||||
|
||||
// Horizontally clamped inside [8, 800 - 8]
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
|
||||
// Vertically placed strictly above the control bar slot (y <= 551 - 6 - h)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(551)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('centers and clamps tooltip over Right Skill Slot without spilling off screen', () => {
|
||||
const lines = hud.formatTooltipLines(64, 'right', false)
|
||||
const placement = SkillHotkeysHud.computeTooltipPlacement(RIGHT_SKILL_BOUNDS, lines, mockFont)
|
||||
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(551)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
|
||||
it('places tooltip above speedbar popup cell and clamps within viewport', () => {
|
||||
hud.openSpeedbar('left')
|
||||
const cells = hud.getPopupCells('left')
|
||||
const cell = cells[0]!
|
||||
|
||||
const lines = hud.formatTooltipLines(cell.skill.skillId, 'left', true)
|
||||
const placement = SkillHotkeysHud.computeTooltipPlacement(cell, lines, mockFont)
|
||||
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('5. Mouse Hover Transitions & Drawing', () => {
|
||||
it('sets hoveredSlot and hoverSkillId when hovering left skill button', () => {
|
||||
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
|
||||
expect(hud.hoverSkillId).toBe(hud.leftSkillId)
|
||||
expect(hud.hoveredSlot).toBe('left')
|
||||
expect(hud.hoveredPopupSkill).toBeNull()
|
||||
|
||||
// Move away
|
||||
hud.handleMouseMove(400, 300)
|
||||
expect(hud.hoverSkillId).toBeNull()
|
||||
expect(hud.hoveredSlot).toBeNull()
|
||||
})
|
||||
|
||||
it('sets hoveredSlot and hoverSkillId when hovering right skill button', () => {
|
||||
hud.handleMouseMove(RIGHT_SKILL_BOUNDS.x + 10, RIGHT_SKILL_BOUNDS.y + 10)
|
||||
expect(hud.hoverSkillId).toBe(hud.rightSkillId)
|
||||
expect(hud.hoveredSlot).toBe('right')
|
||||
expect(hud.hoveredPopupSkill).toBeNull()
|
||||
|
||||
// Move away
|
||||
hud.handleMouseMove(400, 300)
|
||||
expect(hud.hoverSkillId).toBeNull()
|
||||
expect(hud.hoveredSlot).toBeNull()
|
||||
})
|
||||
|
||||
it('sets hoveredPopupSkill when hovering inside open speedbar', () => {
|
||||
hud.openSpeedbar('right')
|
||||
const cells = hud.getPopupCells('right')
|
||||
const cell = cells[1]!
|
||||
|
||||
hud.handleMouseMove(cell.x + 10, cell.y + 10)
|
||||
expect(hud.hoveredPopupSkill).toEqual({
|
||||
side: 'right',
|
||||
skillId: cell.skill.skillId,
|
||||
})
|
||||
|
||||
// Move away
|
||||
hud.handleMouseMove(10, 10)
|
||||
expect(hud.hoveredPopupSkill).toBeNull()
|
||||
})
|
||||
|
||||
it('renders authentic D2 double borders and dark translucent fill when tooltip drawn', () => {
|
||||
const fillRects: any[] = []
|
||||
const strokeRects: any[] = []
|
||||
const drawnTexts: any[] = []
|
||||
|
||||
const mockCtx = {
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
fillRect: (...args: any[]) => fillRects.push({ style: mockCtx.fillStyle, args }),
|
||||
strokeRect: (...args: any[]) => strokeRects.push({ style: mockCtx.strokeStyle, args }),
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const testFont = {
|
||||
measureText: () => 120,
|
||||
drawText: (...args: any[]) => drawnTexts.push(args),
|
||||
} as unknown as D2FontRenderer
|
||||
|
||||
// Hover left slot
|
||||
hud.handleMouseMove(LEFT_SKILL_BOUNDS.x + 10, LEFT_SKILL_BOUNDS.y + 10)
|
||||
hud.drawSkillTooltip(mockCtx, testFont)
|
||||
|
||||
// Verify dark background
|
||||
const darkFill = fillRects.find(f => f.style === 'rgba(4, 4, 4, 0.94)')
|
||||
expect(darkFill).toBeDefined()
|
||||
|
||||
// Verify outer bronze border (#8c7244) and inner dark border (#3d2b1a)
|
||||
const outerBorder = strokeRects.find(s => s.style === '#8c7244')
|
||||
const innerBorder = strokeRects.find(s => s.style === '#3d2b1a')
|
||||
expect(outerBorder).toBeDefined()
|
||||
expect(innerBorder).toBeDefined()
|
||||
|
||||
// Verify texts drawn (title in gold)
|
||||
expect(drawnTexts.length).toBeGreaterThanOrEqual(3)
|
||||
const titleDraw = drawnTexts.find(d => d[1].includes('火球') && d[4].color === 'gold')
|
||||
expect(titleDraw).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('6. HudManager End-to-End Class Tooltip Resolution', () => {
|
||||
const mockCanvas = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
style: { cursor: '' },
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
getContext: () => null,
|
||||
} as unknown as HTMLCanvasElement
|
||||
|
||||
it('attaches skillTree.getTooltipViewModel to hotkeys in HudManager and updates on class switch', () => {
|
||||
const hudMgr = new HudManager(mockCanvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
// Switch to Paladin: Left is Sacrifice (96), Right is Blessed Hammer (112)
|
||||
hudMgr.switchClass('pal')
|
||||
expect(hudMgr.hotkeys.tooltipResolver).toBeDefined()
|
||||
|
||||
const sacLines = hudMgr.hotkeys.formatTooltipLines(96, 'left', false)
|
||||
expect(sacLines[0]!.text).toContain('Sacrifice')
|
||||
expect(sacLines.some(l => l.text.includes('当前技能等级:'))).toBe(true)
|
||||
|
||||
const hammerLines = hudMgr.hotkeys.formatTooltipLines(112, 'right', false)
|
||||
expect(hammerLines[0]!.text).toContain('Blessed Hammer')
|
||||
expect(hammerLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
|
||||
|
||||
// Switch to Barbarian: Whirlwind (151)
|
||||
hudMgr.switchClass('bar')
|
||||
const wwLines = hudMgr.hotkeys.formatTooltipLines(151, 'right', false)
|
||||
expect(wwLines[0]!.text).toContain('Whirlwind')
|
||||
expect(wwLines.some(l => l.text.includes('当前技能等级:') && l.text.includes('20'))).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,198 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { SkillTreePanel, SKILL_PANEL_ORIGIN } from '../src/ui/skill-tree-panel.ts'
|
||||
import { BAKED_UI_MANIFEST } from '../src/ui/baked-ui-meta.ts'
|
||||
import type { D2FontRenderer } from '../src/ui/font.ts'
|
||||
|
||||
describe('Issue #377: Authentic D2 v1.13c Skill Tree Tabs & Skill Points Banner', () => {
|
||||
it('manifest contains skillTabs and skillPoints image references', () => {
|
||||
expect(BAKED_UI_MANIFEST.images.skillTabs).toBe('/ui/skill-tree-tabs.png')
|
||||
expect(BAKED_UI_MANIFEST.images.skillPoints).toBe('/ui/skillpoints.png')
|
||||
|
||||
const tabsPath = join(process.cwd(), 'public', 'ui', 'skill-tree-tabs.png')
|
||||
const spPath = join(process.cwd(), 'public', 'ui', 'skillpoints.png')
|
||||
|
||||
expect(existsSync(tabsPath), 'skill-tree-tabs.png must exist in public/ui').toBe(true)
|
||||
expect(existsSync(spPath), 'skillpoints.png must exist in public/ui').toBe(true)
|
||||
})
|
||||
|
||||
it('skill-tree-tabs.png has authentic 192x432 strip dimensions (3 states x 64x432)', () => {
|
||||
const tabsPath = join(process.cwd(), 'public', 'ui', 'skill-tree-tabs.png')
|
||||
const buf = readFileSync(tabsPath)
|
||||
// PNG width and height at bytes 16..24
|
||||
const width = buf.readUInt32BE(16)
|
||||
const height = buf.readUInt32BE(20)
|
||||
expect(width).toBe(192)
|
||||
expect(height).toBe(432)
|
||||
})
|
||||
|
||||
it('skillpoints.png has authentic 135x23 dimensions', () => {
|
||||
const spPath = join(process.cwd(), 'public', 'ui', 'skillpoints.png')
|
||||
const buf = readFileSync(spPath)
|
||||
const width = buf.readUInt32BE(16)
|
||||
const height = buf.readUInt32BE(20)
|
||||
expect(width).toBe(135)
|
||||
expect(height).toBe(23)
|
||||
})
|
||||
|
||||
it('draws authentic 64x432 tab column slice according to activeTab', () => {
|
||||
const panel = new SkillTreePanel()
|
||||
panel.visible = true
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockFont = {
|
||||
drawText: vi.fn(),
|
||||
measureText: vi.fn(() => 50),
|
||||
} as unknown as D2FontRenderer
|
||||
|
||||
const mockTabsImg = {
|
||||
complete: true,
|
||||
naturalWidth: 192,
|
||||
naturalHeight: 432,
|
||||
} as unknown as HTMLImageElement
|
||||
|
||||
const mockSpImg = {
|
||||
complete: true,
|
||||
naturalWidth: 135,
|
||||
naturalHeight: 23,
|
||||
} as unknown as HTMLImageElement
|
||||
|
||||
// 1. Tab 2 (Cold Spells, Top Tab): slice sx = 0
|
||||
panel.activeTab = 2
|
||||
panel.draw(mockCtx, {
|
||||
borderRightImg: null,
|
||||
skillPointsImg: mockSpImg,
|
||||
buySellBtnImg: null,
|
||||
skillTabsImg: mockTabsImg,
|
||||
}, mockFont)
|
||||
|
||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
||||
mockTabsImg,
|
||||
0, // sx = 0
|
||||
0,
|
||||
64,
|
||||
432,
|
||||
SKILL_PANEL_ORIGIN.x + 256,
|
||||
SKILL_PANEL_ORIGIN.y,
|
||||
64,
|
||||
432,
|
||||
)
|
||||
|
||||
// 2. Tab 1 (Lightning Spells, Middle Tab): slice sx = 64
|
||||
vi.clearAllMocks()
|
||||
panel.activeTab = 1
|
||||
panel.draw(mockCtx, {
|
||||
borderRightImg: null,
|
||||
skillPointsImg: mockSpImg,
|
||||
buySellBtnImg: null,
|
||||
skillTabsImg: mockTabsImg,
|
||||
}, mockFont)
|
||||
|
||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
||||
mockTabsImg,
|
||||
64, // sx = 64
|
||||
0,
|
||||
64,
|
||||
432,
|
||||
SKILL_PANEL_ORIGIN.x + 256,
|
||||
SKILL_PANEL_ORIGIN.y,
|
||||
64,
|
||||
432,
|
||||
)
|
||||
|
||||
// 3. Tab 0 (Fire Spells, Bottom Tab): slice sx = 128
|
||||
vi.clearAllMocks()
|
||||
panel.activeTab = 0
|
||||
panel.draw(mockCtx, {
|
||||
borderRightImg: null,
|
||||
skillPointsImg: mockSpImg,
|
||||
buySellBtnImg: null,
|
||||
skillTabsImg: mockTabsImg,
|
||||
}, mockFont)
|
||||
|
||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
||||
mockTabsImg,
|
||||
128, // sx = 128
|
||||
0,
|
||||
64,
|
||||
432,
|
||||
SKILL_PANEL_ORIGIN.x + 256,
|
||||
SKILL_PANEL_ORIGIN.y,
|
||||
64,
|
||||
432,
|
||||
)
|
||||
})
|
||||
|
||||
it('draws authentic skillpoints.png banner when unspentSkillPoints > 0', () => {
|
||||
const panel = new SkillTreePanel()
|
||||
panel.visible = true
|
||||
panel.unspentSkillPoints = 5
|
||||
|
||||
const mockCtx = {
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
globalAlpha: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockFont = {
|
||||
drawText: vi.fn(),
|
||||
measureText: vi.fn(() => 50),
|
||||
} as unknown as D2FontRenderer
|
||||
|
||||
const mockSpImg = {
|
||||
complete: true,
|
||||
naturalWidth: 135,
|
||||
naturalHeight: 23,
|
||||
} as unknown as HTMLImageElement
|
||||
|
||||
panel.draw(mockCtx, {
|
||||
borderRightImg: null,
|
||||
skillPointsImg: mockSpImg,
|
||||
buySellBtnImg: null,
|
||||
}, mockFont)
|
||||
|
||||
// Verify skillpoints banner drawn at (ox + 60, oy + 395)
|
||||
expect(mockCtx.drawImage).toHaveBeenCalledWith(
|
||||
mockSpImg,
|
||||
SKILL_PANEL_ORIGIN.x + Math.round((256 - 135) / 2),
|
||||
SKILL_PANEL_ORIGIN.y + 395,
|
||||
135,
|
||||
23,
|
||||
)
|
||||
|
||||
// Verify when unspentSkillPoints = 0, banner is NOT drawn
|
||||
vi.clearAllMocks()
|
||||
panel.unspentSkillPoints = 0
|
||||
panel.draw(mockCtx, {
|
||||
borderRightImg: null,
|
||||
skillPointsImg: mockSpImg,
|
||||
buySellBtnImg: null,
|
||||
}, mockFont)
|
||||
|
||||
expect(mockCtx.drawImage).not.toHaveBeenCalledWith(
|
||||
mockSpImg,
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -37,6 +37,11 @@ import {
|
|||
LEVEL_NAMES_ZH,
|
||||
LEVEL_NAMES_EN,
|
||||
} from '../src/game/level-names-zh.ts'
|
||||
import {
|
||||
buildTooltipViewModel,
|
||||
createInitialState,
|
||||
allocatePoint,
|
||||
} from '../src/game/skill-calc-engine.ts'
|
||||
|
||||
describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
||||
it('verifies offline DC6 UI asset extraction manifest has 0 decode failures', () => {
|
||||
|
|
@ -635,4 +640,122 @@ describe('Diablo II v1.13c UI / HUD (Issue #27)', () => {
|
|||
expect(sceneNameZh(null as any)).toBe('Unknown Area')
|
||||
expect(sceneNameEn(null as any)).toBe('Unknown Area')
|
||||
})
|
||||
|
||||
it('renders authentic multi-line skill tooltips with 1.13c next-level preview and styling rules (Issue #152)', () => {
|
||||
// 1. Unlearned skill with requirements not met (Sorceress lvl 1 looking at Fire Ball lvl 12)
|
||||
const sorcBuild = createInitialState('sor')
|
||||
const unlearnedFireBall = buildTooltipViewModel(sorcBuild, 47, 1) // Fire Ball requires clvl 12, Fire Bolt
|
||||
expect(unlearnedFireBall).not.toBeNull()
|
||||
const unlearnedLines = SkillTreePanel.formatSkillTooltipLines(unlearnedFireBall!)
|
||||
|
||||
// Title: Fire Ball in Gold
|
||||
expect(unlearnedLines[0]).toEqual({ text: '火球 (Fire Ball)', color: 'gold', font: 'fontexocet10', align: 'center' })
|
||||
// Required Level: red when character level < reqlevel
|
||||
const reqLevelLine = unlearnedLines.find(l => l.text.includes('需要角色等级: 12'))
|
||||
expect(reqLevelLine).toBeDefined()
|
||||
expect(reqLevelLine?.color).toBe('red')
|
||||
// Prerequisites: red when unmet
|
||||
const prereqLine = unlearnedLines.find(l => l.text.includes('需要前置技能: 火彈'))
|
||||
expect(prereqLine).toBeDefined()
|
||||
expect(prereqLine?.color).toBe('red')
|
||||
// Unlearned skill MUST NOT include [下一等级] preview
|
||||
expect(unlearnedLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
|
||||
// 2. Unlearned skill with requirements met (Sorceress lvl 1 looking at Fire Bolt lvl 1)
|
||||
const fireBolt = buildTooltipViewModel(sorcBuild, 36, 1)
|
||||
expect(fireBolt).not.toBeNull()
|
||||
const fireBoltLines = SkillTreePanel.formatSkillTooltipLines(fireBolt!)
|
||||
// When level is met, reqlevel line is omitted from tooltip
|
||||
expect(fireBoltLines.some(l => l.text.includes('需要角色等级'))).toBe(false)
|
||||
expect(fireBoltLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
|
||||
// 3. Learned skill (slvl = 1) with next-level preview in green
|
||||
const learnedBuild = createInitialState('sor')
|
||||
allocatePoint(learnedBuild, 36) // Fire Bolt +1
|
||||
const learnedFireBolt = buildTooltipViewModel(learnedBuild, 36, 1)
|
||||
expect(learnedFireBolt).not.toBeNull()
|
||||
const learnedLines = SkillTreePanel.formatSkillTooltipLines(learnedFireBolt!)
|
||||
|
||||
const currLevelLine = learnedLines.find(l => l.text.includes('当前技能等级: 1'))
|
||||
expect(currLevelLine).toBeDefined()
|
||||
expect(currLevelLine?.color).toBe('white')
|
||||
|
||||
const nextLevelHeader = learnedLines.find(l => l.text.includes('[下一等级] (Lv 2)'))
|
||||
expect(nextLevelHeader).toBeDefined()
|
||||
expect(nextLevelHeader?.color).toBe('green')
|
||||
|
||||
// Next level damage preview in green
|
||||
const nextLevelDmg = learnedLines.find(l => l.color === 'green' && l.text.includes('火焰伤害'))
|
||||
expect(nextLevelDmg).toBeDefined()
|
||||
|
||||
// 4. Maxed skill (hardPoints === 20): omits [下一等级] and shows max allocation notice in gold
|
||||
const maxedBuild = createInitialState('sor')
|
||||
allocatePoint(maxedBuild, 36, 20)
|
||||
const maxedFireBolt = buildTooltipViewModel(maxedBuild, 36, 20)
|
||||
expect(maxedFireBolt).not.toBeNull()
|
||||
const maxedLines = SkillTreePanel.formatSkillTooltipLines(maxedFireBolt!)
|
||||
|
||||
expect(maxedLines.some(l => l.text.includes('[下一等级]'))).toBe(false)
|
||||
const maxedNotice = maxedLines.find(l => l.text.includes('[已达技能点投入上限 (20/20)]'))
|
||||
expect(maxedNotice).toBeDefined()
|
||||
expect(maxedNotice?.color).toBe('gold')
|
||||
|
||||
// 5. Synergy section formatting
|
||||
const fireballBuild = createInitialState('sor')
|
||||
allocatePoint(fireballBuild, 36) // Fire Bolt +1
|
||||
const fbVm = buildTooltipViewModel(fireballBuild, 47, 12)
|
||||
const fbLines = SkillTreePanel.formatSkillTooltipLines(fbVm!)
|
||||
const synHeader = fbLines.find(l => l.text.includes('[协同技能加成]'))
|
||||
expect(synHeader).toBeDefined()
|
||||
expect(synHeader?.color).toBe('blue')
|
||||
const synFireBolt = fbLines.find(l => l.text.includes('火彈: 每个有效技能点增加 14% 伤害'))
|
||||
expect(synFireBolt).toBeDefined()
|
||||
expect(synFireBolt?.color).toBe('blue') // active synergy (Fire Bolt has 1 hard point)
|
||||
const synMeteor = fbLines.find(l => l.text.includes('隕石: 每个有效技能点增加 14% 伤害'))
|
||||
expect(synMeteor).toBeDefined()
|
||||
expect(synMeteor?.color).toBe('gray') // inactive synergy (Meteor has 0 hard points)
|
||||
})
|
||||
|
||||
it('clamps tooltip placement within canvas viewport and strictly above the control bar (Issue #152)', () => {
|
||||
const dummyLines = [
|
||||
{ text: '火球', color: 'white' as const, font: 'fontexocet10' as const, align: 'center' as const },
|
||||
{ text: '当前等级: 1', color: 'white' as const, font: 'font8' as const, align: 'center' as const },
|
||||
{ text: '火焰伤害: 6 - 14', color: 'blue' as const, font: 'font8' as const, align: 'center' as const },
|
||||
{ text: '[下一等级] (Lv 2)', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
|
||||
{ text: '火焰伤害: 10 - 20', color: 'green' as const, font: 'font8' as const, align: 'center' as const },
|
||||
{ text: '[协同技能加成]', color: 'blue' as const, font: 'fontexocet10' as const, align: 'center' as const },
|
||||
{ text: '• 火弹: 每个有效技能点增加 14% 伤害 (当前 +14%)', color: 'blue' as const, font: 'font8' as const, align: 'left' as const },
|
||||
{ text: '• 陨石: 每个有效技能点增加 14% 伤害', color: 'gray' as const, font: 'font8' as const, align: 'left' as const },
|
||||
]
|
||||
|
||||
const mockFont = {
|
||||
measureText: (txt: string) => txt.length * 8,
|
||||
} as any
|
||||
|
||||
// Test extreme positions across the 800x600 screen
|
||||
const testPositions = [
|
||||
{ x: 420, y: 80, w: 48, h: 48 }, // Top-left node in right-dock panel
|
||||
{ x: 700, y: 120, w: 48, h: 48 }, // Far-right node
|
||||
{ x: 450, y: 400, w: 48, h: 48 }, // Bottom node
|
||||
{ x: 500, y: 500, w: 48, h: 48 }, // Extreme bottom node near control bar
|
||||
{ x: 50, y: 200, w: 48, h: 48 }, // Far left node
|
||||
]
|
||||
|
||||
for (const pos of testPositions) {
|
||||
const placement = SkillTreePanel.computeTooltipPlacement(pos, dummyLines, mockFont)
|
||||
// Viewport bounds: x >= 8, x + w <= 792 (800 - 8)
|
||||
expect(placement.x).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.x + placement.w).toBeLessThanOrEqual(792)
|
||||
|
||||
// Vertical bounds: y >= 8, y + h <= 552 (strictly above control bar y=552)
|
||||
expect(placement.y).toBeGreaterThanOrEqual(8)
|
||||
expect(placement.y + placement.h).toBeLessThanOrEqual(552)
|
||||
|
||||
// Minimum width is 240
|
||||
expect(placement.w).toBeGreaterThanOrEqual(240)
|
||||
// Height matches line count * 17 + 24
|
||||
expect(placement.h).toBe(dummyLines.length * 17 + 24)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,467 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
HudManager,
|
||||
computeDockingLayout,
|
||||
computeHudLayout,
|
||||
CHAR_PANEL_ORIGIN,
|
||||
INV_PANEL_ORIGIN,
|
||||
INV_GRID_ORIGIN,
|
||||
SKILL_PANEL_ORIGIN,
|
||||
} from '../src/ui/hud-manager.ts'
|
||||
import { STASH_PANEL_ORIGIN, STASH_GRID_ORIGIN } from '../src/ui/world-panels.ts'
|
||||
import type { UiInventoryItem } from '../src/ui/inventory.ts'
|
||||
|
||||
class MockWindow extends EventTarget {
|
||||
innerWidth = 1920
|
||||
innerHeight = 1080
|
||||
devicePixelRatio = 1
|
||||
}
|
||||
|
||||
if (typeof globalThis.window === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.window = new MockWindow()
|
||||
}
|
||||
if (typeof globalThis.HTMLInputElement === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.HTMLInputElement = class HTMLInputElement {}
|
||||
}
|
||||
if (typeof globalThis.HTMLSelectElement === 'undefined') {
|
||||
// @ts-ignore
|
||||
globalThis.HTMLSelectElement = class HTMLSelectElement {}
|
||||
}
|
||||
|
||||
function createMockCanvas(width: number, height: number): HTMLCanvasElement {
|
||||
return {
|
||||
clientWidth: width,
|
||||
clientHeight: height,
|
||||
width,
|
||||
height,
|
||||
style: { pointerEvents: 'none' },
|
||||
getBoundingClientRect: () => ({ left: 0, top: 0, width, height }),
|
||||
getContext: () => null,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
} as unknown as HTMLCanvasElement
|
||||
}
|
||||
|
||||
function createDummyItem(id: string, code: string, invWidth: number, invHeight: number, nameZh: string): UiInventoryItem {
|
||||
return {
|
||||
id,
|
||||
code,
|
||||
invFile: code,
|
||||
name: nameZh,
|
||||
nameZh,
|
||||
baseNameZh: nameZh,
|
||||
quality: 'normal',
|
||||
invWidth,
|
||||
invHeight,
|
||||
allowedSlots: [],
|
||||
stats: [],
|
||||
}
|
||||
}
|
||||
|
||||
describe('Widescreen UI Panels Left/Right Docking & Central Vision Corridor (Issue #153)', () => {
|
||||
describe('Docking Layout Mathematics & Metrics Across Resolutions', () => {
|
||||
it('computes 800x600 classic layout with 0px margin and 0px central channel (classic D2 parity)', () => {
|
||||
const docking = computeDockingLayout(800, 600)
|
||||
expect(docking.uiScale).toBe(1.0)
|
||||
expect(docking.offsetX).toBe(0)
|
||||
expect(docking.offsetY).toBe(0)
|
||||
expect(docking.marginW).toBe(0)
|
||||
expect(docking.leftDockX).toBe(0)
|
||||
expect(docking.rightDockX).toBe(400)
|
||||
expect(docking.channelLeft).toBe(400)
|
||||
expect(docking.channelRight).toBe(400)
|
||||
expect(docking.channelWidthLogical).toBe(0)
|
||||
expect(docking.channelWidthCss).toBe(0)
|
||||
expect(docking.isWidescreen).toBe(false)
|
||||
})
|
||||
|
||||
it('computes 1024x768 4:3 layout with 0px margin and 0px central channel', () => {
|
||||
const docking = computeDockingLayout(1024, 768)
|
||||
expect(docking.uiScale).toBe(1.28)
|
||||
expect(docking.offsetX).toBe(0)
|
||||
expect(docking.offsetY).toBe(0)
|
||||
expect(docking.marginW).toBe(0)
|
||||
expect(docking.leftDockX).toBe(0)
|
||||
expect(docking.rightDockX).toBe(400)
|
||||
expect(docking.channelLeft).toBe(400)
|
||||
expect(docking.channelRight).toBe(400)
|
||||
expect(docking.channelWidthLogical).toBe(0)
|
||||
expect(docking.channelWidthCss).toBe(0)
|
||||
expect(docking.isWidescreen).toBe(false)
|
||||
})
|
||||
|
||||
it('computes 1280x720 16:9 layout with 320px CSS central channel', () => {
|
||||
const docking = computeDockingLayout(1280, 720)
|
||||
expect(docking.uiScale).toBeCloseTo(1.2, 3)
|
||||
expect(docking.offsetX).toBe(160)
|
||||
expect(docking.offsetY).toBe(0)
|
||||
expect(docking.marginW).toBeCloseTo(133.333, 2)
|
||||
expect(docking.leftDockX).toBeCloseTo(-133.333, 2)
|
||||
expect(docking.rightDockX).toBeCloseTo(533.333, 2)
|
||||
expect(docking.channelLeft).toBeCloseTo(266.667, 2)
|
||||
expect(docking.channelRight).toBeCloseTo(533.333, 2)
|
||||
expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2)
|
||||
expect(docking.channelWidthCss).toBe(320)
|
||||
expect(docking.isWidescreen).toBe(true)
|
||||
})
|
||||
|
||||
it('computes 1920x1080 Full HD 16:9 layout with 480px CSS central channel', () => {
|
||||
const docking = computeDockingLayout(1920, 1080)
|
||||
expect(docking.uiScale).toBeCloseTo(1.8, 3)
|
||||
expect(docking.offsetX).toBe(240)
|
||||
expect(docking.offsetY).toBe(0)
|
||||
expect(docking.marginW).toBeCloseTo(133.333, 2)
|
||||
expect(docking.leftDockX).toBeCloseTo(-133.333, 2)
|
||||
expect(docking.rightDockX).toBeCloseTo(533.333, 2)
|
||||
expect(docking.channelLeft).toBeCloseTo(266.667, 2)
|
||||
expect(docking.channelRight).toBeCloseTo(533.333, 2)
|
||||
expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2)
|
||||
expect(docking.channelWidthCss).toBe(480)
|
||||
expect(docking.isWidescreen).toBe(true)
|
||||
})
|
||||
|
||||
it('computes 2560x1440 QHD 16:9 layout with 640px CSS central channel', () => {
|
||||
const docking = computeDockingLayout(2560, 1440)
|
||||
expect(docking.uiScale).toBeCloseTo(2.4, 3)
|
||||
expect(docking.offsetX).toBe(320)
|
||||
expect(docking.offsetY).toBe(0)
|
||||
expect(docking.marginW).toBeCloseTo(133.333, 2)
|
||||
expect(docking.channelWidthLogical).toBeCloseTo(266.667, 2)
|
||||
expect(docking.channelWidthCss).toBe(640)
|
||||
expect(docking.isWidescreen).toBe(true)
|
||||
})
|
||||
|
||||
it('computes 3440x1440 Ultrawide 21:9 layout with 1520px CSS central channel', () => {
|
||||
const docking = computeDockingLayout(3440, 1440)
|
||||
expect(docking.uiScale).toBeCloseTo(2.4, 3)
|
||||
expect(docking.offsetX).toBe(760)
|
||||
expect(docking.offsetY).toBe(0)
|
||||
expect(docking.marginW).toBeCloseTo(316.667, 2)
|
||||
expect(docking.channelWidthLogical).toBeCloseTo(633.333, 2)
|
||||
expect(docking.channelWidthCss).toBe(1520)
|
||||
expect(docking.isWidescreen).toBe(true)
|
||||
})
|
||||
|
||||
it('guarantees channelRight >= channelLeft and zero panel overlap across all resolutions', () => {
|
||||
const resolutions = [
|
||||
[800, 600],
|
||||
[1024, 768],
|
||||
[1280, 720],
|
||||
[1280, 800],
|
||||
[1366, 768],
|
||||
[1600, 900],
|
||||
[1920, 1080],
|
||||
[1920, 1200],
|
||||
[2560, 1440],
|
||||
[3440, 1440],
|
||||
[3840, 2160],
|
||||
[5120, 1440],
|
||||
]
|
||||
|
||||
for (const [w, h] of resolutions) {
|
||||
const d = computeDockingLayout(w, h)
|
||||
expect(d.channelRight).toBeGreaterThanOrEqual(d.channelLeft)
|
||||
expect(d.channelWidthLogical).toBeGreaterThanOrEqual(0)
|
||||
expect(d.channelWidthCss).toBeGreaterThanOrEqual(0)
|
||||
|
||||
// Left panel right edge = channelLeft. Right panel left edge = channelRight.
|
||||
// Overlap amount = Math.max(0, channelLeft - channelRight) must be 0.
|
||||
const overlap = Math.max(0, d.channelLeft - d.channelRight)
|
||||
expect(overlap).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Invariant Preservation', () => {
|
||||
it('preserves canonical panel and grid origin constants unchanged', () => {
|
||||
expect(CHAR_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432 })
|
||||
expect(INV_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
||||
expect(INV_GRID_ORIGIN).toEqual({ x: 418, y: 316, cols: 10, rows: 4, cellPx: 29 })
|
||||
expect(SKILL_PANEL_ORIGIN).toEqual({ x: 400, y: 60, width: 320, height: 432 })
|
||||
expect(STASH_PANEL_ORIGIN).toEqual({ x: 80, y: 60, width: 320, height: 432, w: 320, h: 432 })
|
||||
expect(STASH_GRID_ORIGIN).toEqual({ x: 154, y: 142, cols: 6, rows: 8, cellPx: 29 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Central Vision Corridor & Input Interception (isPointInterceptedByHud)', () => {
|
||||
it('returns false at (400, 300) when dual panels are open on widescreen (1920x1080)', () => {
|
||||
const canvas = createMockCanvas(1920, 1080)
|
||||
const hud = new HudManager(canvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
hud.leftPanel = 'char'
|
||||
hud.rightPanel = 'inv'
|
||||
|
||||
// Screen center (400, 300) in logical coordinates is in the open central corridor
|
||||
expect(hud.isPointInterceptedByHud(400, 300)).toBe(false)
|
||||
|
||||
// Test across the entire central corridor: 400 - marginW < x < 400 + marginW
|
||||
const docking = hud.getDockingLayout()
|
||||
expect(docking.isWidescreen).toBe(true)
|
||||
|
||||
const channelMid = (docking.channelLeft + docking.channelRight) / 2
|
||||
expect(channelMid).toBeCloseTo(400, 2)
|
||||
expect(hud.isPointInterceptedByHud(channelMid, 250)).toBe(false)
|
||||
expect(hud.isPointInterceptedByHud(docking.channelLeft + 10, 250)).toBe(false)
|
||||
expect(hud.isPointInterceptedByHud(docking.channelRight - 10, 250)).toBe(false)
|
||||
|
||||
// Points inside left panel are intercepted
|
||||
expect(hud.isPointInterceptedByHud(docking.channelLeft - 20, 250)).toBe(true)
|
||||
expect(hud.isPointInterceptedByHud(docking.leftDockX + 10, 250)).toBe(true)
|
||||
|
||||
// Points inside right panel are intercepted
|
||||
expect(hud.isPointInterceptedByHud(docking.channelRight + 20, 250)).toBe(true)
|
||||
expect(hud.isPointInterceptedByHud(800 + docking.marginW - 10, 250)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true at (400, 300) when dual panels are open in 800x600 (classic D2)', () => {
|
||||
const canvas = createMockCanvas(800, 600)
|
||||
const hud = new HudManager(canvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
hud.leftPanel = 'char'
|
||||
hud.rightPanel = 'inv'
|
||||
|
||||
// In 800x600, panels touch at 400 with 0px corridor, intercepting x=400
|
||||
expect(hud.isPointInterceptedByHud(400, 300)).toBe(true)
|
||||
|
||||
// When panels are closed, x=400 is not intercepted
|
||||
hud.leftPanel = 'none'
|
||||
hud.rightPanel = 'none'
|
||||
expect(hud.isPointInterceptedByHud(400, 300)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HudManager getDockingLayout and syncPublishedState', () => {
|
||||
it('exposes getDockingLayout() matching current canvas dimensions', () => {
|
||||
const canvas = createMockCanvas(1920, 1080)
|
||||
const hud = new HudManager(canvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
const layout = hud.getDockingLayout()
|
||||
expect(layout.isWidescreen).toBe(true)
|
||||
expect(layout.channelWidthCss).toBe(480)
|
||||
expect(layout.marginW).toBeCloseTo(133.333, 2)
|
||||
})
|
||||
|
||||
it('includes docking metrics in published state', () => {
|
||||
const canvas = createMockCanvas(1920, 1080)
|
||||
const hud = new HudManager(canvas, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
|
||||
hud.syncPublishedState()
|
||||
expect(hud.state.docking).toBeDefined()
|
||||
expect(hud.state.docking.isWidescreen).toBe(true)
|
||||
expect(hud.state.docking.channelWidthCss).toBe(480)
|
||||
expect(hud.state.docking.channelLeft).toBeCloseTo(266.667, 2)
|
||||
expect(hud.state.docking.channelRight).toBeCloseTo(533.333, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pointer Event Coordinate Translation in Widescreen', () => {
|
||||
class MockCanvas extends EventTarget {
|
||||
clientWidth = 1920
|
||||
clientHeight = 1080
|
||||
width = 1920
|
||||
height = 1080
|
||||
style = { pointerEvents: 'none' }
|
||||
getBoundingClientRect() {
|
||||
return { left: 0, top: 0, width: this.clientWidth, height: this.clientHeight }
|
||||
}
|
||||
getContext() {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class SyntheticMouseEvent extends Event {
|
||||
readonly clientX: number
|
||||
readonly clientY: number
|
||||
readonly button: number
|
||||
readonly shiftKey: boolean
|
||||
|
||||
constructor(
|
||||
type: string,
|
||||
init: { clientX: number; clientY: number; button?: number; shiftKey?: boolean },
|
||||
) {
|
||||
super(type, { cancelable: true, bubbles: true })
|
||||
this.clientX = init.clientX
|
||||
this.clientY = init.clientY
|
||||
this.button = init.button ?? 0
|
||||
this.shiftKey = init.shiftKey ?? false
|
||||
}
|
||||
}
|
||||
|
||||
let canvas: MockCanvas
|
||||
let hud: HudManager
|
||||
|
||||
beforeEach(() => {
|
||||
canvas = new MockCanvas()
|
||||
hud = new HudManager(canvas as unknown as HTMLCanvasElement, {
|
||||
onToggleAutomap: () => {},
|
||||
onWaypointTeleport: () => {},
|
||||
})
|
||||
hud['bindEvents']()
|
||||
})
|
||||
|
||||
it('translates mousemove coordinates for right dock panels and adjusts item tooltip hover position', () => {
|
||||
hud.toggleRightPanel('inv')
|
||||
const item = createDummyItem('item1', 'rin', 1, 1, '乔丹之石')
|
||||
hud.inventory.gridItems = [{ item, col: 0, row: 0 }]
|
||||
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.marginW // ~133.333
|
||||
|
||||
// Target item in col 0: canonical logical position is INV_GRID_ORIGIN.x + 14.5 = 418 + 14.5 = 432.5
|
||||
// In widescreen, visual position is 432.5 + deltaRight.
|
||||
const visualLogicalX = INV_GRID_ORIGIN.x + 14.5 + deltaRight
|
||||
const clientX = visualLogicalX * docking.uiScale + docking.offsetX
|
||||
const clientY = (INV_GRID_ORIGIN.y + 14.5) * docking.uiScale
|
||||
|
||||
window.dispatchEvent(new SyntheticMouseEvent('mousemove', { clientX, clientY }))
|
||||
|
||||
// Inventory should have detected hover
|
||||
expect(hud.inventory.hoveredItem).not.toBeNull()
|
||||
expect(hud.inventory.hoveredItem?.item.id).toBe('item1')
|
||||
// hoveredItem.x must be translated by +deltaRight so the tooltip renders over visual position
|
||||
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(visualLogicalX, 1)
|
||||
})
|
||||
|
||||
it('translates mousemove coordinates for left dock stash items', () => {
|
||||
hud.leftPanel = 'stash'
|
||||
const item = createDummyItem('stash-item1', 'rin', 1, 1, '储物箱戒指')
|
||||
hud.worldPanels.stashItems = [{ item, col: 0, row: 0 }]
|
||||
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW // ~ -133.333
|
||||
|
||||
// Stash col 0 cell center: STASH_GRID_ORIGIN.x + 14.5 = 154 + 14.5 = 168.5
|
||||
// In widescreen, visual position is 168.5 + deltaLeft = 168.5 - 133.333 = 35.167
|
||||
const visualLogicalX = STASH_GRID_ORIGIN.x + 14.5 + deltaLeft
|
||||
const clientX = visualLogicalX * docking.uiScale + docking.offsetX
|
||||
const clientY = (STASH_GRID_ORIGIN.y + 14.5) * docking.uiScale
|
||||
|
||||
window.dispatchEvent(new SyntheticMouseEvent('mousemove', { clientX, clientY }))
|
||||
|
||||
expect(hud.inventory.hoveredItem).not.toBeNull()
|
||||
expect(hud.inventory.hoveredItem?.item.id).toBe('stash-item1')
|
||||
expect(hud.inventory.hoveredItem?.x).toBeCloseTo(visualLogicalX, 1)
|
||||
})
|
||||
|
||||
it('executes quick transfer from Inventory to Stash via Shift+Click in widescreen', () => {
|
||||
hud.leftPanel = 'stash'
|
||||
hud.rightPanel = 'inv'
|
||||
|
||||
const item = createDummyItem('quick1', 'rin', 1, 1, '传送戒指')
|
||||
hud.inventory.gridItems = [{ item, col: 0, row: 0 }]
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(0)
|
||||
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
// Visual position of item in Inventory col 0, row 0
|
||||
const visualLogicalX = INV_GRID_ORIGIN.x + 10 + deltaRight
|
||||
const visualLogicalY = INV_GRID_ORIGIN.y + 10
|
||||
const clientX = visualLogicalX * docking.uiScale + docking.offsetX
|
||||
const clientY = visualLogicalY * docking.uiScale + docking.offsetY
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
}),
|
||||
)
|
||||
|
||||
// Quick transfer should succeed: item moved from inventory to stash
|
||||
expect(hud.inventory.gridItems).toHaveLength(0)
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(1)
|
||||
expect(hud.worldPanels.stashItems[0]!.item.id).toBe('quick1')
|
||||
})
|
||||
|
||||
it('executes quick transfer from Stash to Inventory via Shift+Click in widescreen', () => {
|
||||
hud.leftPanel = 'stash'
|
||||
hud.rightPanel = 'inv'
|
||||
|
||||
const item = createDummyItem('stash-quick1', 'rin', 1, 1, '马拉的万花筒')
|
||||
hud.worldPanels.stashItems = [{ item, col: 0, row: 0 }]
|
||||
hud.inventory.gridItems = []
|
||||
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
|
||||
// Visual position of item in Stash col 0, row 0
|
||||
const visualLogicalX = STASH_GRID_ORIGIN.x + 10 + deltaLeft
|
||||
const visualLogicalY = STASH_GRID_ORIGIN.y + 10
|
||||
const clientX = visualLogicalX * docking.uiScale + docking.offsetX
|
||||
const clientY = visualLogicalY * docking.uiScale + docking.offsetY
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX,
|
||||
clientY,
|
||||
button: 0,
|
||||
shiftKey: true,
|
||||
}),
|
||||
)
|
||||
|
||||
// Quick transfer should succeed: item moved from stash to inventory
|
||||
expect(hud.worldPanels.stashItems).toHaveLength(0)
|
||||
expect(hud.inventory.gridItems).toHaveLength(1)
|
||||
expect(hud.inventory.gridItems[0]!.item.id).toBe('stash-quick1')
|
||||
})
|
||||
|
||||
it('closes left and right panels via close buttons at widescreen coordinates', () => {
|
||||
hud.leftPanel = 'char'
|
||||
hud.rightPanel = 'inv'
|
||||
|
||||
const docking = hud.getDockingLayout()
|
||||
const deltaLeft = -docking.marginW
|
||||
const deltaRight = docking.marginW
|
||||
|
||||
// Left panel close button: CHAR_CLOSE_BTN_BOUNDS: x = 208, y = 448
|
||||
const visualLeftCloseX = 208 + deltaLeft
|
||||
const visualLeftCloseY = 448
|
||||
const clientLeftX = visualLeftCloseX * docking.uiScale + docking.offsetX
|
||||
const clientLeftY = visualLeftCloseY * docking.uiScale + docking.offsetY
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientLeftX,
|
||||
clientY: clientLeftY,
|
||||
button: 0,
|
||||
shiftKey: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.leftPanel).toBe('none')
|
||||
|
||||
// Right panel close button: INV_CLOSE_BTN_BOUNDS: x = 418, y = 445
|
||||
const visualRightCloseX = 418 + deltaRight
|
||||
const visualRightCloseY = 445
|
||||
const clientRightX = visualRightCloseX * docking.uiScale + docking.offsetX
|
||||
const clientRightY = visualRightCloseY * docking.uiScale + docking.offsetY
|
||||
|
||||
canvas.dispatchEvent(
|
||||
new SyntheticMouseEvent('mousedown', {
|
||||
clientX: clientRightX,
|
||||
clientY: clientRightY,
|
||||
button: 0,
|
||||
shiftKey: false,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(hud.rightPanel).toBe('none')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
/**
|
||||
* tests/world-panels.test.ts
|
||||
*
|
||||
* Unit tests for Diablo II 1.13c Waypoint and World Panels UI Parity (Issue #378).
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { WorldPanelsHud, ACT_WAYPOINTS } from '../src/ui/world-panels.ts'
|
||||
import { D2FontRenderer } from '../src/ui/font.ts'
|
||||
|
||||
function getPngDimensions(filePath: string): { width: number; height: number } {
|
||||
const buf = readFileSync(filePath)
|
||||
if (buf.toString('ascii', 1, 4) !== 'PNG') throw new Error('Not a PNG file')
|
||||
const width = buf.readUInt32BE(16)
|
||||
const height = buf.readUInt32BE(20)
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
describe('Issue #378: Diablo II 1.13c Waypoint Panel Tabs & Icons', () => {
|
||||
it('verifies waypoint-tabs.png, waypoint-icons.png, and quest-tabs.png asset dimensions', () => {
|
||||
const waypointTabsPath = join(process.cwd(), 'public/ui/waypoint-tabs.png')
|
||||
const waypointIconsPath = join(process.cwd(), 'public/ui/waypoint-icons.png')
|
||||
const questTabsPath = join(process.cwd(), 'public/ui/quest-tabs.png')
|
||||
|
||||
expect(existsSync(waypointTabsPath)).toBe(true)
|
||||
expect(existsSync(waypointIconsPath)).toBe(true)
|
||||
expect(existsSync(questTabsPath)).toBe(true)
|
||||
|
||||
// 10 frames of 63x31 = 630x31
|
||||
const wpTabsDim = getPngDimensions(waypointTabsPath)
|
||||
expect(wpTabsDim.width).toBe(630)
|
||||
expect(wpTabsDim.height).toBe(31)
|
||||
|
||||
// 5 frames of 30x30 = 150x30
|
||||
const wpIconsDim = getPngDimensions(waypointIconsPath)
|
||||
expect(wpIconsDim.width).toBe(150)
|
||||
expect(wpIconsDim.height).toBe(30)
|
||||
|
||||
// 10 frames of 63x31 = 630x31
|
||||
const qTabsDim = getPngDimensions(questTabsPath)
|
||||
expect(qTabsDim.width).toBe(630)
|
||||
expect(qTabsDim.height).toBe(31)
|
||||
})
|
||||
|
||||
it('renders waypoint tabs and icons using drawImage slices when assets are present', () => {
|
||||
const hud = new WorldPanelsHud()
|
||||
const font = new D2FontRenderer()
|
||||
|
||||
const drawImageCalls: any[] = []
|
||||
const ctx = {
|
||||
drawImage: vi.fn((...args: any[]) => {
|
||||
drawImageCalls.push(args)
|
||||
}),
|
||||
fillRect: vi.fn(),
|
||||
strokeRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
save: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 60 })),
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
globalAlpha: 1,
|
||||
} as unknown as CanvasRenderingContext2D
|
||||
|
||||
const mockTabsImg = {
|
||||
complete: true,
|
||||
naturalWidth: 630,
|
||||
naturalHeight: 31,
|
||||
} as unknown as HTMLImageElement
|
||||
|
||||
const mockIconsImg = {
|
||||
complete: true,
|
||||
naturalWidth: 150,
|
||||
naturalHeight: 30,
|
||||
} as unknown as HTMLImageElement
|
||||
|
||||
hud.selectedActTab = 2
|
||||
|
||||
hud.drawLeftDockPanel(
|
||||
ctx,
|
||||
'waypoint',
|
||||
{
|
||||
questBgImg: null,
|
||||
waypointBgImg: null,
|
||||
borderLeftImg: null,
|
||||
stashBgImg: null,
|
||||
cubeBgImg: null,
|
||||
vendorBgImg: null,
|
||||
buySellBtnImg: null,
|
||||
waypointTabsImg: mockTabsImg,
|
||||
waypointIconsImg: mockIconsImg,
|
||||
},
|
||||
font,
|
||||
)
|
||||
|
||||
// Verify 5 tab draws from mockTabsImg:
|
||||
// Act 1 (inactive, frame 0 -> sx 0)
|
||||
// Act 2 (active, frame 6 -> sx 6 * 63 = 378)
|
||||
// Act 3 (inactive, frame 2 -> sx 2 * 63 = 126)
|
||||
// Act 4 (inactive, frame 3 -> sx 3 * 63 = 189)
|
||||
// Act 5 (inactive, frame 4 -> sx 4 * 63 = 252)
|
||||
const tabDraws = drawImageCalls.filter((c) => c[0] === mockTabsImg)
|
||||
expect(tabDraws.length).toBe(5)
|
||||
expect(tabDraws[0][1]).toBe(0) // Act 1 sx
|
||||
expect(tabDraws[1][1]).toBe(6 * 63) // Act 2 (active) sx = 378
|
||||
expect(tabDraws[2][1]).toBe(2 * 63) // Act 3 sx = 126
|
||||
|
||||
// Verify waypoint icons drawn from mockIconsImg for Act 2 (frame index 1 -> sx 30)
|
||||
const iconDraws = drawImageCalls.filter((c) => c[0] === mockIconsImg)
|
||||
const act2WpCount = (ACT_WAYPOINTS[2] ?? []).length
|
||||
expect(iconDraws.length).toBe(act2WpCount)
|
||||
for (const call of iconDraws) {
|
||||
expect(call[1]).toBe(30) // sx = 30 for Act 2
|
||||
expect(call[3]).toBe(30) // sw = 30
|
||||
expect(call[4]).toBe(30) // sh = 30
|
||||
}
|
||||
})
|
||||
|
||||
it('updates hoveredWaypointIdx on mouse move and highlights hovered waypoint', () => {
|
||||
const hud = new WorldPanelsHud()
|
||||
hud.selectedActTab = 1
|
||||
const ox = 80
|
||||
const oy = 60
|
||||
|
||||
// Hover over first waypoint (i = 0): wy = oy + 62 = 122 .. 153
|
||||
hud.handleMouseMove(ox + 50, oy + 70)
|
||||
expect(hud.hoveredWaypointIdx).toBe(0)
|
||||
|
||||
// Move away
|
||||
hud.handleMouseMove(ox + 50, oy + 500)
|
||||
expect(hud.hoveredWaypointIdx).toBeNull()
|
||||
})
|
||||
|
||||
it('handles clicking waypoint tabs and locked vs unlocked waypoints', () => {
|
||||
const hud = new WorldPanelsHud()
|
||||
const onWaypointTeleport = vi.fn()
|
||||
const onClose = vi.fn()
|
||||
|
||||
const ox = 80
|
||||
const oy = 60
|
||||
|
||||
// 1. Click Act 3 tab (tab 3: logicalX approx ox + 8 + 2 * 61 + 10 = ox + 140, logicalY oy + 20)
|
||||
const tabClickHandled = hud.handleLeftDockClick('waypoint', ox + 140, oy + 20, {
|
||||
onWaypointTeleport,
|
||||
onClose,
|
||||
})
|
||||
expect(tabClickHandled).toBe(true)
|
||||
expect(hud.selectedActTab).toBe(3)
|
||||
|
||||
// 2. In Act 3:
|
||||
// First waypoint (Kurast Docks) is unlocked
|
||||
const wps = ACT_WAYPOINTS[3]!
|
||||
expect(wps[0]!.unlocked).toBe(true)
|
||||
|
||||
// Temporarily mark index 1 as locked
|
||||
;(wps[1] as any).unlocked = false
|
||||
|
||||
try {
|
||||
// Click locked waypoint: should NOT trigger teleport or close
|
||||
const lockedWy = oy + 62 + 1 * 36 + 10
|
||||
const lockedClick = hud.handleLeftDockClick('waypoint', ox + 50, lockedWy, {
|
||||
onWaypointTeleport,
|
||||
onClose,
|
||||
})
|
||||
expect(lockedClick).toBe(true)
|
||||
expect(onWaypointTeleport).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
|
||||
// Click unlocked waypoint (index 0): should trigger teleport and close
|
||||
const unlockedWy = oy + 62 + 0 * 36 + 10
|
||||
const unlockedClick = hud.handleLeftDockClick('waypoint', ox + 50, unlockedWy, {
|
||||
onWaypointTeleport,
|
||||
onClose,
|
||||
})
|
||||
expect(unlockedClick).toBe(true)
|
||||
expect(onWaypointTeleport).toHaveBeenCalledWith(3, wps[0]!.slug, wps[0]!.levelId)
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
} finally {
|
||||
;(wps[1] as any).unlocked = true
|
||||
}
|
||||
})
|
||||
})
|
||||