Compare commits
No commits in common. "7d7925ab2b72e6e8795017d5ed9ca6992a077c5f" and "2733ba625b46d4273811bab6f021cea9c9f8a626" have entirely different histories.
7d7925ab2b
...
2733ba625b
|
|
@ -53,11 +53,6 @@
|
||||||
- 所有暗金怪(`SuperUniques`)必须自动推导并解析其随从(Minions)所依赖的全部 DCC/DC6 动画与材质资源,一并纳入离线打包构建。
|
- 所有暗金怪(`SuperUniques`)必须自动推导并解析其随从(Minions)所依赖的全部 DCC/DC6 动画与材质资源,一并纳入离线打包构建。
|
||||||
2. **原版文本数据转录不得篡改或简化**:
|
2. **原版文本数据转录不得篡改或简化**:
|
||||||
- `SuperUniques.txt`、`MonStats.txt` 等元数据必须严格保留原版 Class 标识(如 `scarab2`, `zombie5`, `fetishshaman4`, `nihlathakboss` 等具体变体),禁止粗暴截断变体数字后缀或自编别名。
|
- `SuperUniques.txt`、`MonStats.txt` 等元数据必须严格保留原版 Class 标识(如 `scarab2`, `zombie5`, `fetishshaman4`, `nihlathakboss` 等具体变体),禁止粗暴截断变体数字后缀或自编别名。
|
||||||
3. **技能与投射物美术资源必须采用原版资源并离线烘焙 (Missile & Spell FX Parity)**:
|
|
||||||
- 严禁长期使用由代码绘制的方块、纯几何色块或纯程序化简易图形作为技能投射物/特效替代品。
|
|
||||||
- 所有技能投射物(Missiles)、次生爆炸(Explosions)、状态光环与法术特效,必须严格以 1.13c 原版 MPQ(`d2data.mpq` / `d2exp.mpq` / `Patch_D2.mpq`)中由 `Missiles.txt` 的 `CelFile` 字段所引用的原版 DCC / DC6 动画文件为准(例如 `Firebolt.dcc`, `Fireball.dcc`, `FireArrowExplode2.dcc` 等)。
|
|
||||||
- 必须通过离线解包烘焙脚本(如 `scripts/pack-missiles.ts`)结合章节调色板(`ACT1/pal.pl2`),将原版 DCC/DC6 的全部朝向(如 16 方向)与全帧序列预打包烘焙为 Web 原生 Sprite Atlas(图集)及对应元数据,供前端 WebGL 运行时直接高效索引与渲染。
|
|
||||||
- 音效接入解耦:现阶段音效系统(`Sounds.txt` / WAV 音频资源)允许暂缓接入,但视觉动画必须具备 100% 像素级原版 DCC 贴图资产与帧率对齐。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@
|
||||||
"pack:ui": "tsx scripts/pack-ui.ts",
|
"pack:ui": "tsx scripts/pack-ui.ts",
|
||||||
"pack:animdata": "tsx scripts/pack-animdata.ts",
|
"pack:animdata": "tsx scripts/pack-animdata.ts",
|
||||||
"pack:entities": "tsx scripts/pack-entity-assets.ts",
|
"pack:entities": "tsx scripts/pack-entity-assets.ts",
|
||||||
"pack:missiles": "tsx scripts/pack-missiles.ts",
|
|
||||||
"verify:entity-packs": "tsx scripts/verify-entity-packs.ts",
|
"verify:entity-packs": "tsx scripts/verify-entity-packs.ts",
|
||||||
"verify:packs": "tsx scripts/verify-packs.ts",
|
"verify:packs": "tsx scripts/verify-packs.ts",
|
||||||
"verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts",
|
"verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts",
|
||||||
|
|
|
||||||
|
|
@ -1,683 +0,0 @@
|
||||||
{
|
|
||||||
"name": "fireball",
|
|
||||||
"celFile": "Fireball",
|
|
||||||
"width": 1024,
|
|
||||||
"height": 335,
|
|
||||||
"directions": 16,
|
|
||||||
"framesPerDirection": 5,
|
|
||||||
"animSpeed": 16,
|
|
||||||
"groups": [
|
|
||||||
[
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
75,
|
|
||||||
48,
|
|
||||||
-19,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
76,
|
|
||||||
0,
|
|
||||||
75,
|
|
||||||
48,
|
|
||||||
-19,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
152,
|
|
||||||
0,
|
|
||||||
75,
|
|
||||||
48,
|
|
||||||
-19,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
228,
|
|
||||||
0,
|
|
||||||
75,
|
|
||||||
48,
|
|
||||||
-19,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
304,
|
|
||||||
0,
|
|
||||||
75,
|
|
||||||
48,
|
|
||||||
-19,
|
|
||||||
-74
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
380,
|
|
||||||
0,
|
|
||||||
79,
|
|
||||||
52,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
460,
|
|
||||||
0,
|
|
||||||
79,
|
|
||||||
52,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
540,
|
|
||||||
0,
|
|
||||||
79,
|
|
||||||
52,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
620,
|
|
||||||
0,
|
|
||||||
79,
|
|
||||||
52,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
700,
|
|
||||||
0,
|
|
||||||
79,
|
|
||||||
52,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
780,
|
|
||||||
0,
|
|
||||||
81,
|
|
||||||
52,
|
|
||||||
-63,
|
|
||||||
-61
|
|
||||||
],
|
|
||||||
[
|
|
||||||
862,
|
|
||||||
0,
|
|
||||||
81,
|
|
||||||
52,
|
|
||||||
-63,
|
|
||||||
-61
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
53,
|
|
||||||
81,
|
|
||||||
52,
|
|
||||||
-63,
|
|
||||||
-61
|
|
||||||
],
|
|
||||||
[
|
|
||||||
82,
|
|
||||||
53,
|
|
||||||
81,
|
|
||||||
52,
|
|
||||||
-63,
|
|
||||||
-61
|
|
||||||
],
|
|
||||||
[
|
|
||||||
164,
|
|
||||||
53,
|
|
||||||
81,
|
|
||||||
52,
|
|
||||||
-63,
|
|
||||||
-61
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
246,
|
|
||||||
53,
|
|
||||||
74,
|
|
||||||
48,
|
|
||||||
-55,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
321,
|
|
||||||
53,
|
|
||||||
74,
|
|
||||||
48,
|
|
||||||
-55,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
396,
|
|
||||||
53,
|
|
||||||
74,
|
|
||||||
48,
|
|
||||||
-55,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
471,
|
|
||||||
53,
|
|
||||||
74,
|
|
||||||
48,
|
|
||||||
-55,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
546,
|
|
||||||
53,
|
|
||||||
74,
|
|
||||||
48,
|
|
||||||
-55,
|
|
||||||
-74
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
621,
|
|
||||||
53,
|
|
||||||
34,
|
|
||||||
55,
|
|
||||||
-17,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
656,
|
|
||||||
53,
|
|
||||||
34,
|
|
||||||
55,
|
|
||||||
-17,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
691,
|
|
||||||
53,
|
|
||||||
34,
|
|
||||||
55,
|
|
||||||
-17,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
726,
|
|
||||||
53,
|
|
||||||
34,
|
|
||||||
55,
|
|
||||||
-17,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
761,
|
|
||||||
53,
|
|
||||||
34,
|
|
||||||
55,
|
|
||||||
-17,
|
|
||||||
-81
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
796,
|
|
||||||
53,
|
|
||||||
94,
|
|
||||||
40,
|
|
||||||
-20,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
891,
|
|
||||||
53,
|
|
||||||
94,
|
|
||||||
40,
|
|
||||||
-20,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
109,
|
|
||||||
94,
|
|
||||||
40,
|
|
||||||
-20,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
95,
|
|
||||||
109,
|
|
||||||
94,
|
|
||||||
40,
|
|
||||||
-20,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
190,
|
|
||||||
109,
|
|
||||||
94,
|
|
||||||
40,
|
|
||||||
-20,
|
|
||||||
-65
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
285,
|
|
||||||
109,
|
|
||||||
40,
|
|
||||||
61,
|
|
||||||
-20,
|
|
||||||
-63
|
|
||||||
],
|
|
||||||
[
|
|
||||||
326,
|
|
||||||
109,
|
|
||||||
40,
|
|
||||||
61,
|
|
||||||
-20,
|
|
||||||
-63
|
|
||||||
],
|
|
||||||
[
|
|
||||||
367,
|
|
||||||
109,
|
|
||||||
40,
|
|
||||||
61,
|
|
||||||
-20,
|
|
||||||
-63
|
|
||||||
],
|
|
||||||
[
|
|
||||||
408,
|
|
||||||
109,
|
|
||||||
40,
|
|
||||||
61,
|
|
||||||
-20,
|
|
||||||
-63
|
|
||||||
],
|
|
||||||
[
|
|
||||||
449,
|
|
||||||
109,
|
|
||||||
40,
|
|
||||||
61,
|
|
||||||
-20,
|
|
||||||
-63
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
490,
|
|
||||||
109,
|
|
||||||
91,
|
|
||||||
37,
|
|
||||||
-72,
|
|
||||||
-63
|
|
||||||
],
|
|
||||||
[
|
|
||||||
582,
|
|
||||||
109,
|
|
||||||
91,
|
|
||||||
37,
|
|
||||||
-72,
|
|
||||||
-63
|
|
||||||
],
|
|
||||||
[
|
|
||||||
674,
|
|
||||||
109,
|
|
||||||
91,
|
|
||||||
37,
|
|
||||||
-72,
|
|
||||||
-63
|
|
||||||
],
|
|
||||||
[
|
|
||||||
766,
|
|
||||||
109,
|
|
||||||
91,
|
|
||||||
37,
|
|
||||||
-72,
|
|
||||||
-63
|
|
||||||
],
|
|
||||||
[
|
|
||||||
858,
|
|
||||||
109,
|
|
||||||
91,
|
|
||||||
37,
|
|
||||||
-72,
|
|
||||||
-63
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
950,
|
|
||||||
109,
|
|
||||||
53,
|
|
||||||
53,
|
|
||||||
-17,
|
|
||||||
-79
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
171,
|
|
||||||
53,
|
|
||||||
53,
|
|
||||||
-17,
|
|
||||||
-79
|
|
||||||
],
|
|
||||||
[
|
|
||||||
54,
|
|
||||||
171,
|
|
||||||
53,
|
|
||||||
53,
|
|
||||||
-17,
|
|
||||||
-79
|
|
||||||
],
|
|
||||||
[
|
|
||||||
108,
|
|
||||||
171,
|
|
||||||
53,
|
|
||||||
53,
|
|
||||||
-17,
|
|
||||||
-79
|
|
||||||
],
|
|
||||||
[
|
|
||||||
162,
|
|
||||||
171,
|
|
||||||
53,
|
|
||||||
53,
|
|
||||||
-17,
|
|
||||||
-79
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
216,
|
|
||||||
171,
|
|
||||||
88,
|
|
||||||
41,
|
|
||||||
-19,
|
|
||||||
-68
|
|
||||||
],
|
|
||||||
[
|
|
||||||
305,
|
|
||||||
171,
|
|
||||||
88,
|
|
||||||
41,
|
|
||||||
-19,
|
|
||||||
-68
|
|
||||||
],
|
|
||||||
[
|
|
||||||
394,
|
|
||||||
171,
|
|
||||||
88,
|
|
||||||
41,
|
|
||||||
-19,
|
|
||||||
-68
|
|
||||||
],
|
|
||||||
[
|
|
||||||
483,
|
|
||||||
171,
|
|
||||||
88,
|
|
||||||
41,
|
|
||||||
-19,
|
|
||||||
-68
|
|
||||||
],
|
|
||||||
[
|
|
||||||
572,
|
|
||||||
171,
|
|
||||||
88,
|
|
||||||
41,
|
|
||||||
-19,
|
|
||||||
-68
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
661,
|
|
||||||
171,
|
|
||||||
89,
|
|
||||||
45,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
751,
|
|
||||||
171,
|
|
||||||
89,
|
|
||||||
45,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
841,
|
|
||||||
171,
|
|
||||||
89,
|
|
||||||
45,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
931,
|
|
||||||
171,
|
|
||||||
89,
|
|
||||||
45,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
225,
|
|
||||||
89,
|
|
||||||
45,
|
|
||||||
-19,
|
|
||||||
-62
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
90,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
57,
|
|
||||||
-17,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
150,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
57,
|
|
||||||
-17,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
210,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
57,
|
|
||||||
-17,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
270,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
57,
|
|
||||||
-17,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
330,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
57,
|
|
||||||
-17,
|
|
||||||
-62
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
390,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
-42,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
450,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
-42,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
510,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
-42,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
570,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
-42,
|
|
||||||
-62
|
|
||||||
],
|
|
||||||
[
|
|
||||||
630,
|
|
||||||
225,
|
|
||||||
59,
|
|
||||||
59,
|
|
||||||
-42,
|
|
||||||
-62
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
690,
|
|
||||||
225,
|
|
||||||
92,
|
|
||||||
43,
|
|
||||||
-72,
|
|
||||||
-61
|
|
||||||
],
|
|
||||||
[
|
|
||||||
783,
|
|
||||||
225,
|
|
||||||
92,
|
|
||||||
43,
|
|
||||||
-72,
|
|
||||||
-61
|
|
||||||
],
|
|
||||||
[
|
|
||||||
876,
|
|
||||||
225,
|
|
||||||
92,
|
|
||||||
43,
|
|
||||||
-72,
|
|
||||||
-61
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
285,
|
|
||||||
92,
|
|
||||||
43,
|
|
||||||
-72,
|
|
||||||
-61
|
|
||||||
],
|
|
||||||
[
|
|
||||||
93,
|
|
||||||
285,
|
|
||||||
92,
|
|
||||||
43,
|
|
||||||
-72,
|
|
||||||
-61
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
186,
|
|
||||||
285,
|
|
||||||
85,
|
|
||||||
43,
|
|
||||||
-65,
|
|
||||||
-69
|
|
||||||
],
|
|
||||||
[
|
|
||||||
272,
|
|
||||||
285,
|
|
||||||
85,
|
|
||||||
43,
|
|
||||||
-65,
|
|
||||||
-69
|
|
||||||
],
|
|
||||||
[
|
|
||||||
358,
|
|
||||||
285,
|
|
||||||
85,
|
|
||||||
43,
|
|
||||||
-65,
|
|
||||||
-69
|
|
||||||
],
|
|
||||||
[
|
|
||||||
444,
|
|
||||||
285,
|
|
||||||
85,
|
|
||||||
43,
|
|
||||||
-65,
|
|
||||||
-69
|
|
||||||
],
|
|
||||||
[
|
|
||||||
530,
|
|
||||||
285,
|
|
||||||
85,
|
|
||||||
43,
|
|
||||||
-65,
|
|
||||||
-69
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
616,
|
|
||||||
285,
|
|
||||||
53,
|
|
||||||
50,
|
|
||||||
-35,
|
|
||||||
-77
|
|
||||||
],
|
|
||||||
[
|
|
||||||
670,
|
|
||||||
285,
|
|
||||||
53,
|
|
||||||
50,
|
|
||||||
-35,
|
|
||||||
-77
|
|
||||||
],
|
|
||||||
[
|
|
||||||
724,
|
|
||||||
285,
|
|
||||||
53,
|
|
||||||
50,
|
|
||||||
-35,
|
|
||||||
-77
|
|
||||||
],
|
|
||||||
[
|
|
||||||
778,
|
|
||||||
285,
|
|
||||||
53,
|
|
||||||
50,
|
|
||||||
-35,
|
|
||||||
-77
|
|
||||||
],
|
|
||||||
[
|
|
||||||
832,
|
|
||||||
285,
|
|
||||||
53,
|
|
||||||
50,
|
|
||||||
-35,
|
|
||||||
-77
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB |
|
|
@ -1,683 +0,0 @@
|
||||||
{
|
|
||||||
"name": "firebolt",
|
|
||||||
"celFile": "Firebolt",
|
|
||||||
"width": 512,
|
|
||||||
"height": 439,
|
|
||||||
"directions": 16,
|
|
||||||
"framesPerDirection": 5,
|
|
||||||
"animSpeed": 16,
|
|
||||||
"groups": [
|
|
||||||
[
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
55,
|
|
||||||
39,
|
|
||||||
-12,
|
|
||||||
-71
|
|
||||||
],
|
|
||||||
[
|
|
||||||
56,
|
|
||||||
0,
|
|
||||||
55,
|
|
||||||
39,
|
|
||||||
-12,
|
|
||||||
-71
|
|
||||||
],
|
|
||||||
[
|
|
||||||
112,
|
|
||||||
0,
|
|
||||||
55,
|
|
||||||
39,
|
|
||||||
-12,
|
|
||||||
-71
|
|
||||||
],
|
|
||||||
[
|
|
||||||
168,
|
|
||||||
0,
|
|
||||||
55,
|
|
||||||
39,
|
|
||||||
-12,
|
|
||||||
-71
|
|
||||||
],
|
|
||||||
[
|
|
||||||
224,
|
|
||||||
0,
|
|
||||||
55,
|
|
||||||
39,
|
|
||||||
-12,
|
|
||||||
-71
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
280,
|
|
||||||
0,
|
|
||||||
63,
|
|
||||||
41,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
344,
|
|
||||||
0,
|
|
||||||
63,
|
|
||||||
41,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
408,
|
|
||||||
0,
|
|
||||||
63,
|
|
||||||
41,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
42,
|
|
||||||
63,
|
|
||||||
41,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
64,
|
|
||||||
42,
|
|
||||||
63,
|
|
||||||
41,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
128,
|
|
||||||
42,
|
|
||||||
62,
|
|
||||||
41,
|
|
||||||
-49,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
191,
|
|
||||||
42,
|
|
||||||
62,
|
|
||||||
41,
|
|
||||||
-49,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
254,
|
|
||||||
42,
|
|
||||||
62,
|
|
||||||
41,
|
|
||||||
-49,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
317,
|
|
||||||
42,
|
|
||||||
62,
|
|
||||||
41,
|
|
||||||
-49,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
380,
|
|
||||||
42,
|
|
||||||
62,
|
|
||||||
41,
|
|
||||||
-49,
|
|
||||||
-56
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
443,
|
|
||||||
42,
|
|
||||||
56,
|
|
||||||
40,
|
|
||||||
-44,
|
|
||||||
-71
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
84,
|
|
||||||
56,
|
|
||||||
40,
|
|
||||||
-44,
|
|
||||||
-71
|
|
||||||
],
|
|
||||||
[
|
|
||||||
57,
|
|
||||||
84,
|
|
||||||
56,
|
|
||||||
40,
|
|
||||||
-44,
|
|
||||||
-71
|
|
||||||
],
|
|
||||||
[
|
|
||||||
114,
|
|
||||||
84,
|
|
||||||
56,
|
|
||||||
40,
|
|
||||||
-44,
|
|
||||||
-71
|
|
||||||
],
|
|
||||||
[
|
|
||||||
171,
|
|
||||||
84,
|
|
||||||
56,
|
|
||||||
40,
|
|
||||||
-44,
|
|
||||||
-71
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
228,
|
|
||||||
84,
|
|
||||||
30,
|
|
||||||
45,
|
|
||||||
-15,
|
|
||||||
-76
|
|
||||||
],
|
|
||||||
[
|
|
||||||
259,
|
|
||||||
84,
|
|
||||||
30,
|
|
||||||
45,
|
|
||||||
-15,
|
|
||||||
-76
|
|
||||||
],
|
|
||||||
[
|
|
||||||
290,
|
|
||||||
84,
|
|
||||||
30,
|
|
||||||
45,
|
|
||||||
-15,
|
|
||||||
-76
|
|
||||||
],
|
|
||||||
[
|
|
||||||
321,
|
|
||||||
84,
|
|
||||||
30,
|
|
||||||
45,
|
|
||||||
-15,
|
|
||||||
-76
|
|
||||||
],
|
|
||||||
[
|
|
||||||
352,
|
|
||||||
84,
|
|
||||||
30,
|
|
||||||
45,
|
|
||||||
-15,
|
|
||||||
-76
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
383,
|
|
||||||
84,
|
|
||||||
71,
|
|
||||||
30,
|
|
||||||
-13,
|
|
||||||
-58
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
130,
|
|
||||||
71,
|
|
||||||
30,
|
|
||||||
-13,
|
|
||||||
-58
|
|
||||||
],
|
|
||||||
[
|
|
||||||
72,
|
|
||||||
130,
|
|
||||||
71,
|
|
||||||
30,
|
|
||||||
-13,
|
|
||||||
-58
|
|
||||||
],
|
|
||||||
[
|
|
||||||
144,
|
|
||||||
130,
|
|
||||||
71,
|
|
||||||
30,
|
|
||||||
-13,
|
|
||||||
-58
|
|
||||||
],
|
|
||||||
[
|
|
||||||
216,
|
|
||||||
130,
|
|
||||||
71,
|
|
||||||
30,
|
|
||||||
-13,
|
|
||||||
-58
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
288,
|
|
||||||
130,
|
|
||||||
32,
|
|
||||||
46,
|
|
||||||
-15,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
321,
|
|
||||||
130,
|
|
||||||
32,
|
|
||||||
46,
|
|
||||||
-15,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
354,
|
|
||||||
130,
|
|
||||||
32,
|
|
||||||
46,
|
|
||||||
-15,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
387,
|
|
||||||
130,
|
|
||||||
32,
|
|
||||||
46,
|
|
||||||
-15,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
420,
|
|
||||||
130,
|
|
||||||
32,
|
|
||||||
46,
|
|
||||||
-15,
|
|
||||||
-56
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
177,
|
|
||||||
71,
|
|
||||||
28,
|
|
||||||
-57,
|
|
||||||
-57
|
|
||||||
],
|
|
||||||
[
|
|
||||||
72,
|
|
||||||
177,
|
|
||||||
71,
|
|
||||||
28,
|
|
||||||
-57,
|
|
||||||
-57
|
|
||||||
],
|
|
||||||
[
|
|
||||||
144,
|
|
||||||
177,
|
|
||||||
71,
|
|
||||||
28,
|
|
||||||
-57,
|
|
||||||
-57
|
|
||||||
],
|
|
||||||
[
|
|
||||||
216,
|
|
||||||
177,
|
|
||||||
71,
|
|
||||||
28,
|
|
||||||
-57,
|
|
||||||
-57
|
|
||||||
],
|
|
||||||
[
|
|
||||||
288,
|
|
||||||
177,
|
|
||||||
71,
|
|
||||||
28,
|
|
||||||
-57,
|
|
||||||
-57
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
360,
|
|
||||||
177,
|
|
||||||
42,
|
|
||||||
44,
|
|
||||||
-12,
|
|
||||||
-75
|
|
||||||
],
|
|
||||||
[
|
|
||||||
403,
|
|
||||||
177,
|
|
||||||
42,
|
|
||||||
44,
|
|
||||||
-12,
|
|
||||||
-75
|
|
||||||
],
|
|
||||||
[
|
|
||||||
446,
|
|
||||||
177,
|
|
||||||
42,
|
|
||||||
44,
|
|
||||||
-12,
|
|
||||||
-75
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
222,
|
|
||||||
42,
|
|
||||||
44,
|
|
||||||
-12,
|
|
||||||
-75
|
|
||||||
],
|
|
||||||
[
|
|
||||||
43,
|
|
||||||
222,
|
|
||||||
42,
|
|
||||||
44,
|
|
||||||
-12,
|
|
||||||
-75
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
86,
|
|
||||||
222,
|
|
||||||
68,
|
|
||||||
33,
|
|
||||||
-14,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
155,
|
|
||||||
222,
|
|
||||||
68,
|
|
||||||
33,
|
|
||||||
-14,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
224,
|
|
||||||
222,
|
|
||||||
68,
|
|
||||||
33,
|
|
||||||
-14,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
293,
|
|
||||||
222,
|
|
||||||
68,
|
|
||||||
33,
|
|
||||||
-14,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
362,
|
|
||||||
222,
|
|
||||||
68,
|
|
||||||
33,
|
|
||||||
-14,
|
|
||||||
-65
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
431,
|
|
||||||
222,
|
|
||||||
69,
|
|
||||||
32,
|
|
||||||
-12,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
267,
|
|
||||||
69,
|
|
||||||
32,
|
|
||||||
-12,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
70,
|
|
||||||
267,
|
|
||||||
69,
|
|
||||||
32,
|
|
||||||
-12,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
140,
|
|
||||||
267,
|
|
||||||
69,
|
|
||||||
32,
|
|
||||||
-12,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
210,
|
|
||||||
267,
|
|
||||||
69,
|
|
||||||
32,
|
|
||||||
-12,
|
|
||||||
-56
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
280,
|
|
||||||
267,
|
|
||||||
49,
|
|
||||||
45,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
330,
|
|
||||||
267,
|
|
||||||
49,
|
|
||||||
45,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
380,
|
|
||||||
267,
|
|
||||||
49,
|
|
||||||
45,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
430,
|
|
||||||
267,
|
|
||||||
49,
|
|
||||||
45,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
313,
|
|
||||||
49,
|
|
||||||
45,
|
|
||||||
-12,
|
|
||||||
-55
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
50,
|
|
||||||
313,
|
|
||||||
47,
|
|
||||||
45,
|
|
||||||
-35,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
98,
|
|
||||||
313,
|
|
||||||
47,
|
|
||||||
45,
|
|
||||||
-35,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
146,
|
|
||||||
313,
|
|
||||||
47,
|
|
||||||
45,
|
|
||||||
-35,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
194,
|
|
||||||
313,
|
|
||||||
47,
|
|
||||||
45,
|
|
||||||
-35,
|
|
||||||
-55
|
|
||||||
],
|
|
||||||
[
|
|
||||||
242,
|
|
||||||
313,
|
|
||||||
47,
|
|
||||||
45,
|
|
||||||
-35,
|
|
||||||
-55
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
290,
|
|
||||||
313,
|
|
||||||
71,
|
|
||||||
36,
|
|
||||||
-58,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
362,
|
|
||||||
313,
|
|
||||||
71,
|
|
||||||
36,
|
|
||||||
-58,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
434,
|
|
||||||
313,
|
|
||||||
71,
|
|
||||||
36,
|
|
||||||
-58,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
359,
|
|
||||||
71,
|
|
||||||
36,
|
|
||||||
-58,
|
|
||||||
-56
|
|
||||||
],
|
|
||||||
[
|
|
||||||
72,
|
|
||||||
359,
|
|
||||||
71,
|
|
||||||
36,
|
|
||||||
-58,
|
|
||||||
-56
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
144,
|
|
||||||
359,
|
|
||||||
67,
|
|
||||||
35,
|
|
||||||
-54,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
212,
|
|
||||||
359,
|
|
||||||
67,
|
|
||||||
35,
|
|
||||||
-54,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
280,
|
|
||||||
359,
|
|
||||||
67,
|
|
||||||
35,
|
|
||||||
-54,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
348,
|
|
||||||
359,
|
|
||||||
67,
|
|
||||||
35,
|
|
||||||
-54,
|
|
||||||
-65
|
|
||||||
],
|
|
||||||
[
|
|
||||||
416,
|
|
||||||
359,
|
|
||||||
67,
|
|
||||||
35,
|
|
||||||
-54,
|
|
||||||
-65
|
|
||||||
]
|
|
||||||
],
|
|
||||||
[
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
396,
|
|
||||||
44,
|
|
||||||
43,
|
|
||||||
-32,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
45,
|
|
||||||
396,
|
|
||||||
44,
|
|
||||||
43,
|
|
||||||
-32,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
90,
|
|
||||||
396,
|
|
||||||
44,
|
|
||||||
43,
|
|
||||||
-32,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
135,
|
|
||||||
396,
|
|
||||||
44,
|
|
||||||
43,
|
|
||||||
-32,
|
|
||||||
-74
|
|
||||||
],
|
|
||||||
[
|
|
||||||
180,
|
|
||||||
396,
|
|
||||||
44,
|
|
||||||
43,
|
|
||||||
-32,
|
|
||||||
-74
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 62 KiB |
|
|
@ -1,109 +0,0 @@
|
||||||
{
|
|
||||||
"name": "fireexplode",
|
|
||||||
"celFile": "FireArrowExplode2",
|
|
||||||
"width": 512,
|
|
||||||
"height": 141,
|
|
||||||
"directions": 1,
|
|
||||||
"framesPerDirection": 12,
|
|
||||||
"animSpeed": 16,
|
|
||||||
"groups": [
|
|
||||||
[
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
79,
|
|
||||||
0,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
158,
|
|
||||||
0,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
237,
|
|
||||||
0,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
316,
|
|
||||||
0,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
395,
|
|
||||||
0,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
0,
|
|
||||||
71,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
79,
|
|
||||||
71,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
158,
|
|
||||||
71,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
237,
|
|
||||||
71,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
316,
|
|
||||||
71,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
],
|
|
||||||
[
|
|
||||||
395,
|
|
||||||
71,
|
|
||||||
78,
|
|
||||||
70,
|
|
||||||
-39,
|
|
||||||
-81
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 12 KiB |
|
|
@ -1,213 +0,0 @@
|
||||||
/**
|
|
||||||
* Offline Missile Asset & Data Baking Script.
|
|
||||||
*
|
|
||||||
* Extracts authentic Diablo II v1.13c missile DCC sprites from d2data.mpq:
|
|
||||||
* - Firebolt.dcc (16 directions, 5 frames per direction)
|
|
||||||
* - Fireball.dcc (16 directions, 5 frames per direction)
|
|
||||||
* - FireArrowExplode2.dcc (1 direction, 12 frames)
|
|
||||||
*
|
|
||||||
* Bakes them into indexed-color PNG atlases and JSON metadata under:
|
|
||||||
* - public/missiles/
|
|
||||||
* - samples/d2-packs/missiles/
|
|
||||||
*
|
|
||||||
* And generates typed metadata in src/render/missiles-meta.ts.
|
|
||||||
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
|
||||||
import { join } from 'node:path'
|
|
||||||
import { MpqArchive } from '../src/mpq/archive.ts'
|
|
||||||
import { fileSource } from '../src/mpq/file-source.ts'
|
|
||||||
import { decodeDcc } from '../src/formats/dcc.ts'
|
|
||||||
import { decodePl2 } from '../src/formats/pl2.ts'
|
|
||||||
import { packSheetToPng } from './pack-entity-assets.ts'
|
|
||||||
import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts'
|
|
||||||
|
|
||||||
export interface MissileFrameMeta {
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly w: number
|
|
||||||
readonly h: number
|
|
||||||
readonly anchorX: number
|
|
||||||
readonly anchorY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MissileFrameTuple = readonly [
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
anchorX: number,
|
|
||||||
anchorY: number,
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface MissileMeta {
|
|
||||||
readonly name: string
|
|
||||||
readonly celFile: string
|
|
||||||
readonly width: number
|
|
||||||
readonly height: number
|
|
||||||
readonly directions: number
|
|
||||||
readonly framesPerDirection: number
|
|
||||||
readonly animSpeed: number
|
|
||||||
readonly groups: readonly (readonly MissileFrameTuple[])[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const MISSILE_TARGETS = [
|
|
||||||
{ name: 'firebolt', celFile: 'Firebolt', animSpeed: 16 },
|
|
||||||
{ name: 'fireball', celFile: 'Fireball', animSpeed: 16 },
|
|
||||||
{ name: 'fireexplode', celFile: 'FireArrowExplode2', animSpeed: 16 },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export async function bakeMissiles(projectRoot: string = process.cwd()): Promise<Record<string, MissileMeta>> {
|
|
||||||
const mpqPath = join(projectRoot, 'samples', 'd2', 'd2data.mpq')
|
|
||||||
const mpq = await MpqArchive.open(await fileSource(mpqPath))
|
|
||||||
|
|
||||||
const palFile = mpq.find('data/global/palette/ACT1/pal.pl2')
|
|
||||||
if (!palFile) throw new Error('data/global/palette/ACT1/pal.pl2 not found in d2data.mpq')
|
|
||||||
const pl2 = decodePl2(await mpq.read(palFile))
|
|
||||||
|
|
||||||
const publicOutDir = join(projectRoot, 'public', 'missiles')
|
|
||||||
const packOutDir = join(projectRoot, 'samples', 'd2-packs', 'missiles')
|
|
||||||
mkdirSync(publicOutDir, { recursive: true })
|
|
||||||
mkdirSync(packOutDir, { recursive: true })
|
|
||||||
|
|
||||||
const metaRecord: Record<string, MissileMeta> = {}
|
|
||||||
|
|
||||||
for (const target of MISSILE_TARGETS) {
|
|
||||||
const f = mpq.find(`data/global/missiles/${target.celFile}.dcc`)
|
|
||||||
if (!f) throw new Error(`Missile DCC not found: data/global/missiles/${target.celFile}.dcc`)
|
|
||||||
|
|
||||||
const dcc = decodeDcc(await mpq.read(f))
|
|
||||||
const groups = dcc.directions.map(dir => ({
|
|
||||||
frames: dir.frames.map(
|
|
||||||
(frame): SpriteFrame => ({
|
|
||||||
width: frame.frame.width,
|
|
||||||
height: frame.frame.height,
|
|
||||||
indices: frame.frame.indices,
|
|
||||||
mask: frame.frame.mask,
|
|
||||||
anchorX: dir.box.left,
|
|
||||||
anchorY: dir.box.top,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const sheet: SpriteSheet = { groups, width: null }
|
|
||||||
const packed = packSheetToPng(sheet, pl2.rgb)
|
|
||||||
|
|
||||||
const meta: MissileMeta = {
|
|
||||||
name: target.name,
|
|
||||||
celFile: target.celFile,
|
|
||||||
width: packed.width,
|
|
||||||
height: packed.height,
|
|
||||||
directions: dcc.directions.length,
|
|
||||||
framesPerDirection: dcc.directions[0]?.frames.length ?? 0,
|
|
||||||
animSpeed: target.animSpeed,
|
|
||||||
groups: packed.groups,
|
|
||||||
}
|
|
||||||
|
|
||||||
metaRecord[target.name] = meta
|
|
||||||
|
|
||||||
// Write PNG and JSON to public/missiles/ and samples/d2-packs/missiles/
|
|
||||||
const jsonStr = JSON.stringify(meta, null, 2)
|
|
||||||
writeFileSync(join(publicOutDir, `${target.name}.png`), packed.png)
|
|
||||||
writeFileSync(join(publicOutDir, `${target.name}.json`), jsonStr, 'utf-8')
|
|
||||||
writeFileSync(join(packOutDir, `${target.name}.png`), packed.png)
|
|
||||||
writeFileSync(join(packOutDir, `${target.name}.json`), jsonStr, 'utf-8')
|
|
||||||
|
|
||||||
console.log(`[Missile Packer] Baked ${target.name} (${target.celFile}.dcc): ${packed.width}x${packed.height}, ${packed.png.byteLength} bytes PNG`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate src/render/missiles-meta.ts
|
|
||||||
const metaTsContent = `/**
|
|
||||||
* Authentic Diablo II v1.13c Missile Atlas Metadata.
|
|
||||||
* Auto-generated by scripts/pack-missiles.ts.
|
|
||||||
* Enforces Zero Runtime MPQ/DLL Architectural Invariant.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export interface MissileFrameMeta {
|
|
||||||
readonly x: number
|
|
||||||
readonly y: number
|
|
||||||
readonly w: number
|
|
||||||
readonly h: number
|
|
||||||
readonly anchorX: number
|
|
||||||
readonly anchorY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MissileFrameTuple = readonly [
|
|
||||||
x: number,
|
|
||||||
y: number,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
anchorX: number,
|
|
||||||
anchorY: number,
|
|
||||||
]
|
|
||||||
|
|
||||||
export interface MissileMeta {
|
|
||||||
readonly name: string
|
|
||||||
readonly celFile: string
|
|
||||||
readonly width: number
|
|
||||||
readonly height: number
|
|
||||||
readonly directions: number
|
|
||||||
readonly framesPerDirection: number
|
|
||||||
readonly animSpeed: number
|
|
||||||
readonly groups: readonly (readonly MissileFrameTuple[])[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The engine's 64-direction space -> DCC direction tables.
|
|
||||||
* Ported from OpenDiablo2 d2fileformats/d2dcc/dcc_dir_lookup.go (Dir64ToDcc).
|
|
||||||
*/
|
|
||||||
const DIR64_TO_DCC_16: readonly number[] = [
|
|
||||||
4, 4, 8, 8, 8, 8, 0, 0, 0, 0, 9, 9, 9, 9, 5, 5,
|
|
||||||
5, 5, 10, 10, 10, 10, 1, 1, 1, 1, 11, 11, 11, 11, 6, 6,
|
|
||||||
6, 6, 12, 12, 12, 12, 2, 2, 2, 2, 13, 13, 13, 13, 7, 7,
|
|
||||||
7, 7, 14, 14, 14, 14, 3, 3, 3, 3, 15, 15, 15, 15, 4, 4,
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calculate DCC direction index for a missile based on 2D velocity vector.
|
|
||||||
*
|
|
||||||
* In Diablo II's 64-direction space:
|
|
||||||
* - South (0, +y) is dir 0
|
|
||||||
* - West (-x, 0) is dir 16
|
|
||||||
* - North (0, -y) is dir 32
|
|
||||||
* - East (+x, 0) is dir 48
|
|
||||||
*/
|
|
||||||
export function velocityToDccDirection(vx: number, vy: number, directions = 16): number {
|
|
||||||
if (directions <= 1) return 0
|
|
||||||
const angle = Math.atan2(vy, vx) // -PI to PI
|
|
||||||
// Map angle so that South (PI/2) is 0, turning clockwise
|
|
||||||
const fraction = (angle - Math.PI / 2) / (2 * Math.PI)
|
|
||||||
const dir64 = Math.round((((fraction % 1) + 1) % 1) * 64) % 64
|
|
||||||
if (directions === 16) {
|
|
||||||
return DIR64_TO_DCC_16[dir64] ?? 0
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FIREBOLT_META: MissileMeta = ${JSON.stringify(metaRecord['firebolt'], null, 2)}
|
|
||||||
|
|
||||||
export const FIREBALL_META: MissileMeta = ${JSON.stringify(metaRecord['fireball'], null, 2)}
|
|
||||||
|
|
||||||
export const FIREEXPLODE_META: MissileMeta = ${JSON.stringify(metaRecord['fireexplode'], null, 2)}
|
|
||||||
|
|
||||||
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = {
|
|
||||||
firebolt: FIREBOLT_META,
|
|
||||||
fireball: FIREBALL_META,
|
|
||||||
fireexplode: FIREEXPLODE_META,
|
|
||||||
}
|
|
||||||
`
|
|
||||||
const metaTsPath = join(projectRoot, 'src', 'render', 'missiles-meta.ts')
|
|
||||||
writeFileSync(metaTsPath, metaTsContent, 'utf-8')
|
|
||||||
console.log(`[Missile Packer] Wrote ${metaTsPath}`)
|
|
||||||
|
|
||||||
return metaRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute standalone if called from CLI
|
|
||||||
if (process.argv[1]?.endsWith('pack-missiles.ts')) {
|
|
||||||
bakeMissiles().catch((err: unknown) => {
|
|
||||||
console.error('Bake missiles failed:', err)
|
|
||||||
process.exit(1)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -48,11 +48,6 @@ import type { NpcDef } from '../game/quests.ts'
|
||||||
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getMissileTxtData } from '../game/skills.ts'
|
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, getMissileTxtData } from '../game/skills.ts'
|
||||||
import type { Projectile } from '../game/skills.ts'
|
import type { Projectile } from '../game/skills.ts'
|
||||||
import { findSafeDropPosition, calculateBounceHeight, type GroundItemEntity } from '../game/ground-items.ts'
|
import { findSafeDropPosition, calculateBounceHeight, type GroundItemEntity } from '../game/ground-items.ts'
|
||||||
import {
|
|
||||||
GroundLabelOverlay,
|
|
||||||
computeInitialGroundLabelLayouts,
|
|
||||||
resolveLadderCollisions,
|
|
||||||
} from '../ui/ground-labels.ts'
|
|
||||||
import type { AtlasFrame } from '../render/atlas.ts'
|
import type { AtlasFrame } from '../render/atlas.ts'
|
||||||
import { SpriteRenderer } from '../render/renderer.ts'
|
import { SpriteRenderer } from '../render/renderer.ts'
|
||||||
import type { AtlasHandle } from '../render/renderer.ts'
|
import type { AtlasHandle } from '../render/renderer.ts'
|
||||||
|
|
@ -60,8 +55,6 @@ import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
|
||||||
import { RoofFadeController } from '../render/roofs.ts'
|
import { RoofFadeController } from '../render/roofs.ts'
|
||||||
import { facingToDirection } from '../game/character.ts'
|
import { facingToDirection } from '../game/character.ts'
|
||||||
import type { LoadedMonsterArt } from '../game/monster-art.ts'
|
import type { LoadedMonsterArt } from '../game/monster-art.ts'
|
||||||
import type { MissileMeta } from '../render/missiles-meta.ts'
|
|
||||||
import { velocityToDccDirection } from '../render/missiles-meta.ts'
|
|
||||||
import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts'
|
import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts'
|
||||||
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
|
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
|
||||||
import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, getLevelNames, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
|
import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, getLevelNames, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
|
||||||
|
|
@ -2943,95 +2936,12 @@ function buildStreamingRooms(
|
||||||
return defs
|
return defs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Packed missile animation asset loaded into GPU memory.
|
|
||||||
*/
|
|
||||||
export interface LoadedMissileArt {
|
|
||||||
readonly meta: MissileMeta
|
|
||||||
readonly handle: AtlasHandle
|
|
||||||
readonly frames: AtlasFrame[][]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load authentic 1.13c baked missile atlases from /missiles/*.png or packBase.
|
|
||||||
*/
|
|
||||||
export async function loadMissileArtMap(
|
|
||||||
renderer: SpriteRenderer,
|
|
||||||
packBase?: string,
|
|
||||||
): Promise<Map<string, LoadedMissileArt>> {
|
|
||||||
const map = new Map<string, LoadedMissileArt>()
|
|
||||||
if (typeof fetch === 'undefined') return map
|
|
||||||
const targets = ['firebolt', 'fireball', 'fireexplode']
|
|
||||||
const baseCandidates = [
|
|
||||||
'/missiles',
|
|
||||||
packBase ? `${packBase}/missiles` : '',
|
|
||||||
'samples/d2-packs/missiles',
|
|
||||||
].filter(Boolean)
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
targets.map(async name => {
|
|
||||||
for (const base of baseCandidates) {
|
|
||||||
try {
|
|
||||||
const [jsonResp, pngResp] = await Promise.all([
|
|
||||||
fetch(`${base}/${name}.json`),
|
|
||||||
fetch(`${base}/${name}.png`),
|
|
||||||
])
|
|
||||||
if (jsonResp.ok && pngResp.ok) {
|
|
||||||
const meta = (await jsonResp.json()) as MissileMeta
|
|
||||||
const blob = await pngResp.blob()
|
|
||||||
const bitmap = await createImageBitmap(blob)
|
|
||||||
let handle: AtlasHandle
|
|
||||||
try {
|
|
||||||
handle = renderer.addAtlas(bitmap, meta.width, meta.height)
|
|
||||||
} finally {
|
|
||||||
bitmap.close()
|
|
||||||
}
|
|
||||||
const frames: AtlasFrame[][] = meta.groups.map(group =>
|
|
||||||
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
...(anchorX !== undefined ? { anchorX } : {}),
|
|
||||||
...(anchorY !== undefined ? { anchorY } : {}),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
map.set(name, { meta, handle, frames })
|
|
||||||
break
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore candidate error and continue to next fallback path
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return map
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a Diablo II 1.13c style Fire Bolt projectile:
|
* Render a Diablo II 1.13c style Fire Bolt projectile:
|
||||||
* - Uses authentic baked Firebolt.dcc sprite atlas when available.
|
* Luminous multi-layered fireball with white-hot core, fiery body, glowing halo,
|
||||||
* - Gracefully falls back to procedural glowing fireball & trailing particles in test/offline environments.
|
* and a trailing plume of animated fire/ember particles extending behind the velocity vector.
|
||||||
*/
|
*/
|
||||||
export function drawFireboltProjectile(
|
export function drawFireboltProjectile(renderer: SpriteRenderer, shot: Projectile): void {
|
||||||
renderer: SpriteRenderer,
|
|
||||||
shot: Projectile,
|
|
||||||
missileArt?: LoadedMissileArt,
|
|
||||||
): void {
|
|
||||||
if (missileArt !== undefined) {
|
|
||||||
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
|
|
||||||
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
|
|
||||||
const frameIndex = Math.floor(((50 - shot.ttl) * missileArt.meta.animSpeed) / 16) % frameCount
|
|
||||||
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
|
||||||
if (frame !== undefined) {
|
|
||||||
const drawX = frame.anchorX !== undefined ? shot.x + frame.anchorX : shot.x - frame.width / 2
|
|
||||||
const drawY = frame.anchorY !== undefined ? shot.y + frame.anchorY : shot.y - frame.height / 2
|
|
||||||
renderer.draw(frame, drawX, drawY, { atlas: missileArt.handle })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const x = shot.x
|
const x = shot.x
|
||||||
const y = shot.y
|
const y = shot.y
|
||||||
const vx = shot.vx
|
const vx = shot.vx
|
||||||
|
|
@ -3044,7 +2954,7 @@ export function drawFireboltProjectile(
|
||||||
for (let i = 1; i <= 5; i += 1) {
|
for (let i = 1; i <= 5; i += 1) {
|
||||||
const dist = i * 4.2
|
const dist = i * 4.2
|
||||||
// Sinusoidal organic wobble perpendicular to travel direction
|
// Sinusoidal organic wobble perpendicular to travel direction
|
||||||
const flicker = Math.sin(shot.ttl * 1.8 + i * 1.5) * (i * 0.75)
|
const flicker = Math.sin((shot.ttl * 1.8) + i * 1.5) * (i * 0.75)
|
||||||
const px = x - dirX * dist - dirY * flicker
|
const px = x - dirX * dist - dirY * flicker
|
||||||
const py = y - dirY * dist + dirX * flicker
|
const py = y - dirY * dist + dirX * flicker
|
||||||
const size = Math.max(3, 8 - i)
|
const size = Math.max(3, 8 - i)
|
||||||
|
|
@ -3053,7 +2963,7 @@ export function drawFireboltProjectile(
|
||||||
else if (i === 2) color = [0.98, 0.52, 0.08, 0.62]
|
else if (i === 2) color = [0.98, 0.52, 0.08, 0.62]
|
||||||
else if (i === 3) color = [0.92, 0.32, 0.04, 0.46]
|
else if (i === 3) color = [0.92, 0.32, 0.04, 0.46]
|
||||||
else if (i === 4) color = [0.75, 0.18, 0.02, 0.32]
|
else if (i === 4) color = [0.75, 0.18, 0.02, 0.32]
|
||||||
else color = [0.45, 0.1, 0.02, 0.18]
|
else color = [0.45, 0.10, 0.02, 0.18]
|
||||||
renderer.drawSolid(px - size / 2, py - size / 2, size, size, color)
|
renderer.drawSolid(px - size / 2, py - size / 2, size, size, color)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -3061,7 +2971,7 @@ export function drawFireboltProjectile(
|
||||||
renderer.drawSolid(x - 10, y - 10, 20, 20, [1.0, 0.35, 0.05, 0.22])
|
renderer.drawSolid(x - 10, y - 10, 20, 20, [1.0, 0.35, 0.05, 0.22])
|
||||||
|
|
||||||
// 3. Medium bright flame body
|
// 3. Medium bright flame body
|
||||||
renderer.drawSolid(x - 7, y - 7, 14, 14, [1.0, 0.62, 0.1, 0.55])
|
renderer.drawSolid(x - 7, y - 7, 14, 14, [1.0, 0.62, 0.10, 0.55])
|
||||||
|
|
||||||
// 4. Inner intense fire
|
// 4. Inner intense fire
|
||||||
renderer.drawSolid(x - 4, y - 4, 8, 8, [1.0, 0.88, 0.22, 0.88])
|
renderer.drawSolid(x - 4, y - 4, 8, 8, [1.0, 0.88, 0.22, 0.88])
|
||||||
|
|
@ -3072,27 +2982,9 @@ export function drawFireboltProjectile(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a Diablo II 1.13c style fire explosion (fireexplode):
|
* Render a Diablo II 1.13c style fire explosion (fireexplode):
|
||||||
* - Uses authentic baked FireArrowExplode2.dcc sprite atlas when available.
|
* 12-frame expanding fireball burst with bright initial flash and radial fiery sparks.
|
||||||
* - Falls back to procedural explosion in test/offline environments.
|
|
||||||
*/
|
*/
|
||||||
export function drawFireExplosion(
|
export function drawFireExplosion(renderer: SpriteRenderer, exp: ActiveExplosion): void {
|
||||||
renderer: SpriteRenderer,
|
|
||||||
exp: ActiveExplosion,
|
|
||||||
explosionArt?: LoadedMissileArt,
|
|
||||||
): void {
|
|
||||||
if (explosionArt !== undefined) {
|
|
||||||
const frameCount = Math.max(1, explosionArt.meta.framesPerDirection)
|
|
||||||
const frameIndex = Math.min(Math.max(0, exp.frame), frameCount - 1)
|
|
||||||
const group = explosionArt.frames[0]
|
|
||||||
const frame = group?.[frameIndex] ?? group?.[0]
|
|
||||||
if (frame !== undefined) {
|
|
||||||
const drawX = frame.anchorX !== undefined ? exp.x + frame.anchorX : exp.x - frame.width / 2
|
|
||||||
const drawY = frame.anchorY !== undefined ? exp.y + frame.anchorY : exp.y - frame.height / 2
|
|
||||||
renderer.draw(frame, drawX, drawY, { atlas: explosionArt.handle })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = Math.min(1, Math.max(0, exp.frame / exp.maxFrames))
|
const progress = Math.min(1, Math.max(0, exp.frame / exp.maxFrames))
|
||||||
const inv = 1 - progress
|
const inv = 1 - progress
|
||||||
const x = exp.x
|
const x = exp.x
|
||||||
|
|
@ -3258,10 +3150,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
|
|
||||||
let character: LoadedCharacter | null = null
|
let character: LoadedCharacter | null = null
|
||||||
let monsterArtMap = new Map<string, LoadedMonsterArt>()
|
let monsterArtMap = new Map<string, LoadedMonsterArt>()
|
||||||
let missileArtMap = new Map<string, LoadedMissileArt>()
|
|
||||||
void loadMissileArtMap(renderer, packEntityBase).then(map => {
|
|
||||||
missileArtMap = map
|
|
||||||
})
|
|
||||||
let monsterWalkFrame = 0
|
let monsterWalkFrame = 0
|
||||||
const playerAnimator = new ActorAnimator()
|
const playerAnimator = new ActorAnimator()
|
||||||
|
|
||||||
|
|
@ -3353,17 +3241,6 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
|
||||||
// render loop only has to move them.
|
// render loop only has to move them.
|
||||||
const labelsContainer = document.querySelector<HTMLElement>('#labels')
|
const labelsContainer = document.querySelector<HTMLElement>('#labels')
|
||||||
const labelPool: HTMLDivElement[] = []
|
const labelPool: HTMLDivElement[] = []
|
||||||
|
|
||||||
// Ground item Alt floating labels container and overlay manager
|
|
||||||
const groundLabelsContainer = document.querySelector<HTMLElement>('#ground-labels') ?? (() => {
|
|
||||||
if (typeof document === 'undefined') return null
|
|
||||||
const el = document.createElement('div')
|
|
||||||
el.id = 'ground-labels'
|
|
||||||
document.body.appendChild(el)
|
|
||||||
return el
|
|
||||||
})()
|
|
||||||
const groundLabelOverlay = groundLabelsContainer ? new GroundLabelOverlay(groundLabelsContainer) : null
|
|
||||||
|
|
||||||
/** `performance.now()` at which the HUD text may be rewritten again. */
|
/** `performance.now()` at which the HUD text may be rewritten again. */
|
||||||
let hudNextMs = 0
|
let hudNextMs = 0
|
||||||
|
|
||||||
|
|
@ -4305,9 +4182,7 @@ if (typeof window !== 'undefined') {
|
||||||
for (const shot of engine.projectiles) {
|
for (const shot of engine.projectiles) {
|
||||||
pushEntity(shot.x, shot.y, () => {
|
pushEntity(shot.x, shot.y, () => {
|
||||||
if (shot.skillId === '36' || shot.skillId === 'firebolt' || shot.missileType === 'firebolt') {
|
if (shot.skillId === '36' || shot.skillId === 'firebolt' || shot.missileType === 'firebolt') {
|
||||||
drawFireboltProjectile(renderer, shot, missileArtMap.get('firebolt'))
|
drawFireboltProjectile(renderer, shot)
|
||||||
} else if (shot.skillId === '47' || shot.skillId === 'fireball' || shot.missileType === 'fireball') {
|
|
||||||
drawFireboltProjectile(renderer, shot, missileArtMap.get('fireball'))
|
|
||||||
} else {
|
} else {
|
||||||
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
|
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
|
||||||
}
|
}
|
||||||
|
|
@ -4316,7 +4191,7 @@ if (typeof window !== 'undefined') {
|
||||||
|
|
||||||
for (const exp of engine.explosions) {
|
for (const exp of engine.explosions) {
|
||||||
pushEntity(exp.x, exp.y, () => {
|
pushEntity(exp.x, exp.y, () => {
|
||||||
drawFireExplosion(renderer, exp, missileArtMap.get('fireexplode'))
|
drawFireExplosion(renderer, exp)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4422,28 +4297,6 @@ if (typeof window !== 'undefined') {
|
||||||
for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true
|
for (let index = labelCount; index < labelPool.length; index += 1) labelPool[index]!.hidden = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groundLabelOverlay !== null) {
|
|
||||||
const showLabels = Boolean(hudManager?.showGroundLabels)
|
|
||||||
if (showLabels && engine.groundItems.count > 0) {
|
|
||||||
const rect = canvas.getBoundingClientRect()
|
|
||||||
const scaleX = canvas.width > 0 ? rect.width / canvas.width : 1
|
|
||||||
const scaleY = canvas.height > 0 ? rect.height / canvas.height : 1
|
|
||||||
const projectToScreen = (worldX: number, worldY: number) => {
|
|
||||||
const domX = rect.left + rect.width * 0.5 + (worldX - camX) * camera.zoom * scaleX
|
|
||||||
const domY = rect.top + rect.height * 0.5 + (worldY - camY) * camera.zoom * scaleY
|
|
||||||
if (domX < -150 || domX > rect.width + 150 || domY < -150 || domY > rect.height + 150) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
return { x: domX, y: domY }
|
|
||||||
}
|
|
||||||
const initialLayouts = computeInitialGroundLabelLayouts(engine.groundItems.all, projectToScreen)
|
|
||||||
const resolvedLayouts = resolveLadderCollisions(initialLayouts)
|
|
||||||
groundLabelOverlay.update(true, resolvedLayouts)
|
|
||||||
} else {
|
|
||||||
groundLabelOverlay.update(false, [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
state.drawCalls = renderer.drawCalls
|
state.drawCalls = renderer.drawCalls
|
||||||
state.quadsDrawn = renderer.quadsSubmitted
|
state.quadsDrawn = renderer.quadsSubmitted
|
||||||
state.culledDraws = culledDraws
|
state.culledDraws = culledDraws
|
||||||
|
|
|
||||||
|
|
@ -1,345 +0,0 @@
|
||||||
/**
|
|
||||||
* Diablo II v1.13c Authentic Ground Item Alt Labels (`src/ui/ground-labels.ts`).
|
|
||||||
*
|
|
||||||
* Implements:
|
|
||||||
* - Holding the `Alt` key displays floating text labels for all ground items on screen.
|
|
||||||
* - Text color and border styling strictly following Diablo II 1.13c item quality colors:
|
|
||||||
* Normal (white), Low (gray), Superior (white), Magic (blue), Rare (yellow),
|
|
||||||
* Set (green), Unique (gold/tan), Craft/Rune (orange), Gold (yellow/gold).
|
|
||||||
* - Vertical ladder collision avoidance (anti-overlap):
|
|
||||||
* When multiple items drop in close proximity (e.g. boss drops, packed loot),
|
|
||||||
* labels are neatly stacked vertically into a non-overlapping ladder.
|
|
||||||
* - Clickable labels to trigger pathfinding and item pickup.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { GroundItemEntity } from '../game/ground-items.ts'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentic 1.13c Diablo II quality color codes for ground item labels.
|
|
||||||
* Matching D2 palette tables & D2WinFont values.
|
|
||||||
*/
|
|
||||||
export const GROUND_LABEL_QUALITY_COLORS: Record<string, string> = {
|
|
||||||
normal: '#d8d8d8',
|
|
||||||
low: '#808080',
|
|
||||||
superior: '#ffffff',
|
|
||||||
magic: '#6868ff',
|
|
||||||
rare: '#ffff64',
|
|
||||||
set: '#00fc00',
|
|
||||||
unique: '#c8a15a',
|
|
||||||
craft: '#ff9c18',
|
|
||||||
rune: '#ff9c18',
|
|
||||||
gold: '#d8b420',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves the quality color string for a ground item.
|
|
||||||
*/
|
|
||||||
export function getGroundItemQualityColor(item: GroundItemEntity): string {
|
|
||||||
if (item.isGold) {
|
|
||||||
return GROUND_LABEL_QUALITY_COLORS.gold!
|
|
||||||
}
|
|
||||||
const q = item.quality?.toLowerCase() ?? 'normal'
|
|
||||||
return GROUND_LABEL_QUALITY_COLORS[q] ?? GROUND_LABEL_QUALITY_COLORS.normal!
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format the user-facing display text for a ground item label.
|
|
||||||
*/
|
|
||||||
export function formatGroundItemLabelText(item: GroundItemEntity): string {
|
|
||||||
if (item.isGold) {
|
|
||||||
return `${item.amount} 金币`
|
|
||||||
}
|
|
||||||
return item.nameZh || item.name || '物品'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Estimates the pixel width of a ground item label based on CJK and Latin character counts.
|
|
||||||
*/
|
|
||||||
export function estimateLabelWidth(text: string, fontSizePx = 13): number {
|
|
||||||
let charWidths = 0
|
|
||||||
for (let i = 0; i < text.length; i += 1) {
|
|
||||||
const code = text.charCodeAt(i)
|
|
||||||
// CJK ideographs / punctuation roughly full-width
|
|
||||||
if (code > 0x2e80) {
|
|
||||||
charWidths += fontSizePx * 1.05
|
|
||||||
} else {
|
|
||||||
// Latin characters / numbers / spaces roughly half-width
|
|
||||||
charWidths += fontSizePx * 0.58
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Base padding: 12px (6px left + 6px right) + 2px border
|
|
||||||
return Math.ceil(charWidths + 14)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroundLabelLayout {
|
|
||||||
readonly id: string
|
|
||||||
readonly itemId: string
|
|
||||||
readonly item: GroundItemEntity
|
|
||||||
readonly text: string
|
|
||||||
readonly color: string
|
|
||||||
readonly borderColor: string
|
|
||||||
readonly width: number
|
|
||||||
readonly height: number
|
|
||||||
readonly screenX: number
|
|
||||||
screenY: number
|
|
||||||
readonly naturalY: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computes initial screen layouts for a list of ground items.
|
|
||||||
*/
|
|
||||||
export function computeInitialGroundLabelLayouts(
|
|
||||||
items: readonly GroundItemEntity[],
|
|
||||||
projectToScreen: (worldX: number, worldY: number) => { x: number; y: number } | null,
|
|
||||||
fontSizePx = 13,
|
|
||||||
labelHeightPx = 18,
|
|
||||||
): GroundLabelLayout[] {
|
|
||||||
const layouts: GroundLabelLayout[] = []
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const pt = projectToScreen(item.x, item.y)
|
|
||||||
if (pt === null) continue
|
|
||||||
|
|
||||||
const text = formatGroundItemLabelText(item)
|
|
||||||
const color = getGroundItemQualityColor(item)
|
|
||||||
const width = estimateLabelWidth(text, fontSizePx)
|
|
||||||
const naturalY = pt.y - 12 // Positioned 12px above ground contact point
|
|
||||||
|
|
||||||
layouts.push({
|
|
||||||
id: `label-${item.id}`,
|
|
||||||
itemId: item.id,
|
|
||||||
item,
|
|
||||||
text,
|
|
||||||
color,
|
|
||||||
borderColor: color,
|
|
||||||
width,
|
|
||||||
height: labelHeightPx,
|
|
||||||
screenX: pt.x,
|
|
||||||
screenY: naturalY,
|
|
||||||
naturalY,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return layouts
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if two rectangular labels overlap within specified padding tolerances.
|
|
||||||
*/
|
|
||||||
export function doLabelsOverlap(
|
|
||||||
a: GroundLabelLayout,
|
|
||||||
b: GroundLabelLayout,
|
|
||||||
padX = 4,
|
|
||||||
padY = 2,
|
|
||||||
): boolean {
|
|
||||||
const aLeft = a.screenX - a.width / 2
|
|
||||||
const aRight = a.screenX + a.width / 2
|
|
||||||
const aTop = a.screenY - a.height / 2
|
|
||||||
const aBottom = a.screenY + a.height / 2
|
|
||||||
|
|
||||||
const bLeft = b.screenX - b.width / 2
|
|
||||||
const bRight = b.screenX + b.width / 2
|
|
||||||
const bTop = b.screenY - b.height / 2
|
|
||||||
const bBottom = b.screenY + b.height / 2
|
|
||||||
|
|
||||||
const xOverlap = aLeft - padX < bRight && aRight + padX > bLeft
|
|
||||||
const yOverlap = aTop - padY < bBottom && aBottom + padY > bTop
|
|
||||||
|
|
||||||
return xOverlap && yOverlap
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentic Diablo II 1.13c Vertical Ladder Collision Avoidance Algorithm.
|
|
||||||
*
|
|
||||||
* Stacks overlapping ground item labels vertically so they remain readable and clickable.
|
|
||||||
* - Groups labels into overlapping horizontal clusters.
|
|
||||||
* - Stacks each cluster from bottom (closest to the ground item) upwards.
|
|
||||||
* - Preserves natural anchor proximity while eliminating 100% of bounding box overlaps.
|
|
||||||
*/
|
|
||||||
export function resolveLadderCollisions(
|
|
||||||
labels: GroundLabelLayout[],
|
|
||||||
padX = 4,
|
|
||||||
padY = 2,
|
|
||||||
): GroundLabelLayout[] {
|
|
||||||
if (labels.length <= 1) return labels
|
|
||||||
|
|
||||||
// Work on a copy with mutable screenY
|
|
||||||
const result: GroundLabelLayout[] = labels.map(l => ({ ...l }))
|
|
||||||
|
|
||||||
// Partition into connected overlapping components (clusters)
|
|
||||||
const n = result.length
|
|
||||||
const adj: number[][] = Array.from({ length: n }, () => [])
|
|
||||||
|
|
||||||
for (let i = 0; i < n; i += 1) {
|
|
||||||
for (let j = i + 1; j < n; j += 1) {
|
|
||||||
// Horizontal overlap check: do their X spans overlap?
|
|
||||||
const a = result[i]!
|
|
||||||
const b = result[j]!
|
|
||||||
const aLeft = a.screenX - a.width / 2
|
|
||||||
const aRight = a.screenX + a.width / 2
|
|
||||||
const bLeft = b.screenX - b.width / 2
|
|
||||||
const bRight = b.screenX + b.width / 2
|
|
||||||
|
|
||||||
if (aLeft - padX < bRight && aRight + padX > bLeft) {
|
|
||||||
adj[i]!.push(j)
|
|
||||||
adj[j]!.push(i)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find connected clusters via BFS
|
|
||||||
const visited = new Uint8Array(n)
|
|
||||||
for (let i = 0; i < n; i += 1) {
|
|
||||||
if (visited[i]) continue
|
|
||||||
const cluster: number[] = []
|
|
||||||
const queue = [i]
|
|
||||||
visited[i] = 1
|
|
||||||
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const curr = queue.shift()!
|
|
||||||
cluster.push(curr)
|
|
||||||
for (const neighbor of adj[curr]!) {
|
|
||||||
if (!visited[neighbor]) {
|
|
||||||
visited[neighbor] = 1
|
|
||||||
queue.push(neighbor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cluster.length <= 1) continue
|
|
||||||
|
|
||||||
// Sort cluster items by their natural Y ascending
|
|
||||||
cluster.sort((idxA, idxB) => {
|
|
||||||
const a = result[idxA]!
|
|
||||||
const b = result[idxB]!
|
|
||||||
if (a.naturalY !== b.naturalY) {
|
|
||||||
return a.naturalY - b.naturalY
|
|
||||||
}
|
|
||||||
return a.screenX - b.screenX
|
|
||||||
})
|
|
||||||
|
|
||||||
// Ladder layout: Bottom-most label is placed closest to the ground item,
|
|
||||||
// and subsequent labels stack upward neatly.
|
|
||||||
// Base Y is the natural Y of the lowest (highest Y coordinate) item in the cluster.
|
|
||||||
const bottomItem = result[cluster[cluster.length - 1]!]!
|
|
||||||
let currentBottomY = bottomItem.naturalY
|
|
||||||
|
|
||||||
// Stack upward from bottom to top
|
|
||||||
for (let k = cluster.length - 1; k >= 0; k -= 1) {
|
|
||||||
const itemIdx = cluster[k]!
|
|
||||||
const item = result[itemIdx]!
|
|
||||||
item.screenY = currentBottomY
|
|
||||||
currentBottomY -= item.height + padY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DOM Manager for ground item Alt labels overlay.
|
|
||||||
*/
|
|
||||||
export class GroundLabelOverlay {
|
|
||||||
private readonly container: HTMLElement
|
|
||||||
private readonly labelPool: HTMLElement[] = []
|
|
||||||
private onPickupCallback: ((item: GroundItemEntity) => void) | null = null
|
|
||||||
|
|
||||||
constructor(container: HTMLElement) {
|
|
||||||
this.container = container
|
|
||||||
this.container.style.position = 'fixed'
|
|
||||||
this.container.style.inset = '0'
|
|
||||||
this.container.style.pointerEvents = 'none'
|
|
||||||
this.container.style.zIndex = '5'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Register a callback when a ground item label is clicked.
|
|
||||||
*/
|
|
||||||
setOnPickupCallback(callback: (item: GroundItemEntity) => void): void {
|
|
||||||
this.onPickupCallback = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update and render ground item labels based on current visible items and Alt key state.
|
|
||||||
*/
|
|
||||||
update(
|
|
||||||
visible: boolean,
|
|
||||||
layouts: readonly GroundLabelLayout[],
|
|
||||||
): void {
|
|
||||||
if (!visible || layouts.length === 0) {
|
|
||||||
this.hideAll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let count = 0
|
|
||||||
for (const layout of layouts) {
|
|
||||||
let el = this.labelPool[count]
|
|
||||||
if (el === undefined) {
|
|
||||||
el = document.createElement('div')
|
|
||||||
el.className = 'd2-ground-label'
|
|
||||||
el.style.position = 'absolute'
|
|
||||||
el.style.transform = 'translate(-50%, -50%)'
|
|
||||||
el.style.background = 'rgba(0, 0, 0, 0.88)'
|
|
||||||
el.style.padding = '2px 6px'
|
|
||||||
el.style.fontFamily = "'Exocet', 'Formal', 'SimSun', sans-serif"
|
|
||||||
el.style.fontSize = '13px'
|
|
||||||
el.style.lineHeight = '14px'
|
|
||||||
el.style.whiteSpace = 'nowrap'
|
|
||||||
el.style.pointerEvents = 'auto'
|
|
||||||
el.style.cursor = 'pointer'
|
|
||||||
el.style.userSelect = 'none'
|
|
||||||
el.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.8)'
|
|
||||||
el.style.textShadow = '1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000'
|
|
||||||
el.style.transition = 'border-color 0.1s, background-color 0.1s'
|
|
||||||
|
|
||||||
el.addEventListener('mouseenter', () => {
|
|
||||||
el.style.backgroundColor = 'rgba(28, 28, 48, 0.96)'
|
|
||||||
el.style.borderColor = '#ffffff'
|
|
||||||
})
|
|
||||||
el.addEventListener('mouseleave', () => {
|
|
||||||
el.style.backgroundColor = 'rgba(0, 0, 0, 0.88)'
|
|
||||||
el.style.borderColor = el.dataset.qualityColor || '#404040'
|
|
||||||
})
|
|
||||||
el.addEventListener('pointerdown', (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
e.preventDefault()
|
|
||||||
const currentItem = (el as any).__groundItem as GroundItemEntity | undefined
|
|
||||||
if (currentItem && this.onPickupCallback) {
|
|
||||||
this.onPickupCallback(currentItem)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
this.labelPool.push(el)
|
|
||||||
this.container.appendChild(el)
|
|
||||||
}
|
|
||||||
|
|
||||||
;(el as any).__groundItem = layout.item
|
|
||||||
el.dataset.itemId = layout.itemId
|
|
||||||
el.dataset.qualityColor = layout.borderColor
|
|
||||||
if (el.textContent !== layout.text) el.textContent = layout.text
|
|
||||||
el.style.color = layout.color
|
|
||||||
el.style.border = `1px solid ${layout.borderColor}`
|
|
||||||
el.style.left = `${Math.round(layout.screenX)}px`
|
|
||||||
el.style.top = `${Math.round(layout.screenY)}px`
|
|
||||||
el.style.display = 'block'
|
|
||||||
el.hidden = false
|
|
||||||
count += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hide surplus pooled elements
|
|
||||||
for (let i = count; i < this.labelPool.length; i += 1) {
|
|
||||||
this.labelPool[i]!.style.display = 'none'
|
|
||||||
this.labelPool[i]!.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hide all active labels.
|
|
||||||
*/
|
|
||||||
hideAll(): void {
|
|
||||||
for (const el of this.labelPool) {
|
|
||||||
el.style.display = 'none'
|
|
||||||
el.hidden = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -611,7 +611,6 @@ export class HudManager {
|
||||||
this.hotkeys.triggerFunctionKey(key)
|
this.hotkeys.triggerFunctionKey(key)
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
} else if (e.key === 'Alt') {
|
} else if (e.key === 'Alt') {
|
||||||
e.preventDefault()
|
|
||||||
this.showGroundLabels = true
|
this.showGroundLabels = true
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
} else if (e.key === 'Escape' || e.code === 'Space' || e.key === ' ') {
|
} else if (e.key === 'Escape' || e.code === 'Space' || e.key === ' ') {
|
||||||
|
|
@ -623,14 +622,6 @@ export class HudManager {
|
||||||
|
|
||||||
window.addEventListener('keyup', (e) => {
|
window.addEventListener('keyup', (e) => {
|
||||||
if (e.key === 'Alt') {
|
if (e.key === 'Alt') {
|
||||||
e.preventDefault()
|
|
||||||
this.showGroundLabels = false
|
|
||||||
this.syncPublishedState()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
window.addEventListener('blur', () => {
|
|
||||||
if (this.showGroundLabels) {
|
|
||||||
this.showGroundLabels = false
|
this.showGroundLabels = false
|
||||||
this.syncPublishedState()
|
this.syncPublishedState()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,11 +22,10 @@ import {
|
||||||
tickProjectiles,
|
tickProjectiles,
|
||||||
type Projectile,
|
type Projectile,
|
||||||
} from '../src/game/skills.ts'
|
} from '../src/game/skills.ts'
|
||||||
import { getSkillRange, drawFireboltProjectile, drawFireExplosion, type LoadedMissileArt } from '../src/scene/act-scene.ts'
|
import { getSkillRange, drawFireboltProjectile, drawFireExplosion } from '../src/scene/act-scene.ts'
|
||||||
import { GameEngine } from '../src/game/engine.ts'
|
import { GameEngine } from '../src/game/engine.ts'
|
||||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
|
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../src/game/demo-data.ts'
|
||||||
import { SpriteRenderer, type AtlasHandle } from '../src/render/renderer.ts'
|
import { SpriteRenderer } from '../src/render/renderer.ts'
|
||||||
import { FIREBOLT_META, FIREEXPLODE_META, velocityToDccDirection } from '../src/render/missiles-meta.ts'
|
|
||||||
|
|
||||||
describe('Issue #385 — Fire Bolt (Skill #36) & Missiles.txt Ground Truth', () => {
|
describe('Issue #385 — Fire Bolt (Skill #36) & Missiles.txt Ground Truth', () => {
|
||||||
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
|
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
|
||||||
|
|
@ -369,117 +368,4 @@ describe('Issue #385 — Fire Bolt (Skill #36) & Missiles.txt Ground Truth', ()
|
||||||
// 2 blast layers + 1 flash core + 8 radial sparks = 11 quads
|
// 2 blast layers + 1 flash core + 8 radial sparks = 11 quads
|
||||||
expect(drawnQuads.length).toBe(11)
|
expect(drawnQuads.length).toBe(11)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('10. Verifies offline baked authentic missile DCC assets and metadata', () => {
|
|
||||||
const publicFireboltPng = path.resolve(__dirname, '../public/missiles/firebolt.png')
|
|
||||||
const publicFireboltJson = path.resolve(__dirname, '../public/missiles/firebolt.json')
|
|
||||||
const publicExplodePng = path.resolve(__dirname, '../public/missiles/fireexplode.png')
|
|
||||||
const publicExplodeJson = path.resolve(__dirname, '../public/missiles/fireexplode.json')
|
|
||||||
|
|
||||||
expect(fs.existsSync(publicFireboltPng)).toBe(true)
|
|
||||||
expect(fs.statSync(publicFireboltPng).size).toBeGreaterThan(50000)
|
|
||||||
|
|
||||||
expect(fs.existsSync(publicFireboltJson)).toBe(true)
|
|
||||||
const fireboltJson = JSON.parse(fs.readFileSync(publicFireboltJson, 'utf-8'))
|
|
||||||
expect(fireboltJson.name).toBe('firebolt')
|
|
||||||
expect(fireboltJson.celFile).toBe('Firebolt')
|
|
||||||
expect(fireboltJson.directions).toBe(16)
|
|
||||||
expect(fireboltJson.framesPerDirection).toBe(5)
|
|
||||||
expect(fireboltJson.groups.length).toBe(16)
|
|
||||||
expect(fireboltJson.groups[0].length).toBe(5)
|
|
||||||
|
|
||||||
expect(fs.existsSync(publicExplodePng)).toBe(true)
|
|
||||||
expect(fs.statSync(publicExplodePng).size).toBeGreaterThan(10000)
|
|
||||||
|
|
||||||
expect(fs.existsSync(publicExplodeJson)).toBe(true)
|
|
||||||
const explodeJson = JSON.parse(fs.readFileSync(publicExplodeJson, 'utf-8'))
|
|
||||||
expect(explodeJson.name).toBe('fireexplode')
|
|
||||||
expect(explodeJson.celFile).toBe('FireArrowExplode2')
|
|
||||||
expect(explodeJson.directions).toBe(1)
|
|
||||||
expect(explodeJson.framesPerDirection).toBe(12)
|
|
||||||
expect(explodeJson.groups[0].length).toBe(12)
|
|
||||||
|
|
||||||
// Check samples/d2-packs mirrors
|
|
||||||
expect(fs.existsSync(path.resolve(__dirname, '../samples/d2-packs/missiles/firebolt.png'))).toBe(true)
|
|
||||||
expect(fs.existsSync(path.resolve(__dirname, '../samples/d2-packs/missiles/fireexplode.png'))).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('11. Renders authentic baked DCC frames with correct direction mapping and sprite-space anchors', () => {
|
|
||||||
const drawnSprites: any[] = []
|
|
||||||
const mockAtlasHandle = {
|
|
||||||
texture: {} as WebGLTexture,
|
|
||||||
width: 512,
|
|
||||||
height: 439,
|
|
||||||
} as AtlasHandle
|
|
||||||
|
|
||||||
const mockRenderer = {
|
|
||||||
draw(frame: any, x: number, y: number, options?: any) {
|
|
||||||
drawnSprites.push({ frame, x, y, options })
|
|
||||||
},
|
|
||||||
drawSolid() {},
|
|
||||||
} as unknown as SpriteRenderer
|
|
||||||
|
|
||||||
// Build loaded art from metadata
|
|
||||||
const fireboltFrames = FIREBOLT_META.groups.map(group =>
|
|
||||||
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
|
||||||
x, y, width, height, anchorX, anchorY,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
const fireboltArt: LoadedMissileArt = {
|
|
||||||
meta: FIREBOLT_META,
|
|
||||||
handle: mockAtlasHandle,
|
|
||||||
frames: fireboltFrames,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shot moving East (+x): vx = 20, vy = 0
|
|
||||||
const dccEast = velocityToDccDirection(20, 0, 16)
|
|
||||||
expect(dccEast).toBeDefined()
|
|
||||||
|
|
||||||
const shot: Projectile = {
|
|
||||||
skillId: '36',
|
|
||||||
missileType: 'firebolt',
|
|
||||||
x: 300,
|
|
||||||
y: 200,
|
|
||||||
vx: 20,
|
|
||||||
vy: 0,
|
|
||||||
damage: 16,
|
|
||||||
ttl: 48,
|
|
||||||
fromPlayer: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
drawFireboltProjectile(mockRenderer, shot, fireboltArt)
|
|
||||||
expect(drawnSprites.length).toBe(1)
|
|
||||||
const drawn = drawnSprites[0]!
|
|
||||||
expect(drawn.options.atlas).toBe(mockAtlasHandle)
|
|
||||||
// Verify sprite is positioned with anchor offset: x + anchorX, y + anchorY
|
|
||||||
expect(drawn.x).toBe(300 + drawn.frame.anchorX)
|
|
||||||
expect(drawn.y).toBe(200 + drawn.frame.anchorY)
|
|
||||||
|
|
||||||
// Verify explosion DCC rendering
|
|
||||||
const explodeFrames = FIREEXPLODE_META.groups.map(group =>
|
|
||||||
group.map(([x, y, width, height, anchorX, anchorY]) => ({
|
|
||||||
x, y, width, height, anchorX, anchorY,
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
const explodeArt: LoadedMissileArt = {
|
|
||||||
meta: FIREEXPLODE_META,
|
|
||||||
handle: mockAtlasHandle,
|
|
||||||
frames: explodeFrames,
|
|
||||||
}
|
|
||||||
|
|
||||||
drawnSprites.length = 0
|
|
||||||
drawFireExplosion(mockRenderer, {
|
|
||||||
missileType: 'fireexplode',
|
|
||||||
x: 350,
|
|
||||||
y: 250,
|
|
||||||
frame: 3,
|
|
||||||
maxFrames: 12,
|
|
||||||
}, explodeArt)
|
|
||||||
|
|
||||||
expect(drawnSprites.length).toBe(1)
|
|
||||||
const expDrawn = drawnSprites[0]!
|
|
||||||
expect(expDrawn.options.atlas).toBe(mockAtlasHandle)
|
|
||||||
expect(expDrawn.x).toBe(350 + expDrawn.frame.anchorX)
|
|
||||||
expect(expDrawn.y).toBe(250 + expDrawn.frame.anchorY)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,473 +0,0 @@
|
||||||
/**
|
|
||||||
* Diablo II v1.13c Ground Items Alt Labels & Ladder Collision Tests (Issue #390).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'
|
|
||||||
import type { GroundItemEntity } from '../src/game/ground-items.ts'
|
|
||||||
import {
|
|
||||||
GROUND_LABEL_QUALITY_COLORS,
|
|
||||||
getGroundItemQualityColor,
|
|
||||||
formatGroundItemLabelText,
|
|
||||||
estimateLabelWidth,
|
|
||||||
doLabelsOverlap,
|
|
||||||
resolveLadderCollisions,
|
|
||||||
computeInitialGroundLabelLayouts,
|
|
||||||
GroundLabelOverlay,
|
|
||||||
type GroundLabelLayout,
|
|
||||||
} from '../src/ui/ground-labels.ts'
|
|
||||||
|
|
||||||
function createMockItem(partial: Partial<GroundItemEntity> = {}): GroundItemEntity {
|
|
||||||
return {
|
|
||||||
id: partial.id ?? 'item-1',
|
|
||||||
item: partial.item ?? {},
|
|
||||||
name: partial.name ?? 'Short Sword',
|
|
||||||
nameZh: partial.nameZh ?? '短剑',
|
|
||||||
quality: partial.quality ?? 'normal',
|
|
||||||
isGold: partial.isGold ?? false,
|
|
||||||
amount: partial.amount ?? 1,
|
|
||||||
invWidth: partial.invWidth ?? 1,
|
|
||||||
invHeight: partial.invHeight ?? 2,
|
|
||||||
dropTime: 1000,
|
|
||||||
x: partial.x ?? 400,
|
|
||||||
y: partial.y ?? 300,
|
|
||||||
cellX: 20,
|
|
||||||
cellY: 15,
|
|
||||||
sparklePhase: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('Ground Items Alt Labels (Issue #390)', () => {
|
|
||||||
describe('Quality Colors & Text Formatting', () => {
|
|
||||||
it('formats gold amounts accurately as authentic Chinese localized text', () => {
|
|
||||||
const gold1 = createMockItem({ isGold: true, amount: 150 })
|
|
||||||
expect(formatGroundItemLabelText(gold1)).toBe('150 金币')
|
|
||||||
|
|
||||||
const gold2 = createMockItem({ isGold: true, amount: 25000 })
|
|
||||||
expect(formatGroundItemLabelText(gold2)).toBe('25000 金币')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('formats item display names preferring nameZh with fallback to name', () => {
|
|
||||||
const item1 = createMockItem({ name: 'Short Sword', nameZh: '短剑' })
|
|
||||||
expect(formatGroundItemLabelText(item1)).toBe('短剑')
|
|
||||||
|
|
||||||
const item2 = createMockItem({ name: 'Grand Charm', nameZh: '' })
|
|
||||||
expect(formatGroundItemLabelText(item2)).toBe('Grand Charm')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('maps all Diablo II 1.13c item qualities to authentic palette colors', () => {
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'normal' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.normal,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'low' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.low,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'superior' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.superior,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'magic' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.magic,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'rare' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.rare,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'set' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.set,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'unique' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.unique,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'craft' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.craft,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ quality: 'rune' }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.rune,
|
|
||||||
)
|
|
||||||
expect(getGroundItemQualityColor(createMockItem({ isGold: true }))).toBe(
|
|
||||||
GROUND_LABEL_QUALITY_COLORS.gold,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Label Width Estimation', () => {
|
|
||||||
it('correctly accounts for both ASCII and CJK wide characters', () => {
|
|
||||||
const asciiW = estimateLabelWidth('OK', 13)
|
|
||||||
const cjkW = estimateLabelWidth('戒指', 13)
|
|
||||||
// CJK characters have greater pixel width than Latin characters for same character count
|
|
||||||
expect(cjkW).toBeGreaterThan(asciiW)
|
|
||||||
expect(asciiW).toBeGreaterThan(20)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Overlap Detection & Layout Calculation', () => {
|
|
||||||
it('projects ground items to initial layouts with natural Y offset', () => {
|
|
||||||
const items = [
|
|
||||||
createMockItem({ id: 'i1', x: 100, y: 200 }),
|
|
||||||
createMockItem({ id: 'i2', x: 300, y: 400 }),
|
|
||||||
]
|
|
||||||
const project = (wx: number, wy: number) => ({ x: wx * 2, y: wy * 2 })
|
|
||||||
const layouts = computeInitialGroundLabelLayouts(items, project, 13, 18)
|
|
||||||
|
|
||||||
expect(layouts.length).toBe(2)
|
|
||||||
expect(layouts[0]!.screenX).toBe(200)
|
|
||||||
expect(layouts[0]!.naturalY).toBe(388) // 200 * 2 - 12
|
|
||||||
expect(layouts[0]!.screenY).toBe(388)
|
|
||||||
expect(layouts[1]!.screenX).toBe(600)
|
|
||||||
expect(layouts[1]!.naturalY).toBe(788)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('filters out items outside viewport when project returns null', () => {
|
|
||||||
const items = [createMockItem({ id: 'i1' })]
|
|
||||||
const layouts = computeInitialGroundLabelLayouts(items, () => null)
|
|
||||||
expect(layouts.length).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('accurately identifies overlapping and non-overlapping label rectangles', () => {
|
|
||||||
const l1: GroundLabelLayout = {
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'A',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
}
|
|
||||||
const l2Overlapping: GroundLabelLayout = {
|
|
||||||
id: '2',
|
|
||||||
itemId: '2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'B',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 110,
|
|
||||||
screenY: 105,
|
|
||||||
naturalY: 105,
|
|
||||||
}
|
|
||||||
const l3Distant: GroundLabelLayout = {
|
|
||||||
id: '3',
|
|
||||||
itemId: '3',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'C',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 300,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(doLabelsOverlap(l1, l2Overlapping)).toBe(true)
|
|
||||||
expect(doLabelsOverlap(l1, l3Distant)).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('Diablo II 1.13c Ladder Collision Avoidance Algorithm', () => {
|
|
||||||
it('returns empty array or single label unchanged', () => {
|
|
||||||
expect(resolveLadderCollisions([])).toEqual([])
|
|
||||||
const single: GroundLabelLayout[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'A',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(resolveLadderCollisions(single)).toEqual(single)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('retains natural positions when labels do not overlap horizontally', () => {
|
|
||||||
const nonOverlapping: GroundLabelLayout[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Left Item',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
itemId: '2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Right Item',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 300,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
const resolved = resolveLadderCollisions(nonOverlapping)
|
|
||||||
expect(resolved[0]!.screenY).toBe(200)
|
|
||||||
expect(resolved[1]!.screenY).toBe(200)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stacks 2 overlapping labels vertically into a clean ladder with zero overlap', () => {
|
|
||||||
const overlapping: GroundLabelLayout[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Item A',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
itemId: '2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Item B',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 105,
|
|
||||||
screenY: 202,
|
|
||||||
naturalY: 202,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const resolved = resolveLadderCollisions(overlapping, 4, 2)
|
|
||||||
expect(resolved.length).toBe(2)
|
|
||||||
|
|
||||||
// Zero overlap verification
|
|
||||||
expect(doLabelsOverlap(resolved[0]!, resolved[1]!, 4, 2)).toBe(false)
|
|
||||||
|
|
||||||
// Vertical distance between centers must be at least height + padY = 20px
|
|
||||||
const distY = Math.abs(resolved[0]!.screenY - resolved[1]!.screenY)
|
|
||||||
expect(distY).toBeGreaterThanOrEqual(20)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resolves dense 5-item loot explosion cluster into an orderly 5-step ladder', () => {
|
|
||||||
const cluster: GroundLabelLayout[] = []
|
|
||||||
for (let i = 0; i < 5; i += 1) {
|
|
||||||
cluster.push({
|
|
||||||
id: `item-${i}`,
|
|
||||||
itemId: `item-${i}`,
|
|
||||||
item: createMockItem({ id: `item-${i}` }),
|
|
||||||
text: `Drop ${i}`,
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 70,
|
|
||||||
height: 18,
|
|
||||||
screenX: 200 + (i % 2) * 5, // all clustered within 5px horizontally
|
|
||||||
screenY: 300,
|
|
||||||
naturalY: 300,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = resolveLadderCollisions(cluster, 4, 2)
|
|
||||||
expect(resolved.length).toBe(5)
|
|
||||||
|
|
||||||
// Assert pairwise zero-overlap across all 5 labels
|
|
||||||
for (let i = 0; i < resolved.length; i += 1) {
|
|
||||||
for (let j = i + 1; j < resolved.length; j += 1) {
|
|
||||||
expect(doLabelsOverlap(resolved[i]!, resolved[j]!, 4, 2)).toBe(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check ladder sorting: positions strictly decreasing (stacking upward)
|
|
||||||
const sortedByY = [...resolved].sort((a, b) => a.screenY - b.screenY)
|
|
||||||
for (let k = 0; k < sortedByY.length - 1; k += 1) {
|
|
||||||
expect(sortedByY[k + 1]!.screenY - sortedByY[k]!.screenY).toBeGreaterThanOrEqual(20)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
it('handles multiple independent clusters simultaneously', () => {
|
|
||||||
const items: GroundLabelLayout[] = [
|
|
||||||
// Cluster 1 around X=100
|
|
||||||
{
|
|
||||||
id: 'c1-1',
|
|
||||||
itemId: 'c1-1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Loot A',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c1-2',
|
|
||||||
itemId: 'c1-2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Loot B',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 102,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
// Cluster 2 around X=500
|
|
||||||
{
|
|
||||||
id: 'c2-1',
|
|
||||||
itemId: 'c2-1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Loot C',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 500,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'c2-2',
|
|
||||||
itemId: 'c2-2',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Loot D',
|
|
||||||
color: '#fff',
|
|
||||||
borderColor: '#fff',
|
|
||||||
width: 50,
|
|
||||||
height: 18,
|
|
||||||
screenX: 503,
|
|
||||||
screenY: 200,
|
|
||||||
naturalY: 200,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
const resolved = resolveLadderCollisions(items)
|
|
||||||
// Both clusters have their own pairwise overlap resolved
|
|
||||||
expect(doLabelsOverlap(resolved[0]!, resolved[1]!)).toBe(false)
|
|
||||||
expect(doLabelsOverlap(resolved[2]!, resolved[3]!)).toBe(false)
|
|
||||||
// Cluster 1 and Cluster 2 remain at their distinct X
|
|
||||||
expect(resolved[0]!.screenX).toBeLessThan(200)
|
|
||||||
expect(resolved[2]!.screenX).toBeGreaterThan(400)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('GroundLabelOverlay DOM Integration', () => {
|
|
||||||
function createMockDomElement(tag = 'div'): any {
|
|
||||||
const listeners: Record<string, ((e: any) => void)[]> = {}
|
|
||||||
const children: any[] = []
|
|
||||||
const el: any = {
|
|
||||||
tagName: tag.toUpperCase(),
|
|
||||||
className: '',
|
|
||||||
textContent: '',
|
|
||||||
style: {},
|
|
||||||
dataset: {},
|
|
||||||
hidden: false,
|
|
||||||
children,
|
|
||||||
appendChild(child: any) {
|
|
||||||
children.push(child)
|
|
||||||
return child
|
|
||||||
},
|
|
||||||
addEventListener(type: string, fn: any) {
|
|
||||||
listeners[type] = listeners[type] ?? []
|
|
||||||
listeners[type].push(fn)
|
|
||||||
},
|
|
||||||
dispatchEvent(event: any) {
|
|
||||||
for (const fn of listeners[event.type] ?? []) {
|
|
||||||
fn(event)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return el
|
|
||||||
}
|
|
||||||
|
|
||||||
const origDocument = (globalThis as any).document
|
|
||||||
beforeAll(() => {
|
|
||||||
;(globalThis as any).document = {
|
|
||||||
createElement: (tag: string) => createMockDomElement(tag),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
afterAll(() => {
|
|
||||||
;(globalThis as any).document = origDocument
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides all pooled label elements when visible is false or layouts empty', () => {
|
|
||||||
const container = createMockDomElement('div')
|
|
||||||
const overlay = new GroundLabelOverlay(container)
|
|
||||||
|
|
||||||
const layout: GroundLabelLayout = {
|
|
||||||
id: '1',
|
|
||||||
itemId: '1',
|
|
||||||
item: createMockItem(),
|
|
||||||
text: 'Short Sword',
|
|
||||||
color: '#d8d8d8',
|
|
||||||
borderColor: '#d8d8d8',
|
|
||||||
width: 60,
|
|
||||||
height: 18,
|
|
||||||
screenX: 100,
|
|
||||||
screenY: 100,
|
|
||||||
naturalY: 100,
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.update(true, [layout])
|
|
||||||
expect(container.children.length).toBe(1)
|
|
||||||
expect(container.children[0].style.display).toBe('block')
|
|
||||||
|
|
||||||
overlay.update(false, [])
|
|
||||||
expect(container.children[0].style.display).toBe('none')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('renders labels with authentic quality colors and triggers pickup callback on click', () => {
|
|
||||||
const container = createMockDomElement('div')
|
|
||||||
const overlay = new GroundLabelOverlay(container)
|
|
||||||
const onPickup = vi.fn()
|
|
||||||
overlay.setOnPickupCallback(onPickup)
|
|
||||||
|
|
||||||
const uniqueItem = createMockItem({
|
|
||||||
id: 'soj',
|
|
||||||
name: 'The Stone of Jordan',
|
|
||||||
nameZh: '乔丹之石',
|
|
||||||
quality: 'unique',
|
|
||||||
})
|
|
||||||
|
|
||||||
const layout: GroundLabelLayout = {
|
|
||||||
id: 'soj',
|
|
||||||
itemId: 'soj',
|
|
||||||
item: uniqueItem,
|
|
||||||
text: '乔丹之石',
|
|
||||||
color: GROUND_LABEL_QUALITY_COLORS.unique!,
|
|
||||||
borderColor: GROUND_LABEL_QUALITY_COLORS.unique!,
|
|
||||||
width: 80,
|
|
||||||
height: 18,
|
|
||||||
screenX: 250,
|
|
||||||
screenY: 150,
|
|
||||||
naturalY: 150,
|
|
||||||
}
|
|
||||||
|
|
||||||
overlay.update(true, [layout])
|
|
||||||
const el = container.children[0]
|
|
||||||
expect(el.textContent).toBe('乔丹之石')
|
|
||||||
expect(el.style.color).toBe(GROUND_LABEL_QUALITY_COLORS.unique)
|
|
||||||
expect(el.style.left).toBe('250px')
|
|
||||||
expect(el.style.top).toBe('150px')
|
|
||||||
|
|
||||||
// Simulate pointerdown
|
|
||||||
el.dispatchEvent({ type: 'pointerdown', stopPropagation: vi.fn(), preventDefault: vi.fn() })
|
|
||||||
expect(onPickup).toHaveBeenCalledWith(uniqueItem)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue