fix(ground-items): authentic ground item icon sprites and eliminate residual white dot

This commit is contained in:
troytt 2026-09-23 05:48:03 +00:00
parent ff347bb946
commit c814376e8a
62 changed files with 37164 additions and 1712 deletions

View File

@ -58,6 +58,11 @@
- 所有技能投射物(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 贴图资产与帧率对齐。
4. **法术投射物透明度与黑边消除铁律 (Missile Transparency & Additive Blending Parity)**:
- 依据 1.13c `Missiles.txt`,所有魔法投射物与爆炸(除普通物理箭矢 `arrow` 外)均声明 `Trans: 1`(Alpha / Additive 加法半透明)。
- MPQ 原版 DCC 动画帧在提取时背景或抗锯齿过渡区存在深黑底色(如调色板索引 172 对应 RGB `(4, 4, 4)`)。若直接生成仅以索引 0 透明的调色板 PNG 并使用标准混合渲染,会在画面上产生明显的黑色方框或黑色毛边瑕疵(Issue #385)。
- **材质级透明处理**:投射物图集必须离线烘焙为 32-bit RGBA PNG(`colorType = 6`),且对 `Trans: 1` 法术图集严格执行零黑边过滤——凡底色或暗色过渡像素(`max(r, g, b) <= 4`)其 Alpha 通道必须强行置为 0,暗部边缘平滑过渡,确保图集中非透明黑像素数量严格为 0。
- **着色器级混合模式**:WebGL `SpriteRenderer` 必须支持并采用加法混合(`blendMode: 'additive'`,即 `gl.blendFunc(gl.SRC_ALPHA, gl.ONE)`),实现法术发光光晕叠加与 100% 零黑边渲染;普通物理投射物(`Trans: 0`)采用常规 Alpha 混合(`gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA`)。
---
@ -68,3 +73,17 @@
2. **严苛静态类型与全量单测**:
- `npm run typecheck` 必须保持 **0 errors**。
- `npx vitest run` 必须保持全量套件 100% 通过(包括 `global-dt1-load.test.ts`, `iso-spawn-void-guard.test.ts`, `variant-distribution.test.ts`, `superuniques-fidelity.test.ts` 等防退化单测)。
---
## 6. 斜视角与地图物理坐标 vs 渲染图标铁律 (Isometric Perspective & Coordinate Disambiguation)
1. **2:1 斜视角投影下地面圆形的几何特征 (2:1 Isometric Projection Parity)**:
- 暗黑破坏神 2 采用 2:1 二等角投影(Isometric / Dimetric Projection,菱形单元格宽 80、高 40,子图元 Sub-tile 宽 16、高 8,垂直/水平轴比 `ISO_GROUND_ASPECT_RATIO = 0.5`)。
- 任何在游戏世界地面平面(Ground Plane)上的圆形或径向扩张技能(如祝福之槌 Blessed Hammer 的阿基米德螺旋线、剧毒新星 Poison Nova 的 360 度圆环扩散、法术爆炸范围 AoE 伤害判定等),在投影到场景/屏幕像素坐标空间时,其几何形态在视觉上**绝对是椭圆**(长轴为水平 $X$,短轴为垂直 $Y$,且短半轴长为长半轴长的 $0.5$ 倍)。
- 严禁在场景坐标中使用各向同性的 1:1 正圆公式计算地面运动轨迹,否则在视觉上会呈现出立在镜头正前方的“空中竖立圆圈”,与 2:1 倾斜地貌产生严重透视断层。
2. **地图物理坐标与渲染图标严格区分 (Map Coordinates vs. Rendered Sprite Icons)**:
- **地图物理坐标(Map / World Simulation Coordinates)**:`projectile.x`, `projectile.y` 以及速度向量 `vx, vy` 计算出的是投射物在地图平面上的物理世界坐标,负责与怪物碰撞盒、地形阻挡掩码(`COLLIDE_MASK_MISSILE`)进行物理检测与命中判定。
- **渲染图标(Rendered Sprite Billboard)**:投射物的视觉图元是基于原版 DCC 导出的 2.5D 公告板(Billboard),其在渲染时由 `drawMissileProjectile` 通过 `shot.x + frame.anchorX`, `shot.y + frame.anchorY` 锚点对齐绘制,并依据切线速度向量 `(vx, vy)` 的朝向动态选取 16/32 方向的 DCC 预烘焙视角图。
- **严禁混淆两者的职责**:绝对禁止通过拉伸/变形渲染图标去强行模拟椭圆,也绝对禁止把渲染图标的像素尺寸误当作物理坐标。渲染图标保持原版 DCC 原始比例,而物理轨迹坐标严格遵循 2:1 地面椭圆数学方程。

View File

@ -0,0 +1,92 @@
## 暗黑破坏神 II (v1.13c) 施法视觉效果 (Cast Overlay) 底层调查与实现设计规范
### 一、 机制调查与 1.13c 真实底层数据 (Ground Truth Invariant)
在暗黑破坏神 II v1.13c 引擎体系(`D2Common.dll`、`D2Client.dll`)中,释放法术时角色脚下涌现并包裹全身的动画效果并非角色自身动作(Action Frame)或飞弹(Missile),而是属于 **Unit Overlay(单位覆盖动画系统)**。
#### 1. 数据驱动关系
1. **`Skills.txt`**:包含列 **`castoverlay`**,定义技能施展时触发的覆盖效果标识(例如技能 36 火弹绑定 `fire_cast_1`)。
2. **`Overlay.txt`**:集中定义所有覆盖效果的物理与渲染属性:
- **`Filename`**:指向 DCC 动画资源路径(位于 `d2data.mpq: data/global/overlays/{Filename}.dcc`)。
- **`Frames`**:动画总帧数。
- **`AnimRate`**:动画播放速率。法术施法特效原版均为 **16**(代表在 25 FPS 逻辑刻下,每 25 tick 播放 16 帧动画,即步长 `16 / 25 = 0.64` 帧/刻,总时长约 0.88 秒)。
- **`Trans`**:混合渲染模式。原版为 **3**(**Additive Blending / 增色叠加混合**),呈现火焰、电光与寒冰的自发光半透明视觉质感。
- **`PreDraw`**:渲染层级。原版均为 **0**(**Foreground / 前景层**),先绘制施法者角色精灵,再在前景叠加该 Overlay,形成火焰包裹躯干的立体层次。
- **`Xoffset` / `Yoffset` / `Height1..4`**:均为 **0**,严格以施法者单位脚底地面中心点 `(0, 0)` 为基准。
- **`InitRadius` / `Radius` / `Red, Green, Blue`**:动态点光源参数(半径均为 9 个子网格)。Overlay 激活期间会在角色周围投射对应属性的动态光晕。
---
### 二、 核心法术原版 Overlay 映射与 DCC 规格表
经解析 `samples/d2/Patch_D2.mpq` 与 `d2data.mpq`,法师三大元素系法术完整映射如下:
| 技能类别 | 技能名称 (Id) | `Skills.txt` `castoverlay` | DCC 文件名 (`data/global/overlays/`) | 帧数 | DCC 碰撞盒 (Left, Top, W, H) | 光照颜色 (R, G, B, Radius) | 视觉特征 |
| :--- | :--- | :--- | :--- | :---: | :--- | :--- | :--- |
| **火焰系** | **火弹 Fire Bolt (36)** | `fire_cast_1` | `FireCast_for_Sorceress.dcc` | 14 | `[-55, -127, 117, 164]` | (255, 178, 64, 9) | **从脚底升腾直窜头顶的火焰柱 (即 Issue 附件效果)** |
| **火焰系** | 火球 (47), 强化/火焰强化 (46), 火墙 (51), 陨石 (56), 九头海蛇 (62) | `fire_cast_2` | `FireCast2.dcc` | 16 | `[-74, -89, 145, 133]` | (255, 178, 64, 9) | 范围更大、旋转包裹的炽热烈焰 |
| **冰霜系** | 冰弹 (39), 冰封甲 (40), 冰风暴 (45) | `ice_cast_1` | `IceCastNew01.dcc` | 15 | `[-49, -90, 97, 55]` | (81, 81, 255, 9) | 脚下微弱旋起的寒冰碎屑与白霜冷雾 |
| **冰霜系** | 霜之新星 (44), 碎冰甲 (50), 冰尖柱 (55) | `ice_cast_2` | `IceCastNew02.dcc` | 15 | `[-58, -100, 115, 123]` | (81, 81, 255, 9) | 向上旋转升腾的深蓝冰柱与霜雾 |
| **冰霜系** | 暴风雪 (59), 寒冰甲 (60), 冰封球 (64) | `ice_cast_3` | `IceCastNew03.dcc` | 16 | `[-63, -113, 127, 148]` | (81, 81, 255, 9) | 强烈的暴风雪霜华与立体冰凌环绕 |
| **闪电系** | 充能弹 (38), 静电场 (42), 新星 (48), 闪电 (49), 连锁闪电 (53) | `light_cast_1` | `LightningCast.dcc` | 10 | `[-79, -107, 164, 144]` | (255, 255, 255, 9) | 脚底向外炸裂并包裹全身的电弧跳跃 |
| **闪电系** | 心灵传动 (43), 雷云风暴 (57), 能量护盾 (58) | `light_cast_2` | `LightningCastRunesFront.dcc` | 10 | `[-73, -154, 147, 190]` | (255, 255, 255, 9) | 闪电符文闪耀与高耸电光柱 |
| **特殊** | 传送 Teleport (54) | `teleport` | `Teleport.dcc` | 18 | `[-61, -108, 136, 154]` | (255, 255, 200, 5) | 金黄色空间坍缩与残影粒子 |
---
### 三、 系统架构与实现方案 (diablo2-web)
遵循项目 **Zero Runtime MPQ Invariant** 及 **Anti-Silent Failures** 原则:
#### 1. 离线资源烘焙流水线 (`scripts/pack-overlays.ts`)
- 提取 `d2data.mpq` 中上述 8 个关键施法 Overlay DCC 精灵文件。
- 读取 `data/global/palette/ACT1/pal.pl2` 进行真实调色板色彩映射。
- 将单向动画打包成 PNG 纹理图集并导出 JSON 元数据至 `public/overlays/` 与 `samples/d2-packs/overlays/`。
- 自动生成类型定义 `src/render/overlays-meta.ts`,提供精确的 `box.left, box.top, width, height, animRate, trans` 常量。
#### 2. 运行时数据契约与实体抽象 (`src/game/skills.ts` & `src/game/engine.ts`)
- 扩展 `SkillDef` 接口,包含 `castOverlay?: string`。
- 建立 `CANONICAL_113C_OVERLAYS` 真值表,包含 overlay 标识到对应资源与物理属性的映射。
- 在 `GameEngine` 中引入 `ActiveOverlay` 活跃实体管理:
```typescript
export interface ActiveOverlay {
readonly id: string
readonly overlayName: string
readonly casterId: string
x: number
y: number
frame: number
readonly maxFrames: number
readonly animRate: number
readonly preDraw: boolean
readonly trans: number
readonly lightRadius: number
readonly lightColor: readonly [number, number, number]
expired: boolean
}
```
- **生命周期规则**:
- 玩家或怪物在 `executePlayerSkillCast` 触发施法动作时,根据技能 `castoverlay` 生成 `ActiveOverlay`。
- 每个逻辑 tick(25 FPS):`overlay.frame += overlay.animRate / 25`。
- 当 `Math.floor(overlay.frame) >= overlay.maxFrames` 时,标记 `expired = true` 并在 tick 结束时销毁(单次播放不循环)。
- Overlay 坐标实时同步施法者当前脚下位置 `(caster.x, caster.y)`。
#### 3. 渲染管线集成 (`src/scene/act-scene.ts`)
- 资源载入:通过 `loadOverlayArtMap` 预载 `overlays/*.png`。
- 深度层级(Z-Order):
- 角色绘制完成后、飞弹绘制前,执行前景 Overlay 渲染。
- 混合模式(Blend Mode):
- 遇到 `trans === 3`,切换 Canvas2D `ctx.globalCompositeOperation = 'lighter'` 或 WebGL Additive 混合状态,绘制完成后恢复 `source-over`。
- 锚点投影(Anchor Projection):
- 精确应用 DCC 包围盒偏移:`drawX = overlay.x + frameMeta.anchorX`,`drawY = overlay.y + frameMeta.anchorY`。
- 动态光照反馈:
- 在 Overlay 存活期间,将其注册为动态点光源(如火焰投射 9 码的 RGB(255, 178, 64) 暖黄光晕)。
---
### 四、 验证与回归测试策略
1. **表格对齐测试**:验证 `parseSkillsTxt` 与 `Skills.txt`、`Overlay.txt` 的 `castoverlay` 字段及参数完全一致。
2. **生命周期测试**:验证 14 帧的 `fire_cast_1` 在 25 FPS 模拟下,在第 22 个 tick 精确结束并移除。
3. **渲染与图集测试**:验证 `FireCast_for_Sorceress`、`IceCastNew01`、`LightningCast` 纹理图集无缺失、锚点边界无裁切、Additive 模式下正确渲染。
4. **全场景稳定性**:自动化 Headless 运行,保障全 136 个关卡环境施法 Overlay 无跨法术贴图污染或未定义崩溃。

331
public/missiles/arrow.json Normal file
View File

@ -0,0 +1,331 @@
{
"name": "arrow",
"celFile": "Arrow",
"width": 512,
"height": 36,
"directions": 32,
"framesPerDirection": 1,
"animSpeed": 16,
"groups": [
[
[
0,
0,
31,
13,
-16,
-56
]
],
[
[
32,
0,
31,
13,
-15,
-55
]
],
[
[
64,
0,
30,
14,
-15,
-55
]
],
[
[
95,
0,
31,
14,
-15,
-56
]
],
[
[
127,
0,
4,
18,
-2,
-58
]
],
[
[
132,
0,
42,
5,
-22,
-52
]
],
[
[
175,
0,
4,
17,
-2,
-57
]
],
[
[
180,
0,
41,
5,
-20,
-51
]
],
[
[
222,
0,
18,
17,
-9,
-58
]
],
[
[
241,
0,
39,
9,
-20,
-54
]
],
[
[
281,
0,
39,
8,
-20,
-53
]
],
[
[
321,
0,
18,
16,
-8,
-57
]
],
[
[
340,
0,
18,
17,
-9,
-57
]
],
[
[
359,
0,
39,
9,
-19,
-53
]
],
[
[
399,
0,
39,
8,
-19,
-53
]
],
[
[
439,
0,
18,
16,
-9,
-57
]
],
[
[
458,
0,
11,
18,
-6,
-58
]
],
[
[
470,
0,
25,
15,
-13,
-57
]
],
[
[
0,
19,
35,
12,
-18,
-56
]
],
[
[
36,
19,
41,
7,
-21,
-53
]
],
[
[
78,
19,
41,
6,
-21,
-52
]
],
[
[
120,
19,
35,
11,
-17,
-54
]
],
[
[
156,
19,
25,
15,
-12,
-56
]
],
[
[
182,
19,
11,
17,
-5,
-57
]
],
[
[
194,
19,
11,
17,
-6,
-57
]
],
[
[
206,
19,
25,
16,
-13,
-56
]
],
[
[
232,
19,
35,
12,
-17,
-54
]
],
[
[
268,
19,
41,
7,
-20,
-52
]
],
[
[
310,
19,
41,
6,
-20,
-52
]
],
[
[
352,
19,
35,
12,
-17,
-55
]
],
[
[
388,
19,
24,
16,
-12,
-57
]
],
[
[
413,
19,
10,
17,
-5,
-57
]
]
]
}

BIN
public/missiles/arrow.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

@ -0,0 +1,811 @@
{
"name": "blessedhammer",
"celFile": "blessedhammer",
"width": 512,
"height": 192,
"directions": 16,
"framesPerDirection": 6,
"animSpeed": 16,
"groups": [
[
[
0,
0,
27,
38,
-13,
-56
],
[
28,
0,
27,
38,
-13,
-56
],
[
56,
0,
27,
38,
-13,
-56
],
[
84,
0,
27,
38,
-13,
-56
],
[
112,
0,
27,
38,
-13,
-56
],
[
140,
0,
27,
38,
-13,
-56
]
],
[
[
168,
0,
26,
33,
-13,
-53
],
[
195,
0,
26,
33,
-13,
-53
],
[
222,
0,
26,
33,
-13,
-53
],
[
249,
0,
26,
33,
-13,
-53
],
[
276,
0,
26,
33,
-13,
-53
],
[
303,
0,
26,
33,
-13,
-53
]
],
[
[
330,
0,
27,
33,
-14,
-54
],
[
358,
0,
27,
33,
-14,
-54
],
[
386,
0,
27,
33,
-14,
-54
],
[
414,
0,
27,
33,
-14,
-54
],
[
442,
0,
27,
33,
-14,
-54
],
[
470,
0,
27,
33,
-14,
-54
]
],
[
[
0,
39,
27,
38,
-13,
-56
],
[
28,
39,
27,
38,
-13,
-56
],
[
56,
39,
27,
38,
-13,
-56
],
[
84,
39,
27,
38,
-13,
-56
],
[
112,
39,
27,
38,
-13,
-56
],
[
140,
39,
27,
38,
-13,
-56
]
],
[
[
168,
39,
7,
38,
-3,
-56
],
[
176,
39,
7,
38,
-3,
-56
],
[
184,
39,
7,
38,
-3,
-56
],
[
192,
39,
7,
38,
-3,
-56
],
[
200,
39,
7,
38,
-3,
-56
],
[
208,
39,
7,
38,
-3,
-56
]
],
[
[
216,
39,
36,
35,
-18,
-54
],
[
253,
39,
36,
35,
-18,
-54
],
[
290,
39,
36,
35,
-18,
-54
],
[
327,
39,
36,
35,
-18,
-54
],
[
364,
39,
36,
35,
-18,
-54
],
[
401,
39,
36,
35,
-18,
-54
]
],
[
[
438,
39,
7,
32,
-4,
-53
],
[
446,
39,
7,
32,
-4,
-53
],
[
454,
39,
7,
32,
-4,
-53
],
[
462,
39,
7,
32,
-4,
-53
],
[
470,
39,
7,
32,
-4,
-53
],
[
478,
39,
7,
32,
-4,
-53
]
],
[
[
0,
78,
36,
35,
-18,
-55
],
[
37,
78,
36,
35,
-18,
-55
],
[
74,
78,
36,
35,
-18,
-55
],
[
111,
78,
36,
35,
-18,
-55
],
[
148,
78,
36,
35,
-18,
-55
],
[
185,
78,
36,
35,
-18,
-55
]
],
[
[
222,
78,
16,
38,
-8,
-56
],
[
239,
78,
16,
38,
-8,
-56
],
[
256,
78,
16,
38,
-8,
-56
],
[
273,
78,
16,
38,
-8,
-56
],
[
290,
78,
16,
38,
-8,
-56
],
[
307,
78,
16,
38,
-8,
-56
]
],
[
[
324,
78,
34,
36,
-17,
-55
],
[
359,
78,
34,
36,
-17,
-55
],
[
394,
78,
34,
36,
-17,
-55
],
[
429,
78,
34,
36,
-17,
-55
],
[
464,
78,
34,
36,
-17,
-55
],
[
0,
117,
34,
36,
-17,
-55
]
],
[
[
35,
117,
34,
34,
-17,
-54
],
[
70,
117,
34,
34,
-17,
-54
],
[
105,
117,
34,
34,
-17,
-54
],
[
140,
117,
34,
34,
-17,
-54
],
[
175,
117,
34,
34,
-17,
-54
],
[
210,
117,
34,
34,
-17,
-54
]
],
[
[
245,
117,
16,
32,
-8,
-53
],
[
262,
117,
16,
32,
-8,
-53
],
[
279,
117,
16,
32,
-8,
-53
],
[
296,
117,
16,
32,
-8,
-53
],
[
313,
117,
16,
32,
-8,
-53
],
[
330,
117,
16,
32,
-8,
-53
]
],
[
[
347,
117,
16,
32,
-9,
-53
],
[
364,
117,
16,
32,
-9,
-53
],
[
381,
117,
16,
32,
-9,
-53
],
[
398,
117,
16,
32,
-9,
-53
],
[
415,
117,
16,
32,
-9,
-53
],
[
432,
117,
16,
32,
-9,
-53
]
],
[
[
449,
117,
33,
34,
-17,
-54
],
[
0,
154,
33,
34,
-17,
-54
],
[
34,
154,
33,
34,
-17,
-54
],
[
68,
154,
33,
34,
-17,
-54
],
[
102,
154,
33,
34,
-17,
-54
],
[
136,
154,
33,
34,
-17,
-54
]
],
[
[
170,
154,
34,
36,
-17,
-55
],
[
205,
154,
34,
36,
-17,
-55
],
[
240,
154,
34,
36,
-17,
-55
],
[
275,
154,
34,
36,
-17,
-55
],
[
310,
154,
34,
36,
-17,
-55
],
[
345,
154,
34,
36,
-17,
-55
]
],
[
[
380,
154,
16,
38,
-7,
-56
],
[
397,
154,
16,
38,
-7,
-56
],
[
414,
154,
16,
38,
-7,
-56
],
[
431,
154,
16,
38,
-7,
-56
],
[
448,
154,
16,
38,
-7,
-56
],
[
465,
154,
16,
38,
-7,
-56
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,811 @@
{
"name": "bonespear",
"celFile": "BoneSpear",
"width": 1024,
"height": 347,
"directions": 16,
"framesPerDirection": 6,
"animSpeed": 16,
"groups": [
[
[
0,
0,
57,
57,
-28,
-68
],
[
58,
0,
57,
57,
-28,
-68
],
[
116,
0,
57,
57,
-28,
-68
],
[
174,
0,
57,
57,
-28,
-68
],
[
232,
0,
57,
57,
-28,
-68
],
[
290,
0,
57,
57,
-28,
-68
]
],
[
[
348,
0,
58,
57,
-27,
-68
],
[
407,
0,
58,
57,
-27,
-68
],
[
466,
0,
58,
57,
-27,
-68
],
[
525,
0,
58,
57,
-27,
-68
],
[
584,
0,
58,
57,
-27,
-68
],
[
643,
0,
58,
57,
-27,
-68
]
],
[
[
702,
0,
60,
57,
-31,
-68
],
[
763,
0,
60,
57,
-31,
-68
],
[
824,
0,
60,
57,
-31,
-68
],
[
885,
0,
60,
57,
-31,
-68
],
[
946,
0,
60,
57,
-31,
-68
],
[
0,
58,
60,
57,
-31,
-68
]
],
[
[
61,
58,
58,
57,
-29,
-68
],
[
120,
58,
58,
57,
-29,
-68
],
[
179,
58,
58,
57,
-29,
-68
],
[
238,
58,
58,
57,
-29,
-68
],
[
297,
58,
58,
57,
-29,
-68
],
[
356,
58,
58,
57,
-29,
-68
]
],
[
[
415,
58,
57,
57,
-28,
-68
],
[
473,
58,
57,
57,
-28,
-68
],
[
531,
58,
57,
57,
-28,
-68
],
[
589,
58,
57,
57,
-28,
-68
],
[
647,
58,
57,
57,
-28,
-68
],
[
705,
58,
57,
57,
-28,
-68
]
],
[
[
763,
58,
68,
57,
-28,
-68
],
[
832,
58,
68,
57,
-28,
-68
],
[
901,
58,
68,
57,
-28,
-68
],
[
0,
116,
68,
57,
-28,
-68
],
[
69,
116,
68,
57,
-28,
-68
],
[
138,
116,
68,
57,
-28,
-68
]
],
[
[
207,
116,
56,
57,
-27,
-68
],
[
264,
116,
56,
57,
-27,
-68
],
[
321,
116,
56,
57,
-27,
-68
],
[
378,
116,
56,
57,
-27,
-68
],
[
435,
116,
56,
57,
-27,
-68
],
[
492,
116,
56,
57,
-27,
-68
]
],
[
[
549,
116,
69,
57,
-40,
-68
],
[
619,
116,
69,
57,
-40,
-68
],
[
689,
116,
69,
57,
-40,
-68
],
[
759,
116,
69,
57,
-40,
-68
],
[
829,
116,
69,
57,
-40,
-68
],
[
899,
116,
69,
57,
-40,
-68
]
],
[
[
0,
174,
57,
57,
-28,
-68
],
[
58,
174,
57,
57,
-28,
-68
],
[
116,
174,
57,
57,
-28,
-68
],
[
174,
174,
57,
57,
-28,
-68
],
[
232,
174,
57,
57,
-28,
-68
],
[
290,
174,
57,
57,
-28,
-68
]
],
[
[
348,
174,
65,
57,
-28,
-68
],
[
414,
174,
65,
57,
-28,
-68
],
[
480,
174,
65,
57,
-28,
-68
],
[
546,
174,
65,
57,
-28,
-68
],
[
612,
174,
65,
57,
-28,
-68
],
[
678,
174,
65,
57,
-28,
-68
]
],
[
[
744,
174,
66,
57,
-28,
-68
],
[
811,
174,
66,
57,
-28,
-68
],
[
878,
174,
66,
57,
-28,
-68
],
[
945,
174,
66,
57,
-28,
-68
],
[
0,
232,
66,
57,
-28,
-68
],
[
67,
232,
66,
57,
-28,
-68
]
],
[
[
134,
232,
56,
57,
-27,
-68
],
[
191,
232,
56,
57,
-27,
-68
],
[
248,
232,
56,
57,
-27,
-68
],
[
305,
232,
56,
57,
-27,
-68
],
[
362,
232,
56,
57,
-27,
-68
],
[
419,
232,
56,
57,
-27,
-68
]
],
[
[
476,
232,
56,
57,
-27,
-68
],
[
533,
232,
56,
57,
-27,
-68
],
[
590,
232,
56,
57,
-27,
-68
],
[
647,
232,
56,
57,
-27,
-68
],
[
704,
232,
56,
57,
-27,
-68
],
[
761,
232,
56,
57,
-27,
-68
]
],
[
[
818,
232,
67,
57,
-38,
-68
],
[
886,
232,
67,
57,
-38,
-68
],
[
954,
232,
67,
57,
-38,
-68
],
[
0,
290,
67,
57,
-38,
-68
],
[
68,
290,
67,
57,
-38,
-68
],
[
136,
290,
67,
57,
-38,
-68
]
],
[
[
204,
290,
66,
57,
-37,
-68
],
[
271,
290,
66,
57,
-37,
-68
],
[
338,
290,
66,
57,
-37,
-68
],
[
405,
290,
66,
57,
-37,
-68
],
[
472,
290,
66,
57,
-37,
-68
],
[
539,
290,
66,
57,
-37,
-68
]
],
[
[
606,
290,
57,
57,
-28,
-68
],
[
664,
290,
57,
57,
-28,
-68
],
[
722,
290,
57,
57,
-28,
-68
],
[
780,
290,
57,
57,
-28,
-68
],
[
838,
290,
57,
57,
-28,
-68
],
[
896,
290,
57,
57,
-28,
-68
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View File

@ -0,0 +1,539 @@
{
"name": "bonespirit",
"celFile": "BoneSpirit",
"width": 2048,
"height": 499,
"directions": 8,
"framesPerDirection": 8,
"animSpeed": 16,
"groups": [
[
[
0,
0,
123,
122,
-56,
-60
],
[
124,
0,
123,
122,
-56,
-60
],
[
248,
0,
123,
122,
-56,
-60
],
[
372,
0,
123,
122,
-56,
-60
],
[
496,
0,
123,
122,
-56,
-60
],
[
620,
0,
123,
122,
-56,
-60
],
[
744,
0,
123,
122,
-56,
-60
],
[
868,
0,
123,
122,
-56,
-60
]
],
[
[
992,
0,
125,
124,
-57,
-55
],
[
1118,
0,
125,
124,
-57,
-55
],
[
1244,
0,
125,
124,
-57,
-55
],
[
1370,
0,
125,
124,
-57,
-55
],
[
1496,
0,
125,
124,
-57,
-55
],
[
1622,
0,
125,
124,
-57,
-55
],
[
1748,
0,
125,
124,
-57,
-55
],
[
1874,
0,
125,
124,
-57,
-55
]
],
[
[
0,
125,
126,
124,
-69,
-55
],
[
127,
125,
126,
124,
-69,
-55
],
[
254,
125,
126,
124,
-69,
-55
],
[
381,
125,
126,
124,
-69,
-55
],
[
508,
125,
126,
124,
-69,
-55
],
[
635,
125,
126,
124,
-69,
-55
],
[
762,
125,
126,
124,
-69,
-55
],
[
889,
125,
126,
124,
-69,
-55
]
],
[
[
1016,
125,
124,
122,
-68,
-60
],
[
1141,
125,
124,
122,
-68,
-60
],
[
1266,
125,
124,
122,
-68,
-60
],
[
1391,
125,
124,
122,
-68,
-60
],
[
1516,
125,
124,
122,
-68,
-60
],
[
1641,
125,
124,
122,
-68,
-60
],
[
1766,
125,
124,
122,
-68,
-60
],
[
1891,
125,
124,
122,
-68,
-60
]
],
[
[
0,
250,
123,
121,
-62,
-61
],
[
124,
250,
123,
121,
-62,
-61
],
[
248,
250,
123,
121,
-62,
-61
],
[
372,
250,
123,
121,
-62,
-61
],
[
496,
250,
123,
121,
-62,
-61
],
[
620,
250,
123,
121,
-62,
-61
],
[
744,
250,
123,
121,
-62,
-61
],
[
868,
250,
123,
121,
-62,
-61
]
],
[
[
992,
250,
124,
123,
-54,
-58
],
[
1117,
250,
124,
123,
-54,
-58
],
[
1242,
250,
124,
123,
-54,
-58
],
[
1367,
250,
124,
123,
-54,
-58
],
[
1492,
250,
124,
123,
-54,
-58
],
[
1617,
250,
124,
123,
-54,
-58
],
[
1742,
250,
124,
123,
-54,
-58
],
[
1867,
250,
124,
123,
-54,
-58
]
],
[
[
0,
374,
127,
125,
-64,
-54
],
[
128,
374,
127,
125,
-64,
-54
],
[
256,
374,
127,
125,
-64,
-54
],
[
384,
374,
127,
125,
-64,
-54
],
[
512,
374,
127,
125,
-64,
-54
],
[
640,
374,
127,
125,
-64,
-54
],
[
768,
374,
127,
125,
-64,
-54
],
[
896,
374,
127,
125,
-64,
-54
]
],
[
[
1024,
374,
125,
123,
-71,
-58
],
[
1150,
374,
125,
123,
-71,
-58
],
[
1276,
374,
125,
123,
-71,
-58
],
[
1402,
374,
125,
123,
-71,
-58
],
[
1528,
374,
125,
123,
-71,
-58
],
[
1654,
374,
125,
123,
-71,
-58
],
[
1780,
374,
125,
123,
-71,
-58
],
[
1906,
374,
125,
123,
-71,
-58
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 298 KiB

View File

@ -0,0 +1,117 @@
{
"name": "bonespiritexplode",
"celFile": "BoneSpiritExplode",
"width": 512,
"height": 569,
"directions": 1,
"framesPerDirection": 13,
"animSpeed": 16,
"groups": [
[
[
0,
0,
128,
113,
-62,
-100
],
[
129,
0,
128,
113,
-62,
-100
],
[
258,
0,
128,
113,
-62,
-100
],
[
0,
114,
128,
113,
-62,
-100
],
[
129,
114,
128,
113,
-62,
-100
],
[
258,
114,
128,
113,
-62,
-100
],
[
0,
228,
128,
113,
-62,
-100
],
[
129,
228,
128,
113,
-62,
-100
],
[
258,
228,
128,
113,
-62,
-100
],
[
0,
342,
128,
113,
-62,
-100
],
[
129,
342,
128,
113,
-62,
-100
],
[
258,
342,
128,
113,
-62,
-100
],
[
0,
456,
128,
113,
-62,
-100
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,133 @@
{
"name": "freezeexplode",
"celFile": "FreezeExplodeCenter",
"width": 1024,
"height": 323,
"directions": 1,
"framesPerDirection": 15,
"animSpeed": 16,
"groups": [
[
[
0,
0,
143,
107,
-75,
-67
],
[
144,
0,
143,
107,
-75,
-67
],
[
288,
0,
143,
107,
-75,
-67
],
[
432,
0,
143,
107,
-75,
-67
],
[
576,
0,
143,
107,
-75,
-67
],
[
720,
0,
143,
107,
-75,
-67
],
[
864,
0,
143,
107,
-75,
-67
],
[
0,
108,
143,
107,
-75,
-67
],
[
144,
108,
143,
107,
-75,
-67
],
[
288,
108,
143,
107,
-75,
-67
],
[
432,
108,
143,
107,
-75,
-67
],
[
576,
108,
143,
107,
-75,
-67
],
[
720,
108,
143,
107,
-75,
-67
],
[
864,
108,
143,
107,
-75,
-67
],
[
0,
216,
143,
107,
-75,
-67
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

View File

@ -0,0 +1,811 @@
{
"name": "glacialspike",
"celFile": "GlacialSpike",
"width": 1024,
"height": 719,
"directions": 16,
"framesPerDirection": 6,
"animSpeed": 16,
"groups": [
[
[
0,
0,
100,
61,
-34,
-88
],
[
101,
0,
100,
61,
-34,
-88
],
[
202,
0,
100,
61,
-34,
-88
],
[
303,
0,
100,
61,
-34,
-88
],
[
404,
0,
100,
61,
-34,
-88
],
[
505,
0,
100,
61,
-34,
-88
]
],
[
[
606,
0,
104,
67,
-31,
-63
],
[
711,
0,
104,
67,
-31,
-63
],
[
816,
0,
104,
67,
-31,
-63
],
[
0,
68,
104,
67,
-31,
-63
],
[
105,
68,
104,
67,
-31,
-63
],
[
210,
68,
104,
67,
-31,
-63
]
],
[
[
315,
68,
110,
68,
-79,
-62
],
[
426,
68,
110,
68,
-79,
-62
],
[
537,
68,
110,
68,
-79,
-62
],
[
648,
68,
110,
68,
-79,
-62
],
[
759,
68,
110,
68,
-79,
-62
],
[
870,
68,
110,
68,
-79,
-62
]
],
[
[
0,
137,
96,
59,
-62,
-86
],
[
97,
137,
96,
59,
-62,
-86
],
[
194,
137,
96,
59,
-62,
-86
],
[
291,
137,
96,
59,
-62,
-86
],
[
388,
137,
96,
59,
-62,
-86
],
[
485,
137,
96,
59,
-62,
-86
]
],
[
[
582,
137,
37,
73,
-19,
-94
],
[
620,
137,
37,
73,
-19,
-94
],
[
658,
137,
37,
73,
-19,
-94
],
[
696,
137,
37,
73,
-19,
-94
],
[
734,
137,
37,
73,
-19,
-94
],
[
772,
137,
37,
73,
-19,
-94
]
],
[
[
810,
137,
134,
48,
-44,
-71
],
[
0,
211,
134,
48,
-44,
-71
],
[
135,
211,
134,
48,
-44,
-71
],
[
270,
211,
134,
48,
-44,
-71
],
[
405,
211,
134,
48,
-44,
-71
],
[
540,
211,
134,
48,
-44,
-71
]
],
[
[
675,
211,
45,
83,
-21,
-67
],
[
721,
211,
45,
83,
-21,
-67
],
[
767,
211,
45,
83,
-21,
-67
],
[
813,
211,
45,
83,
-21,
-67
],
[
859,
211,
45,
83,
-21,
-67
],
[
905,
211,
45,
83,
-21,
-67
]
],
[
[
0,
295,
129,
48,
-86,
-69
],
[
130,
295,
129,
48,
-86,
-69
],
[
260,
295,
129,
48,
-86,
-69
],
[
390,
295,
129,
48,
-86,
-69
],
[
520,
295,
129,
48,
-86,
-69
],
[
650,
295,
129,
48,
-86,
-69
]
],
[
[
780,
295,
65,
70,
-22,
-93
],
[
846,
295,
65,
70,
-22,
-93
],
[
912,
295,
65,
70,
-22,
-93
],
[
0,
366,
65,
70,
-22,
-93
],
[
66,
366,
65,
70,
-22,
-93
],
[
132,
366,
65,
70,
-22,
-93
]
],
[
[
198,
366,
124,
50,
-41,
-81
],
[
323,
366,
124,
50,
-41,
-81
],
[
448,
366,
124,
50,
-41,
-81
],
[
573,
366,
124,
50,
-41,
-81
],
[
698,
366,
124,
50,
-41,
-81
],
[
823,
366,
124,
50,
-41,
-81
]
],
[
[
0,
437,
124,
51,
-39,
-60
],
[
125,
437,
124,
51,
-39,
-60
],
[
250,
437,
124,
51,
-39,
-60
],
[
375,
437,
124,
51,
-39,
-60
],
[
500,
437,
124,
51,
-39,
-60
],
[
625,
437,
124,
51,
-39,
-60
]
],
[
[
750,
437,
71,
78,
-20,
-65
],
[
822,
437,
71,
78,
-20,
-65
],
[
894,
437,
71,
78,
-20,
-65
],
[
0,
516,
71,
78,
-20,
-65
],
[
72,
516,
71,
78,
-20,
-65
],
[
144,
516,
71,
78,
-20,
-65
]
],
[
[
216,
516,
74,
79,
-54,
-65
],
[
291,
516,
74,
79,
-54,
-65
],
[
366,
516,
74,
79,
-54,
-65
],
[
441,
516,
74,
79,
-54,
-65
],
[
516,
516,
74,
79,
-54,
-65
],
[
591,
516,
74,
79,
-54,
-65
]
],
[
[
666,
516,
129,
52,
-90,
-59
],
[
796,
516,
129,
52,
-90,
-59
],
[
0,
596,
129,
52,
-90,
-59
],
[
130,
596,
129,
52,
-90,
-59
],
[
260,
596,
129,
52,
-90,
-59
],
[
390,
596,
129,
52,
-90,
-59
]
],
[
[
520,
596,
120,
46,
-78,
-78
],
[
641,
596,
120,
46,
-78,
-78
],
[
762,
596,
120,
46,
-78,
-78
],
[
883,
596,
120,
46,
-78,
-78
],
[
0,
649,
120,
46,
-78,
-78
],
[
121,
649,
120,
46,
-78,
-78
]
],
[
[
242,
649,
65,
70,
-43,
-93
],
[
308,
649,
65,
70,
-43,
-93
],
[
374,
649,
65,
70,
-43,
-93
],
[
440,
649,
65,
70,
-43,
-93
],
[
506,
649,
65,
70,
-43,
-93
],
[
572,
649,
65,
70,
-43,
-93
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

View File

@ -0,0 +1,141 @@
{
"name": "holybolt",
"celFile": "HolyBoltMissile",
"width": 512,
"height": 137,
"directions": 1,
"framesPerDirection": 16,
"animSpeed": 16,
"groups": [
[
[
0,
0,
79,
45,
-39,
-59
],
[
80,
0,
79,
45,
-39,
-59
],
[
160,
0,
79,
45,
-39,
-59
],
[
240,
0,
79,
45,
-39,
-59
],
[
320,
0,
79,
45,
-39,
-59
],
[
400,
0,
79,
45,
-39,
-59
],
[
0,
46,
79,
45,
-39,
-59
],
[
80,
46,
79,
45,
-39,
-59
],
[
160,
46,
79,
45,
-39,
-59
],
[
240,
46,
79,
45,
-39,
-59
],
[
320,
46,
79,
45,
-39,
-59
],
[
400,
46,
79,
45,
-39,
-59
],
[
0,
92,
79,
45,
-39,
-59
],
[
80,
92,
79,
45,
-39,
-59
],
[
160,
92,
79,
45,
-39,
-59
],
[
240,
92,
79,
45,
-39,
-59
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@ -0,0 +1,683 @@
{
"name": "iceblast",
"celFile": "IceBlast",
"width": 1024,
"height": 431,
"directions": 16,
"framesPerDirection": 5,
"animSpeed": 16,
"groups": [
[
[
0,
0,
74,
54,
-16,
-71
],
[
75,
0,
74,
54,
-16,
-71
],
[
150,
0,
74,
54,
-16,
-71
],
[
225,
0,
74,
54,
-16,
-71
],
[
300,
0,
74,
54,
-16,
-71
]
],
[
[
375,
0,
85,
56,
-14,
-51
],
[
461,
0,
85,
56,
-14,
-51
],
[
547,
0,
85,
56,
-14,
-51
],
[
633,
0,
85,
56,
-14,
-51
],
[
719,
0,
85,
56,
-14,
-51
]
],
[
[
805,
0,
86,
51,
-70,
-51
],
[
892,
0,
86,
51,
-70,
-51
],
[
0,
57,
86,
51,
-70,
-51
],
[
87,
57,
86,
51,
-70,
-51
],
[
174,
57,
86,
51,
-70,
-51
]
],
[
[
261,
57,
72,
54,
-58,
-72
],
[
334,
57,
72,
54,
-58,
-72
],
[
407,
57,
72,
54,
-58,
-72
],
[
480,
57,
72,
54,
-58,
-72
],
[
553,
57,
72,
54,
-58,
-72
]
],
[
[
626,
57,
45,
59,
-23,
-78
],
[
672,
57,
45,
59,
-23,
-78
],
[
718,
57,
45,
59,
-23,
-78
],
[
764,
57,
45,
59,
-23,
-78
],
[
810,
57,
45,
59,
-23,
-78
]
],
[
[
856,
57,
100,
41,
-16,
-54
],
[
0,
117,
100,
41,
-16,
-54
],
[
101,
117,
100,
41,
-16,
-54
],
[
202,
117,
100,
41,
-16,
-54
],
[
303,
117,
100,
41,
-16,
-54
]
],
[
[
404,
117,
51,
67,
-26,
-51
],
[
456,
117,
51,
67,
-26,
-51
],
[
508,
117,
51,
67,
-26,
-51
],
[
560,
117,
51,
67,
-26,
-51
],
[
612,
117,
51,
67,
-26,
-51
]
],
[
[
664,
117,
101,
45,
-85,
-55
],
[
766,
117,
101,
45,
-85,
-55
],
[
868,
117,
101,
45,
-85,
-55
],
[
0,
185,
101,
45,
-85,
-55
],
[
102,
185,
101,
45,
-85,
-55
]
],
[
[
204,
185,
53,
57,
-17,
-76
],
[
258,
185,
53,
57,
-17,
-76
],
[
312,
185,
53,
57,
-17,
-76
],
[
366,
185,
53,
57,
-17,
-76
],
[
420,
185,
53,
57,
-17,
-76
]
],
[
[
474,
185,
91,
48,
-16,
-63
],
[
566,
185,
91,
48,
-16,
-63
],
[
658,
185,
91,
48,
-16,
-63
],
[
750,
185,
91,
48,
-16,
-63
],
[
842,
185,
91,
48,
-16,
-63
]
],
[
[
0,
243,
99,
42,
-15,
-50
],
[
100,
243,
99,
42,
-15,
-50
],
[
200,
243,
99,
42,
-15,
-50
],
[
300,
243,
99,
42,
-15,
-50
],
[
400,
243,
99,
42,
-15,
-50
]
],
[
[
500,
243,
63,
65,
-15,
-51
],
[
564,
243,
63,
65,
-15,
-51
],
[
628,
243,
63,
65,
-15,
-51
],
[
692,
243,
63,
65,
-15,
-51
],
[
756,
243,
63,
65,
-15,
-51
]
],
[
[
820,
243,
64,
62,
-47,
-51
],
[
885,
243,
64,
62,
-47,
-51
],
[
950,
243,
64,
62,
-47,
-51
],
[
0,
309,
64,
62,
-47,
-51
],
[
65,
309,
64,
62,
-47,
-51
]
],
[
[
130,
309,
99,
45,
-83,
-50
],
[
230,
309,
99,
45,
-83,
-50
],
[
330,
309,
99,
45,
-83,
-50
],
[
430,
309,
99,
45,
-83,
-50
],
[
530,
309,
99,
45,
-83,
-50
]
],
[
[
630,
309,
90,
49,
-75,
-65
],
[
721,
309,
90,
49,
-75,
-65
],
[
812,
309,
90,
49,
-75,
-65
],
[
903,
309,
90,
49,
-75,
-65
],
[
0,
372,
90,
49,
-75,
-65
]
],
[
[
91,
372,
52,
59,
-38,
-77
],
[
144,
372,
52,
59,
-38,
-77
],
[
197,
372,
52,
59,
-38,
-77
],
[
250,
372,
52,
59,
-38,
-77
],
[
303,
372,
52,
59,
-38,
-77
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

View File

@ -0,0 +1,811 @@
{
"name": "icebolt",
"celFile": "Icebolt",
"width": 512,
"height": 357,
"directions": 16,
"framesPerDirection": 6,
"animSpeed": 16,
"groups": [
[
[
0,
0,
51,
27,
-18,
-65
],
[
52,
0,
51,
27,
-18,
-65
],
[
104,
0,
51,
27,
-18,
-65
],
[
156,
0,
51,
27,
-18,
-65
],
[
208,
0,
51,
27,
-18,
-65
],
[
260,
0,
51,
27,
-18,
-65
]
],
[
[
312,
0,
55,
28,
-17,
-53
],
[
368,
0,
55,
28,
-17,
-53
],
[
424,
0,
55,
28,
-17,
-53
],
[
0,
29,
55,
28,
-17,
-53
],
[
56,
29,
55,
28,
-17,
-53
],
[
112,
29,
55,
28,
-17,
-53
]
],
[
[
168,
29,
54,
34,
-37,
-53
],
[
223,
29,
54,
34,
-37,
-53
],
[
278,
29,
54,
34,
-37,
-53
],
[
333,
29,
54,
34,
-37,
-53
],
[
388,
29,
54,
34,
-37,
-53
],
[
443,
29,
54,
34,
-37,
-53
]
],
[
[
0,
64,
53,
29,
-35,
-67
],
[
54,
64,
53,
29,
-35,
-67
],
[
108,
64,
53,
29,
-35,
-67
],
[
162,
64,
53,
29,
-35,
-67
],
[
216,
64,
53,
29,
-35,
-67
],
[
270,
64,
53,
29,
-35,
-67
]
],
[
[
324,
64,
16,
37,
-8,
-71
],
[
341,
64,
16,
37,
-8,
-71
],
[
358,
64,
16,
37,
-8,
-71
],
[
375,
64,
16,
37,
-8,
-71
],
[
392,
64,
16,
37,
-8,
-71
],
[
409,
64,
16,
37,
-8,
-71
]
],
[
[
426,
64,
70,
21,
-24,
-58
],
[
0,
102,
70,
21,
-24,
-58
],
[
71,
102,
70,
21,
-24,
-58
],
[
142,
102,
70,
21,
-24,
-58
],
[
213,
102,
70,
21,
-24,
-58
],
[
284,
102,
70,
21,
-24,
-58
]
],
[
[
355,
102,
18,
39,
-9,
-55
],
[
374,
102,
18,
39,
-9,
-55
],
[
393,
102,
18,
39,
-9,
-55
],
[
412,
102,
18,
39,
-9,
-55
],
[
431,
102,
18,
39,
-9,
-55
],
[
450,
102,
18,
39,
-9,
-55
]
],
[
[
0,
142,
70,
25,
-46,
-57
],
[
71,
142,
70,
25,
-46,
-57
],
[
142,
142,
70,
25,
-46,
-57
],
[
213,
142,
70,
25,
-46,
-57
],
[
284,
142,
70,
25,
-46,
-57
],
[
355,
142,
70,
25,
-46,
-57
]
],
[
[
426,
142,
29,
36,
-10,
-71
],
[
456,
142,
29,
36,
-10,
-71
],
[
0,
179,
29,
36,
-10,
-71
],
[
30,
179,
29,
36,
-10,
-71
],
[
60,
179,
29,
36,
-10,
-71
],
[
90,
179,
29,
36,
-10,
-71
]
],
[
[
120,
179,
66,
24,
-23,
-64
],
[
187,
179,
66,
24,
-23,
-64
],
[
254,
179,
66,
24,
-23,
-64
],
[
321,
179,
66,
24,
-23,
-64
],
[
388,
179,
66,
24,
-23,
-64
],
[
0,
216,
66,
24,
-23,
-64
]
],
[
[
67,
216,
66,
20,
-22,
-51
],
[
134,
216,
66,
20,
-22,
-51
],
[
201,
216,
66,
20,
-22,
-51
],
[
268,
216,
66,
20,
-22,
-51
],
[
335,
216,
66,
20,
-22,
-51
],
[
402,
216,
66,
20,
-22,
-51
]
],
[
[
469,
216,
35,
36,
-9,
-55
],
[
0,
253,
35,
36,
-9,
-55
],
[
36,
253,
35,
36,
-9,
-55
],
[
72,
253,
35,
36,
-9,
-55
],
[
108,
253,
35,
36,
-9,
-55
],
[
144,
253,
35,
36,
-9,
-55
]
],
[
[
180,
253,
31,
39,
-22,
-55
],
[
212,
253,
31,
39,
-22,
-55
],
[
244,
253,
31,
39,
-22,
-55
],
[
276,
253,
31,
39,
-22,
-55
],
[
308,
253,
31,
39,
-22,
-55
],
[
340,
253,
31,
39,
-22,
-55
]
],
[
[
372,
253,
67,
27,
-45,
-52
],
[
440,
253,
67,
27,
-45,
-52
],
[
0,
293,
67,
27,
-45,
-52
],
[
68,
293,
67,
27,
-45,
-52
],
[
136,
293,
67,
27,
-45,
-52
],
[
204,
293,
67,
27,
-45,
-52
]
],
[
[
272,
293,
66,
23,
-43,
-62
],
[
339,
293,
66,
23,
-43,
-62
],
[
406,
293,
66,
23,
-43,
-62
],
[
0,
321,
66,
23,
-43,
-62
],
[
67,
321,
66,
23,
-43,
-62
],
[
134,
321,
66,
23,
-43,
-62
]
],
[
[
201,
321,
33,
36,
-23,
-71
],
[
235,
321,
33,
36,
-23,
-71
],
[
269,
321,
33,
36,
-23,
-71
],
[
303,
321,
33,
36,
-23,
-71
],
[
337,
321,
33,
36,
-23,
-71
],
[
371,
321,
33,
36,
-23,
-71
]
]
]
}

BIN
public/missiles/icebolt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1,141 @@
{
"name": "iceexplode",
"celFile": "IceArrowExplode",
"width": 512,
"height": 332,
"directions": 1,
"framesPerDirection": 16,
"animSpeed": 16,
"groups": [
[
[
0,
0,
83,
110,
-43,
-90
],
[
84,
0,
83,
110,
-43,
-90
],
[
168,
0,
83,
110,
-43,
-90
],
[
252,
0,
83,
110,
-43,
-90
],
[
336,
0,
83,
110,
-43,
-90
],
[
420,
0,
83,
110,
-43,
-90
],
[
0,
111,
83,
110,
-43,
-90
],
[
84,
111,
83,
110,
-43,
-90
],
[
168,
111,
83,
110,
-43,
-90
],
[
252,
111,
83,
110,
-43,
-90
],
[
336,
111,
83,
110,
-43,
-90
],
[
420,
111,
83,
110,
-43,
-90
],
[
0,
222,
83,
110,
-43,
-90
],
[
84,
222,
83,
110,
-43,
-90
],
[
168,
222,
83,
110,
-43,
-90
],
[
252,
222,
83,
110,
-43,
-90
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,683 @@
{
"name": "lightningjavelin",
"celFile": "LightningJavelin",
"width": 1024,
"height": 642,
"directions": 16,
"framesPerDirection": 5,
"animSpeed": 16,
"groups": [
[
[
0,
0,
100,
63,
-42,
-83
],
[
101,
0,
100,
63,
-42,
-83
],
[
202,
0,
100,
63,
-42,
-83
],
[
303,
0,
100,
63,
-42,
-83
],
[
404,
0,
100,
63,
-42,
-83
]
],
[
[
505,
0,
111,
67,
-39,
-89
],
[
617,
0,
111,
67,
-39,
-89
],
[
729,
0,
111,
67,
-39,
-89
],
[
841,
0,
111,
67,
-39,
-89
],
[
0,
68,
111,
67,
-39,
-89
]
],
[
[
112,
68,
108,
67,
-68,
-89
],
[
221,
68,
108,
67,
-68,
-89
],
[
330,
68,
108,
67,
-68,
-89
],
[
439,
68,
108,
67,
-68,
-89
],
[
548,
68,
108,
67,
-68,
-89
]
],
[
[
657,
68,
101,
62,
-60,
-82
],
[
759,
68,
101,
62,
-60,
-82
],
[
861,
68,
101,
62,
-60,
-82
],
[
0,
136,
101,
62,
-60,
-82
],
[
102,
136,
101,
62,
-60,
-82
]
],
[
[
204,
136,
60,
71,
-31,
-89
],
[
265,
136,
60,
71,
-31,
-89
],
[
326,
136,
60,
71,
-31,
-89
],
[
387,
136,
60,
71,
-31,
-89
],
[
448,
136,
60,
71,
-31,
-89
]
],
[
[
509,
136,
133,
60,
-45,
-86
],
[
643,
136,
133,
60,
-45,
-86
],
[
777,
136,
133,
60,
-45,
-86
],
[
0,
208,
133,
60,
-45,
-86
],
[
134,
208,
133,
60,
-45,
-86
]
],
[
[
268,
208,
56,
79,
-27,
-90
],
[
325,
208,
56,
79,
-27,
-90
],
[
382,
208,
56,
79,
-27,
-90
],
[
439,
208,
56,
79,
-27,
-90
],
[
496,
208,
56,
79,
-27,
-90
]
],
[
[
553,
208,
131,
60,
-86,
-85
],
[
685,
208,
131,
60,
-86,
-85
],
[
817,
208,
131,
60,
-86,
-85
],
[
0,
288,
131,
60,
-86,
-85
],
[
132,
288,
131,
60,
-86,
-85
]
],
[
[
264,
288,
71,
69,
-37,
-88
],
[
336,
288,
71,
69,
-37,
-88
],
[
408,
288,
71,
69,
-37,
-88
],
[
480,
288,
71,
69,
-37,
-88
],
[
552,
288,
71,
69,
-37,
-88
]
],
[
[
624,
288,
123,
61,
-45,
-84
],
[
748,
288,
123,
61,
-45,
-84
],
[
872,
288,
123,
61,
-45,
-84
],
[
0,
358,
123,
61,
-45,
-84
],
[
124,
358,
123,
61,
-45,
-84
]
],
[
[
248,
358,
129,
60,
-43,
-88
],
[
378,
358,
129,
60,
-43,
-88
],
[
508,
358,
129,
60,
-43,
-88
],
[
638,
358,
129,
60,
-43,
-88
],
[
768,
358,
129,
60,
-43,
-88
]
],
[
[
898,
358,
78,
76,
-33,
-90
],
[
0,
435,
78,
76,
-33,
-90
],
[
79,
435,
78,
76,
-33,
-90
],
[
158,
435,
78,
76,
-33,
-90
],
[
237,
435,
78,
76,
-33,
-90
]
],
[
[
316,
435,
76,
76,
-41,
-90
],
[
393,
435,
76,
76,
-41,
-90
],
[
470,
435,
76,
76,
-41,
-90
],
[
547,
435,
76,
76,
-41,
-90
],
[
624,
435,
76,
76,
-41,
-90
]
],
[
[
701,
435,
127,
59,
-84,
-87
],
[
829,
435,
127,
59,
-84,
-87
],
[
0,
512,
127,
59,
-84,
-87
],
[
128,
512,
127,
59,
-84,
-87
],
[
256,
512,
127,
59,
-84,
-87
]
],
[
[
384,
512,
121,
61,
-77,
-83
],
[
506,
512,
121,
61,
-77,
-83
],
[
628,
512,
121,
61,
-77,
-83
],
[
750,
512,
121,
61,
-77,
-83
],
[
872,
512,
121,
61,
-77,
-83
]
],
[
[
0,
574,
71,
68,
-36,
-86
],
[
72,
574,
71,
68,
-36,
-86
],
[
144,
574,
71,
68,
-36,
-86
],
[
216,
574,
71,
68,
-36,
-86
],
[
288,
574,
71,
68,
-36,
-86
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

7755
public/missiles/teeth.json Normal file

File diff suppressed because it is too large Load Diff

BIN
public/missiles/teeth.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

View File

@ -0,0 +1,117 @@
{
"name": "teethexplode",
"celFile": "teethexplode",
"width": 512,
"height": 427,
"directions": 1,
"framesPerDirection": 13,
"animSpeed": 16,
"groups": [
[
[
0,
0,
105,
106,
-52,
-100
],
[
106,
0,
105,
106,
-52,
-100
],
[
212,
0,
105,
106,
-52,
-100
],
[
318,
0,
105,
106,
-52,
-100
],
[
0,
107,
105,
106,
-52,
-100
],
[
106,
107,
105,
106,
-52,
-100
],
[
212,
107,
105,
106,
-52,
-100
],
[
318,
107,
105,
106,
-52,
-100
],
[
0,
214,
105,
106,
-52,
-100
],
[
106,
214,
105,
106,
-52,
-100
],
[
212,
214,
105,
106,
-52,
-100
],
[
318,
214,
105,
106,
-52,
-100
],
[
0,
321,
105,
106,
-52,
-100
]
]
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -1,10 +1,13 @@
/**
* 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)
* Extracts authentic Diablo II v1.13c missile DCC sprites from MPQ archives:
* - Fire: Firebolt, Fireball, FireArrowExplode2
* - Cold: Icebolt, IceBlast, GlacialSpike, IceArrowExplode, FreezeExplodeCenter
* - Lightning: ChargedBolt
* - Necromancer: teethMissile, teethexplode, BoneSpear, BoneSpirit, BoneSpiritExplode, poisonNova
* - Paladin: HolyBoltMissile, blessedhammer
* - Amazon: SafeArrow, FireArrow, IceArrow, Arrow, LightningJavelin
*
* Bakes them into indexed-color PNG atlases and JSON metadata under:
* - public/missiles/
@ -20,7 +23,7 @@ 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 { encodeRgbaPng } from './png.ts'
import type { SpriteFrame, SpriteSheet } from '../src/formats/sprite.ts'
export interface MissileFrameMeta {
@ -53,18 +56,196 @@ export interface MissileMeta {
}
const MISSILE_TARGETS = [
// Fire spells & explosions
{ name: 'firebolt', celFile: 'Firebolt', animSpeed: 16 },
{ name: 'fireball', celFile: 'Fireball', animSpeed: 16 },
{ name: 'fireexplode', celFile: 'FireArrowExplode2', animSpeed: 16 },
// Ice spells & explosions
{ name: 'icebolt', celFile: 'Icebolt', animSpeed: 16 },
{ name: 'iceblast', celFile: 'IceBlast', animSpeed: 16 },
{ name: 'glacialspike', celFile: 'GlacialSpike', animSpeed: 16 },
{ name: 'iceexplode', celFile: 'IceArrowExplode', animSpeed: 16 },
{ name: 'freezeexplode', celFile: 'FreezeExplodeCenter', animSpeed: 16 },
// Lightning
{ name: 'chargedbolt', celFile: 'ChargedBolt', animSpeed: 16 },
// Necromancer
{ name: 'teeth', celFile: 'teethMissile', animSpeed: 16 },
{ name: 'teethexplode', celFile: 'teethexplode', animSpeed: 16 },
{ name: 'bonespear', celFile: 'BoneSpear', animSpeed: 16 },
{ name: 'bonespirit', celFile: 'BoneSpirit', animSpeed: 16 },
{ name: 'bonespiritexplode', celFile: 'BoneSpiritExplode', animSpeed: 16 },
{ name: 'poisonnova', celFile: 'poisonNova', animSpeed: 16 },
// Paladin
{ name: 'holybolt', celFile: 'HolyBoltMissile', animSpeed: 16 },
{ name: 'blessedhammer', celFile: 'blessedhammer', animSpeed: 16 },
// Amazon
{ name: 'magicarrow', celFile: 'SafeArrow', animSpeed: 16 },
{ name: 'firearrow', celFile: 'FireArrow', animSpeed: 16 },
{ name: 'icearrow', celFile: 'IceArrow', animSpeed: 16 },
{ name: 'arrow', celFile: 'Arrow', animSpeed: 16, trans: 0 },
{ name: 'lightningjavelin', celFile: 'LightningJavelin', 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 PAGE_SIZE = 4096
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))
/**
* Shelf-pack all frames of a missile SpriteSheet into a 32-bit RGBA PNG buffer.
* Enforces Diablo II 1.13c Trans=1 transparency:
* - Pure black / background antialiasing fringe pixels (max(r,g,b) <= 4 or idx 172) are strictly transparent (alpha=0).
* - Semi-dark edge pixels ramp smoothly so no black borders or dark smudge boxes appear over the ground.
* - Core pixels remain solid (alpha=255) for vibrant spell rendering.
*/
export function packMissileSheetToRgbaPng(
sheet: SpriteSheet,
palette: Uint8Array,
trans: number = 1,
): {
png: Uint8Array
width: number
height: number
groups: [number, number, number, number, number, number][][]
} {
const frames = sheet.groups.flatMap(g => g.frames)
const widest = Math.max(1, ...frames.map(f => f.width))
const area = frames.reduce((sum, f) => sum + (f.width + 1) * (f.height + 1), 0)
let pageWidth = Math.min(PAGE_SIZE, Math.max(widest + 2, 512))
while (pageWidth < PAGE_SIZE && pageWidth * pageWidth < area * 1.15) {
pageWidth *= 2
}
pageWidth = Math.min(PAGE_SIZE, pageWidth)
let cursorX = 0
let shelfY = 0
let shelfHeight = 0
const placements: { x: number; y: number; width: number; height: number }[] = []
for (const frame of frames) {
const w = Math.max(1, Math.min(frame.width, pageWidth))
const h = Math.max(1, Math.min(frame.height, PAGE_SIZE))
if (cursorX + w > pageWidth) {
shelfY += shelfHeight + 1
shelfHeight = 0
cursorX = 0
}
placements.push({ x: cursorX, y: shelfY, width: w, height: h })
cursorX += w + 1
shelfHeight = Math.max(shelfHeight, h)
}
const pageHeight = Math.max(1, shelfY + shelfHeight)
const rgba = new Uint8Array(pageWidth * pageHeight * 4)
let idx = 0
for (const frame of frames) {
const place = placements[idx]!
idx += 1
for (let row = 0; row < place.height; row += 1) {
const fromRow = row * frame.width
const toRow = (place.y + row) * pageWidth + place.x
for (let col = 0; col < place.width; col += 1) {
const srcAt = fromRow + col
const dst = (toRow + col) * 4
if (frame.mask[srcAt] === 0) {
rgba[dst] = 0
rgba[dst + 1] = 0
rgba[dst + 2] = 0
rgba[dst + 3] = 0
} else {
const val = frame.indices[srcAt]!
if (val === 0) {
rgba[dst] = 0
rgba[dst + 1] = 0
rgba[dst + 2] = 0
rgba[dst + 3] = 0
} else {
const r = palette[val * 3]!
const g = palette[val * 3 + 1]!
const b = palette[val * 3 + 2]!
if (trans === 1) {
const m = Math.max(r, g, b)
if (m <= 4) {
rgba[dst] = 0
rgba[dst + 1] = 0
rgba[dst + 2] = 0
rgba[dst + 3] = 0
} else {
const alpha = Math.min(255, Math.round(((m - 4) / (60 - 4)) * 255))
rgba[dst] = r
rgba[dst + 1] = g
rgba[dst + 2] = b
rgba[dst + 3] = alpha
}
} else {
rgba[dst] = r
rgba[dst + 1] = g
rgba[dst + 2] = b
rgba[dst + 3] = 255
}
}
}
}
}
}
const groups: [number, number, number, number, number, number][][] = []
let consumed = 0
for (const group of sheet.groups) {
const gList: [number, number, number, number, number, number][] = []
for (let i = 0; i < group.frames.length; i += 1) {
const p = placements[consumed]!
const f = group.frames[i]!
consumed += 1
gList.push([p.x, p.y, p.width, p.height, f.anchorX ?? 0, f.anchorY ?? 0])
}
groups.push(gList)
}
const png = encodeRgbaPng({ width: pageWidth, height: pageHeight, pixels: rgba })
return { png, width: pageWidth, height: pageHeight, groups }
}
function formatMetaTs(meta: MissileMeta): string {
const groupsStr = meta.groups
.map(
group =>
` [\n${group.map(f => ` [${f.join(', ')}]`).join(',\n')}\n ]`,
)
.join(',\n')
return `{\n name: ${JSON.stringify(meta.name)},\n celFile: ${JSON.stringify(meta.celFile)},\n width: ${meta.width},\n height: ${meta.height},\n directions: ${meta.directions},\n framesPerDirection: ${meta.framesPerDirection},\n animSpeed: ${meta.animSpeed},\n groups: [\n${groupsStr}\n ],\n}`
}
export async function bakeMissiles(projectRoot: string = process.cwd()): Promise<Record<string, MissileMeta>> {
const archivePaths = [
join(projectRoot, 'samples', 'd2', 'Patch_D2.mpq'),
join(projectRoot, 'samples', 'd2', 'd2exp.mpq'),
join(projectRoot, 'samples', 'd2', 'd2data.mpq'),
]
const archives: MpqArchive[] = []
for (const p of archivePaths) {
try {
archives.push(await MpqArchive.open(await fileSource(p)))
} catch {
// ignore
}
}
async function readMpqFile(path: string): Promise<Uint8Array | null> {
for (const a of archives) {
const f = a.find(path)
if (f) return a.read(f)
}
return null
}
const palBytes = await readMpqFile('data/global/palette/ACT1/pal.pl2')
if (!palBytes) throw new Error('data/global/palette/ACT1/pal.pl2 not found in MPQ archives')
const pl2 = decodePl2(palBytes)
const publicOutDir = join(projectRoot, 'public', 'missiles')
const packOutDir = join(projectRoot, 'samples', 'd2-packs', 'missiles')
@ -74,10 +255,10 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise
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 raw = await readMpqFile(`data/global/missiles/${target.celFile}.dcc`)
if (!raw) throw new Error(`Missile DCC not found: data/global/missiles/${target.celFile}.dcc`)
const dcc = decodeDcc(await mpq.read(f))
const dcc = decodeDcc(raw)
const groups = dcc.directions.map(dir => ({
frames: dir.frames.map(
(frame): SpriteFrame => ({
@ -92,7 +273,8 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise
}))
const sheet: SpriteSheet = { groups, width: null }
const packed = packSheetToPng(sheet, pl2.rgb)
const trans = 'trans' in target ? (target as any).trans : 1
const packed = packMissileSheetToRgbaPng(sheet, pl2.rgb, trans)
const meta: MissileMeta = {
name: target.name,
@ -118,6 +300,15 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise
}
// Generate src/render/missiles-meta.ts
const metaExports = MISSILE_TARGETS.map(t => {
const upper = t.name.toUpperCase()
return `export const ${upper}_META: MissileMeta = ${formatMetaTs(metaRecord[t.name]!)}`
}).join('\n\n')
const mapEntries = MISSILE_TARGETS.map(t => {
return ` '${t.name}': ${t.name.toUpperCase()}_META,`
}).join('\n')
const metaTsContent = `/**
* Authentic Diablo II v1.13c Missile Atlas Metadata.
* Auto-generated by scripts/pack-missiles.ts.
@ -157,12 +348,32 @@ export interface MissileMeta {
* 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,
]
const DIR64_TO_DCC: Readonly<Record<number, readonly number[]>> = {
4: [
0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1,
1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2,
2, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 0, 0, 0, 0, 0, 0, 0, 0,
],
8: [
4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5,
5, 5, 5, 5, 1, 1, 1, 1, 1, 1, 1, 1, 6, 6, 6, 6,
6, 6, 6, 6, 2, 2, 2, 2, 2, 2, 2, 2, 7, 7, 7, 7,
7, 7, 7, 7, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4,
],
16: [
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,
],
32: [
4, 16, 16, 8, 8, 17, 17, 0, 0, 18, 18, 9, 9, 19, 19, 5,
5, 20, 20, 10, 10, 21, 21, 1, 1, 22, 22, 11, 11, 23, 23, 6,
6, 24, 24, 12, 12, 25, 25, 2, 2, 26, 26, 13, 13, 27, 27, 7,
7, 28, 28, 14, 14, 29, 29, 3, 3, 30, 30, 15, 15, 31, 31, 4,
],
}
/**
* Calculate DCC direction index for a missile based on 2D velocity vector.
@ -179,35 +390,36 @@ export function velocityToDccDirection(vx: number, vy: number, directions = 16):
// 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
const table = DIR64_TO_DCC[directions]
if (table !== undefined) {
return table[dir64] ?? 0
}
return 0
}
export const FIREBOLT_META: MissileMeta = ${JSON.stringify(metaRecord['firebolt'], null, 2)}
${metaExports}
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,
}
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
${mapEntries}
})
`
const metaTsPath = join(projectRoot, 'src', 'render', 'missiles-meta.ts')
writeFileSync(metaTsPath, metaTsContent, 'utf-8')
console.log(`[Missile Packer] Wrote ${metaTsPath}`)
const outTsPath = join(projectRoot, 'src', 'render', 'missiles-meta.ts')
writeFileSync(outTsPath, metaTsContent, 'utf-8')
console.log(`[Missile Packer] Wrote ${outTsPath}`)
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)
})
// Direct invocation via tsx
if (import.meta.url === `file://${process.argv[1]}`) {
bakeMissiles()
.then(() => {
console.log('[Missile Packer] All missile assets baked successfully.')
process.exit(0)
})
.catch(err => {
console.error('[Missile Packer] Failed:', err)
process.exit(1)
})
}

View File

@ -170,3 +170,65 @@ export function encodeIndexedPng(image: IndexedImage): Uint8Array {
for (const part of parts) { out.set(part, offset); offset += part.byteLength }
return out
}
/** An RGBA image ready to encode. */
export interface RgbaImage {
/** Width in pixels. */
readonly width: number
/** Height in pixels. */
readonly height: number
/** RGBA pixels, row-major, 4 bytes per pixel (`width * height * 4` bytes). */
readonly pixels: Uint8Array
}
/**
* Encode a 32-bit RGBA image as a PNG (color type 6).
*
* @param image - the RGBA image.
* @returns the PNG bytes.
*/
export function encodeRgbaPng(image: RgbaImage): Uint8Array {
const { width, height, pixels } = image
if (pixels.byteLength !== width * height * 4) {
throw new Error(`pixel buffer is ${String(pixels.byteLength)} bytes for ${String(width)}x${String(height)} RGBA`)
}
const ihdr = new Uint8Array(13)
const ihdrView = new DataView(ihdr.buffer)
ihdrView.setUint32(0, width)
ihdrView.setUint32(4, height)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // colour type: RGBA
ihdr[10] = 0 // deflate
ihdr[11] = 0 // adaptive filtering
ihdr[12] = 0 // no interlace
const stride = 4
const raw = new Uint8Array((width * stride + 1) * height)
const candidate = new Uint8Array(width * stride)
let at = 0
for (let y = 0; y < height; y += 1) {
const row = pixels.subarray(y * width * stride, (y + 1) * width * stride)
const previous = y === 0 ? null : pixels.subarray((y - 1) * width * stride, y * width * stride)
let bestType = 0
let bestScore = Number.POSITIVE_INFINITY
for (const type of [0, 1, 2]) {
filterRow(type, row, previous, stride, candidate)
const value = score(candidate)
if (value < bestScore) { bestScore = value; bestType = type }
}
filterRow(bestType, row, previous, stride, candidate)
raw[at] = bestType
raw.set(candidate, at + 1)
at += width * stride + 1
}
const idat = new Uint8Array(deflateSync(raw, { level: 9 }))
const parts = [SIGNATURE, chunk('IHDR', ihdr), chunk('IDAT', idat), chunk('IEND', new Uint8Array(0))]
const size = parts.reduce((sum, part) => sum + part.byteLength, 0)
const out = new Uint8Array(size)
let offset = 0
for (const part of parts) { out.set(part, offset); offset += part.byteLength }
return out
}

View File

@ -18,7 +18,15 @@
import type { CharacterClassCode } from './classes.ts'
import type { EquipSlotId, GridPlacement, UiInventoryItem } from '../ui/inventory.ts'
import type { CharacterAttributes } from '../ui/character-sheet.ts'
import type { HotkeySkillEntry } from '../ui/hotkeys.ts'
import {
type HotkeySkillEntry,
DEFAULT_SORCERESS_SKILLS,
isLeftUsableSkill,
isPassiveSkill,
isAuraSkill,
} from '../ui/hotkeys.ts'
import { SKILLS_BY_CLASS } from '../data/skills-catalog.ts'
import { calculateManaCost } from './skill-calc-engine.ts'
export interface ClassProfileState {
readonly classCode: CharacterClassCode
@ -1648,124 +1656,46 @@ export const CLASS_STARTER_HARD_POINTS: Record<CharacterClassCode, Readonly<Reco
// 6. DEFAULT SKILLS & HOTKEYS PER CLASS
// -------------------------------------------------------------------------------------------------
function buildClassDefaultSkills(classCode: CharacterClassCode): readonly HotkeySkillEntry[] {
if (classCode === 'sor') return DEFAULT_SORCERESS_SKILLS
const universal: HotkeySkillEntry[] = [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
]
const classSkills = SKILLS_BY_CLASS[classCode] ?? []
const hardPoints = CLASS_STARTER_HARD_POINTS[classCode] ?? {}
const entries: HotkeySkillEntry[] = []
for (const sk of classSkills) {
if (isPassiveSkill(sk.id)) continue
const hard = hardPoints[sk.id] ?? 1
const mana = calculateManaCost(sk, hard)
entries.push({
skillId: sk.id,
name: sk.name,
nameZh: sk.nameZh,
level: hard,
manaCost: mana,
leftUsable: isLeftUsableSkill(sk.id),
rightUsable: true,
isAura: isAuraSkill(sk.id),
})
}
return [...universal, ...entries]
}
export const CLASS_DEFAULT_SKILLS: Record<CharacterClassCode, readonly HotkeySkillEntry[]> = {
ama: [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 6, name: 'Magic Arrow', nameZh: '魔法箭', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 7, name: 'Fire Arrow', nameZh: '火箭', level: 1, manaCost: 3, leftUsable: true, rightUsable: true },
{ skillId: 11, name: 'Cold Arrow', nameZh: '冰箭', level: 1, manaCost: 3.5, leftUsable: true, rightUsable: true },
{ skillId: 12, name: 'Multiple Shot', nameZh: '多重箭', level: 10, manaCost: 13, leftUsable: true, rightUsable: true },
{ skillId: 22, name: 'Guided Arrow', nameZh: '导引箭', level: 20, manaCost: 8, leftUsable: true, rightUsable: true },
{ skillId: 26, name: 'Strafe', nameZh: '炮轰', level: 10, manaCost: 11, leftUsable: true, rightUsable: true },
{ skillId: 31, name: 'Freezing Arrow', nameZh: '冻结箭', level: 10, manaCost: 17, leftUsable: true, rightUsable: true },
{ skillId: 10, name: 'Jab', nameZh: '戳刺', level: 1, manaCost: 2, leftUsable: true, rightUsable: true },
{ skillId: 24, name: 'Charged Strike', nameZh: '充能一击', level: 5, manaCost: 5, leftUsable: true, rightUsable: true },
{ skillId: 35, name: 'Lightning Fury', nameZh: '闪电之怒', level: 10, manaCost: 14, leftUsable: true, rightUsable: true },
{ skillId: 8, name: 'Inner Sight', nameZh: '内视', level: 1, manaCost: 5, leftUsable: false, rightUsable: true },
{ skillId: 17, name: 'Slow Missiles', nameZh: '减速箭', level: 1, manaCost: 5, leftUsable: false, rightUsable: true },
{ skillId: 28, name: 'Decoy', nameZh: '诱饵', level: 1, manaCost: 19, leftUsable: false, rightUsable: true },
{ skillId: 32, name: 'Valkyrie', nameZh: '女武神', level: 10, manaCost: 34, leftUsable: false, rightUsable: true },
],
sor: [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true },
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true },
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, rightUsable: true },
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
{ skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true },
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true },
{ skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: true, rightUsable: true },
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
{ skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true },
{ skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true },
],
nec: [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 66, name: 'Amplify Damage', nameZh: '伤害加深', level: 1, manaCost: 4, leftUsable: false, rightUsable: true },
{ skillId: 67, name: 'Teeth', nameZh: '牙', level: 5, manaCost: 5, leftUsable: true, rightUsable: true },
{ skillId: 68, name: 'Bone Armor', nameZh: '白骨装甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 70, name: 'Raise Skeleton', nameZh: '复生骷髅', level: 20, manaCost: 15, leftUsable: false, rightUsable: true },
{ skillId: 74, name: 'Corpse Explosion', nameZh: '尸体爆炸', level: 10, manaCost: 15, leftUsable: true, rightUsable: true },
{ skillId: 75, name: 'Clay Golem', nameZh: '粘土石魔', level: 1, manaCost: 15, leftUsable: false, rightUsable: true },
{ skillId: 80, name: 'Raise Skeletal Mage', nameZh: '复生骷髅法师', level: 5, manaCost: 12, leftUsable: false, rightUsable: true },
{ skillId: 84, name: 'Bone Spear', nameZh: '骨矛', level: 15, manaCost: 12, leftUsable: true, rightUsable: true },
{ skillId: 87, name: 'Decrepify', nameZh: '衰老', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 92, name: 'Poison Nova', nameZh: '剧毒新星', level: 5, manaCost: 20, leftUsable: false, rightUsable: true },
{ skillId: 95, name: 'Revive', nameZh: '重生', level: 5, manaCost: 45, leftUsable: false, rightUsable: true },
],
pal: [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 96, name: 'Sacrifice', nameZh: '牺牲', level: 5, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 97, name: 'Smite', nameZh: '重击', level: 5, manaCost: 2, leftUsable: true, rightUsable: true },
{ skillId: 101, name: 'Holy Bolt', nameZh: '圣光弹', level: 5, manaCost: 6, leftUsable: true, rightUsable: true },
{ skillId: 106, name: 'Zeal', nameZh: '热忱', level: 5, manaCost: 2, leftUsable: true, rightUsable: true },
{ skillId: 107, name: 'Charge', nameZh: '突击', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
{ skillId: 112, name: 'Blessed Hammer', nameZh: '祝福之槌', level: 20, manaCost: 10, leftUsable: true, rightUsable: true },
{ skillId: 117, name: 'Holy Shield', nameZh: '圣盾', level: 15, manaCost: 35, leftUsable: false, rightUsable: true },
{ skillId: 98, name: 'Might', nameZh: '力量灵气', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 113, name: 'Concentration', nameZh: '专注灵气', level: 20, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 115, name: 'Vigor', nameZh: '活力灵气', level: 15, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 122, name: 'Fanaticism', nameZh: '狂热灵气', level: 10, manaCost: 0, leftUsable: false, rightUsable: true },
],
bar: [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 126, name: 'Bash', nameZh: '重击', level: 5, manaCost: 2, leftUsable: true, rightUsable: true },
{ skillId: 130, name: 'Howl', nameZh: '狂嚎', level: 1, manaCost: 4, leftUsable: false, rightUsable: true },
{ skillId: 132, name: 'Leap', nameZh: '跳跃', level: 1, manaCost: 2, leftUsable: false, rightUsable: true },
{ skillId: 133, name: 'Double Swing', nameZh: '双手挥击', level: 5, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 138, name: 'Shout', nameZh: '大叫', level: 15, manaCost: 6, leftUsable: false, rightUsable: true },
{ skillId: 143, name: 'Leap Attack', nameZh: '跳跃攻击', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
{ skillId: 144, name: 'Concentrate', nameZh: '专心', level: 5, manaCost: 2, leftUsable: true, rightUsable: true },
{ skillId: 147, name: 'Frenzy', nameZh: '狂乱', level: 10, manaCost: 3, leftUsable: true, rightUsable: true },
{ skillId: 149, name: 'Battle Orders', nameZh: '战斗体制', level: 20, manaCost: 7, leftUsable: false, rightUsable: true },
{ skillId: 151, name: 'Whirlwind', nameZh: '旋风', level: 20, manaCost: 25, leftUsable: true, rightUsable: true },
{ skillId: 152, name: 'Berserk', nameZh: '狂战士', level: 1, manaCost: 4, leftUsable: true, rightUsable: true },
{ skillId: 155, name: 'Battle Command', nameZh: '战斗指挥', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
],
dru: [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 221, name: 'Arctic Blast', nameZh: '极地风暴', level: 1, manaCost: 4, leftUsable: true, rightUsable: true },
{ skillId: 223, name: 'Werewolf', nameZh: '狼人变化', level: 5, manaCost: 15, leftUsable: false, rightUsable: true },
{ skillId: 225, name: 'Firestorm', nameZh: '火风暴', level: 5, manaCost: 4, leftUsable: true, rightUsable: true },
{ skillId: 226, name: 'Oak Sage', nameZh: '橡木智者', level: 15, manaCost: 20, leftUsable: false, rightUsable: true },
{ skillId: 227, name: 'Cyclone Armor', nameZh: '飓风装甲', level: 15, manaCost: 15, leftUsable: false, rightUsable: true },
{ skillId: 229, name: 'Molten Boulder', nameZh: '熔火巨石', level: 5, manaCost: 10, leftUsable: true, rightUsable: true },
{ skillId: 235, name: 'Twister', nameZh: '龙卷风', level: 15, manaCost: 7, leftUsable: true, rightUsable: true },
{ skillId: 237, name: 'Summon Dire Wolf', nameZh: '召唤狂狼', level: 5, manaCost: 20, leftUsable: false, rightUsable: true },
{ skillId: 240, name: 'Tornado', nameZh: '暴风', level: 20, manaCost: 10, leftUsable: true, rightUsable: true },
{ skillId: 245, name: 'Hurricane', nameZh: '毁灭风暴', level: 20, manaCost: 30, leftUsable: false, rightUsable: true },
{ skillId: 247, name: 'Summon Grizzly', nameZh: '召唤灰熊', level: 5, manaCost: 40, leftUsable: false, rightUsable: true },
],
ass: [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
{ skillId: 251, name: 'Fire Blast', nameZh: '火焰爆震', level: 15, manaCost: 3, leftUsable: true, rightUsable: true },
{ skillId: 254, name: 'Tiger Strike', nameZh: '虎击', level: 5, manaCost: 1, leftUsable: true, rightUsable: true },
{ skillId: 255, name: 'Dragon Talon', nameZh: '龙爪', level: 5, manaCost: 6, leftUsable: true, rightUsable: true },
{ skillId: 256, name: 'Shock Web', nameZh: '电能网', level: 15, manaCost: 6, leftUsable: true, rightUsable: true },
{ skillId: 258, name: 'Burst of Speed', nameZh: '速度爆发', level: 5, manaCost: 10, leftUsable: false, rightUsable: true },
{ skillId: 260, name: 'Dragon Claw', nameZh: '双龙爪', level: 5, manaCost: 2, leftUsable: true, rightUsable: true },
{ skillId: 261, name: 'Charged Bolt Sentry', nameZh: '电能守卫', level: 15, manaCost: 13, leftUsable: false, rightUsable: true },
{ skillId: 264, name: 'Cloak of Shadows', nameZh: '魔影斗篷', level: 1, manaCost: 13, leftUsable: false, rightUsable: true },
{ skillId: 267, name: 'Fade', nameZh: '能量消解', level: 5, manaCost: 10, leftUsable: false, rightUsable: true },
{ skillId: 271, name: 'Lightning Sentry', nameZh: '雷电守卫', level: 20, manaCost: 20, leftUsable: false, rightUsable: true },
{ skillId: 276, name: 'Death Sentry', nameZh: '亡者守卫', level: 20, manaCost: 20, leftUsable: false, rightUsable: true },
{ skillId: 279, name: 'Shadow Master', nameZh: '暗影大师', level: 5, manaCost: 35, leftUsable: false, rightUsable: true },
],
ama: buildClassDefaultSkills('ama'),
sor: DEFAULT_SORCERESS_SKILLS,
nec: buildClassDefaultSkills('nec'),
pal: buildClassDefaultSkills('pal'),
bar: buildClassDefaultSkills('bar'),
dru: buildClassDefaultSkills('dru'),
ass: buildClassDefaultSkills('ass'),
}
// -------------------------------------------------------------------------------------------------

View File

@ -141,6 +141,12 @@ export interface Monster {
corpseTicks: number
/** Whether the monster is sleeping (inactive off-screen room). */
sleeping?: boolean | undefined
/** Ticks remaining frozen (cannot move or attack). */
frozenTicks?: number | undefined
/** Ticks remaining chilled (slowed movement). */
chillTicks?: number | undefined
/** Ticks remaining poisoned (damage over time). */
poisonTicks?: number | undefined
}
/** The player's combat-relevant state. */
@ -840,6 +846,20 @@ function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: Comba
}
if (monster.hitFlash > 0) monster.hitFlash -= 1
if (monster.cooldown > 0) monster.cooldown -= 1
if (monster.frozenTicks !== undefined && monster.frozenTicks > 0) {
monster.frozenTicks -= 1
monster.state = 'idle'
continue
}
if (monster.chillTicks !== undefined && monster.chillTicks > 0) {
monster.chillTicks -= 1
}
if (monster.poisonTicks !== undefined && monster.poisonTicks > 0) {
monster.poisonTicks -= 1
if (world.tick % 5 === 0 && monster.hp > 1) {
monster.hp -= 1
}
}
if (options.disableMonsterAggro) {
monster.state = 'idle'
@ -876,7 +896,8 @@ function tickMonsters(world: CombatWorld, options: CombatOptions, terrain: Comba
continue
}
monster.state = 'chase'
const step = monster.stats.speed / 25
const chillFactor = monster.chillTicks !== undefined && monster.chillTicks > 0 ? 0.5 : 1
const step = (monster.stats.speed / 25) * chillFactor
const movedMonster = moveWithCollision(monster, (dx / distance) * step, (dy / distance) * step, terrain)
monster.x = movedMonster.x
monster.y = movedMonster.y

View File

@ -2,7 +2,7 @@ import { tickCombat, createWorld, spawnMonsters, spawnMonsterPacks, damageMonste
import type { CombatWorld, CombatOptions, MonsterPack, MonsterStats, SafeZone } from './combat.ts'
import { Inventory, rollDrop, goldItem, totalStat } from './items.ts'
import type { Item, ItemBase, Affix } from './items.ts'
import { castSkill, tickProjectiles } from './skills.ts'
import { castSkill, tickProjectiles, CANONICAL_113C_MISSILES, ISO_GROUND_ASPECT_RATIO } from './skills.ts'
import type { SkillDef, Projectile } from './skills.ts'
import { QuestLog, npcDialog } from './quests.ts'
import type { NpcDef, QuestDef } from './quests.ts'
@ -323,21 +323,77 @@ export class GameEngine {
this.projectiles.length = 0
this.projectiles.push(...outcome.alive)
for (const hit of outcome.hits) {
const targetMonster = this.world.monsters.find(m => m.index === hit.targetIndex)
if (hit.isUndeadOnly) {
const name = targetMonster?.stats?.name?.toLowerCase() ?? ''
const id = targetMonster?.stats?.id?.toLowerCase() ?? ''
const isUndead = (targetMonster as any)?.isUndead ||
name.includes('skeleton') || name.includes('zombie') || name.includes('ghoul') || name.includes('ghost') || name.includes('wraith') ||
id.includes('skeleton') || id.includes('zombie') || id.includes('ghoul') || id.includes('ghost') || id.includes('wraith')
if (!isUndead) {
continue
}
}
damageMonster(this.world, hit.targetIndex, hit.damage)
this.metrics.castHits += 1
if (targetMonster) {
if (hit.statusEffect === 'freeze') {
targetMonster.frozenTicks = hit.statusDuration ?? 75
} else if (hit.statusEffect === 'chill') {
targetMonster.chillTicks = hit.statusDuration ?? 100
} else if (hit.statusEffect === 'poison') {
targetMonster.poisonTicks = hit.statusDuration ?? 100
}
}
// AoE splash damage
if (hit.aoeRadius && hit.aoeRadius > 0) {
for (const m of this.world.monsters) {
if (m.index === hit.targetIndex || m.state === 'dead' || m.hp <= 0) continue
if (hit.isUndeadOnly) {
const name = m?.stats?.name?.toLowerCase() ?? ''
const id = m?.stats?.id?.toLowerCase() ?? ''
const isUndead = (m as any)?.isUndead ||
name.includes('skeleton') || name.includes('zombie') || name.includes('ghoul') || name.includes('ghost') || name.includes('wraith') ||
id.includes('skeleton') || id.includes('zombie') || id.includes('ghoul') || id.includes('ghost') || id.includes('wraith')
if (!isUndead) continue
}
// Ground distance in 2:1 isometric projection:
// dx^2 + (dy / ISO_GROUND_ASPECT_RATIO)^2 <= aoeRadius^2
const dx = m.x - hit.x
const dy = (m.y - hit.y) / ISO_GROUND_ASPECT_RATIO
if (Math.hypot(dx, dy) <= hit.aoeRadius) {
damageMonster(this.world, m.index, hit.damage)
if (hit.statusEffect === 'freeze') {
m.frozenTicks = hit.statusDuration ?? 75
} else if (hit.statusEffect === 'chill') {
m.chillTicks = hit.statusDuration ?? 100
} else if (hit.statusEffect === 'poison') {
m.poisonTicks = hit.statusDuration ?? 100
}
}
}
}
const missileType = hit.missileType ?? (hit.skillId === '36' || hit.skillId === 'firebolt' ? 'firebolt' : undefined)
if (missileType === 'firebolt' || hit.skillId === '36' || hit.skillId === 'firebolt') {
const mslData = missileType ? CANONICAL_113C_MISSILES[missileType] : undefined
const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : '')
if (explType) {
const explData = CANONICAL_113C_MISSILES[explType]
const maxFrames = explData?.animLen || 12
this.explosions.push({
missileType: 'fireexplode',
missileType: explType,
x: hit.x,
y: hit.y,
frame: 0,
maxFrames: 12,
radius: 24,
maxFrames,
radius: hit.aoeRadius ?? 24,
})
this.world.events.push({
kind: 'missileExplode',
missileType: 'fireexplode',
missileType: explType,
skillId: hit.skillId,
x: hit.x,
y: hit.y,
@ -345,21 +401,34 @@ export class GameEngine {
})
}
}
if (outcome.wallHitEvents) {
for (const wallHit of outcome.wallHitEvents) {
if (wallHit.aoeRadius && wallHit.aoeRadius > 0) {
for (const m of this.world.monsters) {
if (m.state === 'dead' || m.hp <= 0) continue
if (Math.hypot(m.x - wallHit.x, m.y - wallHit.y) <= wallHit.aoeRadius) {
damageMonster(this.world, m.index, 10)
}
}
}
const missileType = wallHit.missileType ?? (wallHit.skillId === '36' || wallHit.skillId === 'firebolt' ? 'firebolt' : undefined)
if (missileType === 'firebolt' || wallHit.skillId === '36' || wallHit.skillId === 'firebolt') {
const mslData = missileType ? CANONICAL_113C_MISSILES[missileType] : undefined
const explType = mslData?.explosionMissile || (missileType === 'firebolt' ? 'fireexplode' : '')
if (explType) {
const explData = CANONICAL_113C_MISSILES[explType]
const maxFrames = explData?.animLen || 12
this.explosions.push({
missileType: 'fireexplode',
missileType: explType,
x: wallHit.x,
y: wallHit.y,
frame: 0,
maxFrames: 12,
radius: 24,
maxFrames,
radius: wallHit.aoeRadius ?? 24,
})
this.world.events.push({
kind: 'missileExplode',
missileType: 'fireexplode',
missileType: explType,
skillId: wallHit.skillId,
x: wallHit.x,
y: wallHit.y,
@ -605,7 +674,7 @@ export class GameEngine {
}
const amount = entity.amount || 1
this.groundItems.remove(id)
const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 16)
const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 32 || (g.item as any)?.isGold)
if (gIdx !== -1) {
this.ground.splice(gIdx, 1)
}
@ -639,7 +708,7 @@ export class GameEngine {
}
this.groundItems.remove(id)
const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 16)
const gIdx = this.ground.findIndex(g => Math.hypot(g.x - entity.x, g.y - entity.y) <= 32 || g.item === entity.item)
if (gIdx !== -1) {
this.ground.splice(gIdx, 1)
}

View File

@ -285,18 +285,19 @@ export class MissileEngine {
startX: m.x,
startY: m.y,
targetX: m.x + Math.cos(subAngle) * 100,
targetY: m.y + Math.sin(subAngle) * 100,
targetY: m.y + Math.sin(subAngle) * (100 * 0.5),
dmgPacket: m.dmgPacket,
})
subMissilesSpawned.push(subName)
}
// Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`)
// In 2:1 isometric ground perspective, vertical expansion is foreshortened by 0.5.
if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') {
m.angleRad += 0.24
const radius = 14 + m.ageTicks * 4
m.x += Math.cos(m.angleRad) * (radius * 0.25)
m.y += Math.sin(m.angleRad) * (radius * 0.25)
m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5)
} else {
m.x += m.vx
m.y += m.vy

View File

@ -72,8 +72,43 @@ export interface Projectile {
readonly fromPlayer: boolean
/** Optional missile type / token (e.g. 'firebolt'). */
readonly missileType?: string | undefined
/** Pierces through enemies without expiring until range or wall hit. */
readonly pierce?: boolean | undefined
/** Set of monster indices already hit by this projectile (for pierce/nextHit). */
readonly hitTargets?: Set<number> | undefined
/** Area of effect splash radius in pixels upon impact. */
readonly aoeRadius?: number | undefined
/** Elemental status effect applied on hit: chill, freeze, poison. */
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
/** Duration in ticks of the status effect. */
readonly statusDuration?: number | undefined
/** Homing missile: steers towards nearest alive monster. */
readonly homing?: boolean | undefined
/** Archimedean spiral trajectory (e.g. Blessed Hammer). */
readonly spiral?: {
readonly originX: number
readonly originY: number
readonly angle: number
readonly radius: number
} | undefined
/** Random perpendicular trajectory wobble (e.g. Charged Bolt). */
readonly jitter?: boolean | undefined
/** Target restriction: only hits undead monsters. */
readonly isUndeadOnly?: boolean | undefined
}
/**
* In Diablo II's 2:1 isometric projection (orthogonal diamond grid: 80x40 cells, 16x8 sub-tiles),
* any horizontal circular curve on the map ground plane projects into scene-pixel coordinates
* as an ellipse with a 2:1 aspect ratio (vertical scale / horizontal scale = 8 / 16 = 0.5).
*
* NOTE: The calculated coordinates (x, y) are physical MAP coordinates on the level plane,
* NOT the rendered sprite icon offsets. Rendered icons are pre-rendered 2.5D DCC billboards
* positioned via sprite frame anchors, while map coordinates govern physical trajectory and collision.
*/
export const ISO_GROUND_ASPECT_RATIO = 0.5
/** What a cast produced. */
export type CastResult =
| { readonly kind: 'mana'; readonly cost: number }
@ -144,6 +179,26 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
speedPxPerSec: 500,
distancePx: 1000,
},
fireball: {
name: 'fireball',
id: 62,
vel: 20,
maxVel: 20,
range: 50,
levRange: 0,
celFile: 'Fireball',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'fireexplode',
light: 7,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 500,
distancePx: 1000,
},
fireexplode: {
name: 'fireexplode',
id: 29,
@ -164,6 +219,466 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
speedPxPerSec: 0,
distancePx: 0,
},
explodingarrowexp: {
name: 'explodingarrowexp',
id: 47,
vel: 0,
maxVel: 0,
range: 12,
levRange: 0,
celFile: 'FireArrowExplode2',
animLen: 12,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 13,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 0,
distancePx: 0,
},
icebolt: {
name: 'icebolt',
id: 59,
vel: 12,
maxVel: 12,
range: 50,
levRange: 0,
celFile: 'Icebolt',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'iceexplode',
light: 7,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 300,
distancePx: 600,
},
iceblast: {
name: 'iceblast',
id: 91,
vel: 12,
maxVel: 12,
range: 50,
levRange: 0,
celFile: 'IceBlast',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'freezeexplode',
light: 7,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 300,
distancePx: 600,
},
glacialspike: {
name: 'glacialspike',
id: 96,
vel: 16,
maxVel: 16,
range: 40,
levRange: 0,
celFile: 'GlacialSpike',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'freezeexplode',
light: 5,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 400,
distancePx: 640,
},
iceexplode: {
name: 'iceexplode',
id: 30,
vel: 0,
maxVel: 0,
range: 16,
levRange: 0,
celFile: 'IceArrowExplode',
animLen: 16,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 11,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
distancePx: 0,
},
freezeexplode: {
name: 'freezeexplode',
id: 88,
vel: 0,
maxVel: 0,
range: 15,
levRange: 0,
celFile: 'FreezeExplodeCenter',
animLen: 15,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 11,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
distancePx: 0,
},
freezingarrowexp1: {
name: 'freezingarrowexp1',
id: 88,
vel: 0,
maxVel: 0,
range: 15,
levRange: 0,
celFile: 'FreezeExplodeCenter',
animLen: 15,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 11,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 0,
distancePx: 0,
},
chargedbolt: {
name: 'chargedbolt',
id: 56,
vel: 12,
maxVel: 12,
range: 98,
levRange: 0,
celFile: 'ChargedBolt',
animLen: 10,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 300,
distancePx: 1176,
},
teeth: {
name: 'teeth',
id: 114,
vel: 16,
maxVel: 16,
range: 50,
levRange: 0,
celFile: 'teethMissile',
animLen: 30,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: 'teethexplode',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 400,
distancePx: 800,
},
teethexplode: {
name: 'teethexplode',
id: 204,
vel: 0,
maxVel: 0,
range: 13,
levRange: 0,
celFile: 'teethexplode',
animLen: 13,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 0,
distancePx: 0,
},
bonespear: {
name: 'bonespear',
id: 192,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'BoneSpear',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: 'teethexplode',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
distancePx: 960,
},
bonespearexplode: {
name: 'bonespearexplode',
id: 216,
vel: 0,
maxVel: 0,
range: 13,
levRange: 0,
celFile: 'teethexplode',
animLen: 13,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 0,
distancePx: 0,
},
bonespirit: {
name: 'bonespirit',
id: 193,
vel: 12,
maxVel: 12,
range: 128,
levRange: 0,
celFile: 'BoneSpirit',
animLen: 8,
animSpeed: 16,
loopAnim: 1,
numDirections: 8,
explosionMissile: 'bonespiritexplode',
light: 3,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 300,
distancePx: 1536,
},
bonespiritexplode: {
name: 'bonespiritexplode',
id: 327,
vel: 0,
maxVel: 0,
range: 13,
levRange: 0,
celFile: 'BoneSpiritExplode',
animLen: 13,
animSpeed: 16,
loopAnim: 0,
numDirections: 1,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 0,
distancePx: 0,
},
poisonnova: {
name: 'poisonnova',
id: 118,
vel: 12,
maxVel: 12,
range: 30,
levRange: 0,
celFile: 'poisonNova',
animLen: 30,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 300,
distancePx: 360,
},
holybolt: {
name: 'holybolt',
id: 55,
vel: 20,
maxVel: 20,
range: 50,
levRange: 0,
celFile: 'HolyBoltMissile',
animLen: 16,
animSpeed: 16,
loopAnim: 1,
numDirections: 1,
explosionMissile: '',
light: 7,
red: 222,
green: 222,
blue: 255,
speedPxPerSec: 500,
distancePx: 1000,
},
blessedhammer: {
name: 'blessedhammer',
id: 92,
vel: 18,
maxVel: 30,
range: 120,
levRange: 0,
celFile: 'blessedhammer',
animLen: 6,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 5,
red: 222,
green: 222,
blue: 255,
speedPxPerSec: 450,
distancePx: 2160,
},
magicarrow: {
name: 'magicarrow',
id: 27,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'SafeArrow',
animLen: 1,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: 'teethexplode',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
distancePx: 960,
},
firearrow: {
name: 'firearrow',
id: 12,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'FireArrow',
animLen: 8,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: 'fireexplode',
light: 6,
red: 255,
green: 178,
blue: 64,
speedPxPerSec: 600,
distancePx: 960,
},
icearrow: {
name: 'icearrow',
id: 28,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'IceArrow',
animLen: 8,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: 'iceexplode',
light: 6,
red: 81,
green: 81,
blue: 255,
speedPxPerSec: 600,
distancePx: 960,
},
arrow: {
name: 'arrow',
id: 0,
vel: 24,
maxVel: 24,
range: 40,
levRange: 0,
celFile: 'Arrow',
animLen: 1,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
distancePx: 960,
},
multipleshotarrow: {
name: 'multipleshotarrow',
id: 213,
vel: 24,
maxVel: 24,
range: 50,
levRange: 0,
celFile: 'Arrow',
animLen: 1,
animSpeed: 16,
loopAnim: 0,
numDirections: 32,
explosionMissile: '',
light: 0,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 600,
distancePx: 1200,
},
lightningjavelin: {
name: 'lightningjavelin',
id: 205,
vel: 30,
maxVel: 30,
range: 25,
levRange: 0,
celFile: 'LightningJavelin',
animLen: 5,
animSpeed: 16,
loopAnim: 1,
numDirections: 16,
explosionMissile: '',
light: 6,
red: 255,
green: 255,
blue: 255,
speedPxPerSec: 750,
distancePx: 750,
},
})
/**
@ -543,6 +1058,10 @@ export interface ProjectileOutcome {
readonly y: number
readonly skillId?: string | undefined
readonly missileType?: string | undefined
readonly aoeRadius?: number | undefined
readonly statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
readonly statusDuration?: number | undefined
readonly isUndeadOnly?: boolean | undefined
}[]
/** Projectiles that died on a wall or expired. */
readonly expired: number
@ -554,6 +1073,7 @@ export interface ProjectileOutcome {
readonly y: number
readonly skillId?: string | undefined
readonly missileType?: string | undefined
readonly aoeRadius?: number | undefined
}[] | undefined
}
@ -583,23 +1103,97 @@ export function tickProjectiles(
y: number
skillId?: string | undefined
missileType?: string | undefined
aoeRadius?: number | undefined
statusEffect?: 'chill' | 'freeze' | 'poison' | undefined
statusDuration?: number | undefined
isUndeadOnly?: boolean | undefined
}[] = []
const wallHitEvents: {
x: number
y: number
skillId?: string | undefined
missileType?: string | undefined
aoeRadius?: number | undefined
}[] = []
let expired = 0
let wallHits = 0
for (const projectile of projectiles) {
if (projectile.ttl <= 0) { expired += 1; continue }
const x = projectile.x + projectile.vx
const y = projectile.y + projectile.vy
const moved: Projectile = { ...projectile, x, y, ttl: projectile.ttl - 1 }
let vx = projectile.vx
let vy = projectile.vy
let x = projectile.x + vx
let y = projectile.y + vy
let spiral = projectile.spiral
// 1. Archimedean spiral trajectory (e.g. Blessed Hammer)
// The hammer traces an Archimedean spiral on the map ground plane. In oblique/isometric
// perspective (2:1 projection), a circular ground expansion projects to an ellipse with a 2:1 aspect ratio.
if (spiral !== undefined) {
const nextAngle = spiral.angle + 0.22
const nextRadius = spiral.radius + 3.2
spiral = { ...spiral, angle: nextAngle, radius: nextRadius }
x = spiral.originX + Math.cos(nextAngle) * nextRadius
y = spiral.originY + Math.sin(nextAngle) * (nextRadius * ISO_GROUND_ASPECT_RATIO)
vx = -Math.sin(nextAngle) * 18
vy = Math.cos(nextAngle) * (18 * ISO_GROUND_ASPECT_RATIO)
} else if (projectile.homing) {
// 2. Homing trajectory (e.g. Bone Spirit) - smoothly steer towards closest living enemy
let nearestDist = Number.POSITIVE_INFINITY
let targetX = 0
let targetY = 0
let foundTarget = false
for (const t of targets) {
if (!t.alive) continue
const d = Math.hypot(t.x - projectile.x, t.y - projectile.y)
if (d < nearestDist && d > 4) {
nearestDist = d
targetX = t.x
targetY = t.y
foundTarget = true
}
}
if (foundTarget) {
const curAngle = Math.atan2(vy, vx)
const targetAngle = Math.atan2(targetY - projectile.y, targetX - projectile.x)
let diff = targetAngle - curAngle
while (diff > Math.PI) diff -= 2 * Math.PI
while (diff < -Math.PI) diff += 2 * Math.PI
const maxTurn = 0.14
const turn = Math.max(-maxTurn, Math.min(maxTurn, diff))
const newAngle = curAngle + turn
const speed = Math.hypot(vx, vy)
vx = Math.cos(newAngle) * speed
vy = Math.sin(newAngle) * speed
x = projectile.x + vx
y = projectile.y + vy
}
} else if (projectile.jitter) {
// 3. Jitter trajectory (e.g. Charged Bolt) - sinusoidal perpendicular wiggle on the ground plane
const perpX = -vy
const perpY = vx
const speed = Math.hypot(vx, vy)
if (speed > 0) {
const wobble = Math.sin(projectile.ttl * 0.8) * 1.5
x += (perpX / speed) * wobble
y += (perpY / speed) * (wobble * ISO_GROUND_ASPECT_RATIO)
}
}
const hitTargets = projectile.hitTargets ?? new Set<number>()
const moved: Projectile = {
...projectile,
x,
y,
vx,
vy,
spiral,
hitTargets,
ttl: projectile.ttl - 1,
}
// Walls first: a projectile dies against missile barriers (solid walls, closed doors)
// while passing freely over ground-only barriers (rivers, lava, chasms) via COLLIDE_MASK_MISSILE.
const blockedByTerrain =
options.isMissileBlocked !== undefined
? options.isMissileBlocked(x, y, projectile.x, projectile.y)
@ -611,13 +1205,17 @@ export function tickProjectiles(
y,
skillId: projectile.skillId,
missileType: projectile.missileType,
aoeRadius: projectile.aoeRadius,
})
continue
}
let struck = false
for (const target of targets) {
if (!target.alive) continue
if (hitTargets.has(target.index)) continue // already struck by piercing projectile
if (Math.hypot(target.x - x, target.y - y) > target.radius + radius) continue
hits.push({
targetIndex: target.index,
damage: projectile.damage,
@ -625,11 +1223,22 @@ export function tickProjectiles(
y,
skillId: projectile.skillId,
missileType: projectile.missileType,
aoeRadius: projectile.aoeRadius,
statusEffect: projectile.statusEffect,
statusDuration: projectile.statusDuration,
isUndeadOnly: projectile.isUndeadOnly,
})
struck = true
break
hitTargets.add(target.index)
if (!projectile.pierce) {
struck = true
break
}
}
if (!struck) {
alive.push(moved)
}
if (!struck) alive.push(moved)
}
return { alive, hits, expired, wallHits, wallHitEvents }
}

File diff suppressed because it is too large Load Diff

View File

@ -80,6 +80,12 @@ export interface DrawOptions {
* - 4: Champion / Elite Blue Tint
*/
readonly paletteRow?: number
/** Destination width in pixels (defaults to frame.width). */
readonly width?: number
/** Destination height in pixels (defaults to frame.height). */
readonly height?: number
/** Blend mode for rendering: 'normal' (alpha blend) or 'additive' (translucent spell glow). */
readonly blendMode?: 'normal' | 'additive'
}
/** Configuration options and event callbacks for SpriteRenderer. */
@ -252,6 +258,8 @@ export class SpriteRenderer {
private frameDrawCalls = 0
/** Quads submitted since the last {@link begin}. */
private frameQuads = 0
/** Current active blend mode ('normal' or 'additive'). */
private currentBlendMode: 'normal' | 'additive' = 'normal'
/** All textures allocated and managed by this renderer. */
private readonly allocatedTextures = new Set<WebGLTexture>()
@ -757,6 +765,10 @@ export class SpriteRenderer {
this.batchTextureCount = 0
this.frameDrawCalls = 0
this.frameQuads = 0
if (this.currentBlendMode !== 'normal') {
this.currentBlendMode = 'normal'
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
}
gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
gl.clearColor(clear[0], clear[1], clear[2], 1)
gl.clear(gl.COLOR_BUFFER_BIT)
@ -772,6 +784,17 @@ export class SpriteRenderer {
*/
draw(frame: AtlasFrame, x: number, y: number, options: DrawOptions = {}): void {
if (this.disposed) return
const blendMode = options.blendMode ?? 'normal'
if (blendMode !== this.currentBlendMode) {
this.flush()
this.currentBlendMode = blendMode
const gl = this.gl
if (blendMode === 'additive') {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE)
} else {
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
}
}
const page = options.atlas ?? this.defaultAtlas
const unit = this.unitFor(page)
const u0 = frame.x / page.width
@ -781,8 +804,10 @@ export class SpriteRenderer {
const tint = options.tint
const flipped = options.flipX === true
const paletteRow = page.indexed === true ? (options.paletteRow ?? 0) : -1
const drawW = options.width ?? frame.width
const drawH = options.height ?? frame.height
this.quad(
x, y, x + frame.width, y + frame.height,
x, y, x + drawW, y + drawH,
flipped ? u1 : u0, v0,
flipped ? u0 : u1, v1,
tint === undefined ? 1 : tint[0],
@ -805,6 +830,11 @@ export class SpriteRenderer {
*/
drawSolid(x: number, y: number, width: number, height: number, color: readonly [number, number, number, number]): void {
if (this.disposed) return
if (this.currentBlendMode !== 'normal') {
this.flush()
this.currentBlendMode = 'normal'
this.gl.blendFunc(this.gl.SRC_ALPHA, this.gl.ONE_MINUS_SRC_ALPHA)
}
const unit = this.unitFor(this.whiteAtlas)
this.quad(x, y, x + width, y + height, 0.5, 0.5, 0.5, 0.5, color[0], color[1], color[2], color[3], unit, -1)
}

View File

@ -45,7 +45,7 @@ import { getEmbeddedDropTables } from '../game/embedded-drop-tables.ts'
import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
import { npcDialog } from '../game/quests.ts'
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, ISO_GROUND_ASPECT_RATIO } from '../game/skills.ts'
import type { Projectile } from '../game/skills.ts'
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
import {
@ -53,6 +53,8 @@ import {
computeInitialGroundLabelLayouts,
resolveLadderCollisions,
} from '../ui/ground-labels.ts'
import { BAKED_UI_MANIFEST } from '../ui/baked-ui-meta.ts'
import { resolveItemSpriteRect } from '../ui/inventory.ts'
import type { AtlasFrame } from '../render/atlas.ts'
import { SpriteRenderer } from '../render/renderer.ts'
import type { AtlasHandle } from '../render/renderer.ts'
@ -60,8 +62,7 @@ import { buildBounds, intersects, viewportRect } from '../render/cull.ts'
import { RoofFadeController } from '../render/roofs.ts'
import { facingToDirection } from '../game/character.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 { velocityToDccDirection, MISSILE_METAS, type MissileMeta, type MissileFrameTuple } from '../render/missiles-meta.ts'
import { ActorAnimator, type AnimClipMeta } from '../game/actor-animator.ts'
import { resolveMonsterArtSpec } from '../game/monster-mapping.ts'
import { ACT_NAMES_ZH, LEVEL_NAMES_ZH, getLevelNames, sceneNameZh, variantLabelZh } from '../game/level-names-zh.ts'
@ -997,8 +998,28 @@ export function getSkillRange(
const info = getAttackWeaponInfo(weapon)
return info.reach
}
if (skillId === 36) {
const missile = getMissileTxtData('firebolt')
const missileSkillMap: Record<number, string> = {
36: 'firebolt',
39: 'icebolt',
45: 'iceblast',
55: 'glacialspike',
38: 'chargedbolt',
67: 'teeth',
84: 'bonespear',
93: 'bonespirit',
92: 'poisonnova',
101: 'holybolt',
112: 'blessedhammer',
6: 'magicarrow',
7: 'firearrow',
11: 'icearrow',
21: 'icearrow',
12: 'arrow',
16: 'firearrow',
20: 'lightningjavelin',
}
if (skillId in missileSkillMap) {
const missile = getMissileTxtData(missileSkillMap[skillId]!)
return missile.distancePx || 1000
}
const def = getBatch1SkillDef(skillId)
@ -1010,7 +1031,6 @@ export function getSkillRange(
case 40: return 0
case 42: return 160
case 44: return 240
case 47: return 450
case 54: return 450
case 56: return 450
case 59: return 450
@ -1029,6 +1049,15 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
return getSkillManaCost(skillId, 1)
}
switch (skillId) {
case 45: return 6
case 55: return 10
case 84: return 7
case 93: return 12
case 92: return 20
case 12: return 4
case 16: return 5
case 20: return 8
case 21: return 4
case 40: return 7
case 42: return 9
case 59: return 27
@ -1264,38 +1293,208 @@ export function castSkill(
let damage = 28
let delay = 0
let skillName = '技能'
let missileType: string | undefined = undefined
let pierce: boolean | undefined = undefined
let aoeRadius: number | undefined = undefined
let statusEffect: 'chill' | 'freeze' | 'poison' | undefined = undefined
let statusDuration: number | undefined = undefined
let homing: boolean | undefined = undefined
let spiral: { originX: number; originY: number; angle: number; radius: number } | undefined = undefined
let jitter: boolean | undefined = undefined
let isUndeadOnly: boolean | undefined = undefined
let multiCount = 1
let spreadAngle = 0
let isNova = false
if (skillId === 47) {
speed = 450
range = 450
damage = 28
if (skillId === 36) {
const missile = getMissileTxtData('firebolt')
speed = missile.speedPxPerSec || 500
range = missile.distancePx || 1000
damage = 16
delay = 0
missileType = 'firebolt'
skillName = '火弹'
} else if (skillId === 47) {
const missile = getMissileTxtData('fireball')
speed = missile.speedPxPerSec || 500
range = missile.distancePx || 1000
damage = 35
delay = 0
aoeRadius = 48
missileType = 'fireball'
skillName = '火球'
} else if (skillId === 39) {
const missile = getMissileTxtData('icebolt')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 600
damage = 14
delay = 0
statusEffect = 'chill'
statusDuration = 50
missileType = 'icebolt'
skillName = '冰弹'
} else if (skillId === 45) {
const missile = getMissileTxtData('iceblast')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 600
damage = 24
delay = 0
statusEffect = 'freeze'
statusDuration = 75
missileType = 'iceblast'
skillName = '冰风暴'
} else if (skillId === 55) {
const missile = getMissileTxtData('glacialspike')
speed = missile.speedPxPerSec || 400
range = missile.distancePx || 640
damage = 32
delay = 0
aoeRadius = 64
statusEffect = 'freeze'
statusDuration = 50
missileType = 'glacialspike'
skillName = '冰尖柱'
} else if (skillId === 38) {
const missile = getMissileTxtData('chargedbolt')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 1176
damage = 12
delay = 0
missileType = 'chargedbolt'
jitter = true
multiCount = 4
spreadAngle = 0.45
skillName = '充能弹'
} else if (skillId === 67) {
const missile = getMissileTxtData('teeth')
speed = missile.speedPxPerSec || 400
range = missile.distancePx || 800
damage = 14
delay = 0
missileType = 'teeth'
multiCount = 3
spreadAngle = 0.40
skillName = '牙'
} else if (skillId === 84) {
const missile = getMissileTxtData('bonespear')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 30
delay = 0
pierce = true
missileType = 'bonespear'
skillName = '骨矛'
} else if (skillId === 93) {
const missile = getMissileTxtData('bonespirit')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 1536
damage = 45
delay = 0
homing = true
missileType = 'bonespirit'
skillName = '骨魂'
} else if (skillId === 92) {
const missile = getMissileTxtData('poisonnova')
speed = missile.speedPxPerSec || 300
range = missile.distancePx || 360
damage = 18
delay = 0
statusEffect = 'poison'
statusDuration = 100
missileType = 'poisonnova'
isNova = true
multiCount = 32
skillName = '剧毒新星'
} else if (skillId === 101) {
const missile = getMissileTxtData('holybolt')
speed = missile.speedPxPerSec || 500
range = missile.distancePx || 1000
damage = 35
delay = 0
isUndeadOnly = true
missileType = 'holybolt'
skillName = '圣光弹'
} else if (skillId === 112) {
const missile = getMissileTxtData('blessedhammer')
speed = missile.speedPxPerSec || 450
range = missile.distancePx || 2160
damage = 40
delay = 0
pierce = true
missileType = 'blessedhammer'
const baseAngle = Math.atan2((targetY - player.y) / ISO_GROUND_ASPECT_RATIO, targetX - player.x)
spiral = { originX: player.x, originY: player.y, angle: baseAngle, radius: 12 }
skillName = '祝福之槌'
} else if (skillId === 6) {
const missile = getMissileTxtData('magicarrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 18
delay = 0
missileType = 'magicarrow'
skillName = '魔法箭'
} else if (skillId === 7) {
const missile = getMissileTxtData('firearrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 20
delay = 0
missileType = 'firearrow'
skillName = '火箭'
} else if (skillId === 11) {
const missile = getMissileTxtData('icearrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 22
delay = 0
statusEffect = 'chill'
statusDuration = 50
missileType = 'icearrow'
skillName = '冰箭'
} else if (skillId === 21) {
const missile = getMissileTxtData('icearrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 26
delay = 0
statusEffect = 'freeze'
statusDuration = 50
missileType = 'icearrow'
skillName = '急冻箭'
} else if (skillId === 12) {
const missile = getMissileTxtData('arrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 16
delay = 0
missileType = 'arrow'
multiCount = 5
spreadAngle = 0.50
skillName = '多重箭'
} else if (skillId === 16) {
const missile = getMissileTxtData('firearrow')
speed = missile.speedPxPerSec || 600
range = missile.distancePx || 960
damage = 25
delay = 0
aoeRadius = 48
missileType = 'firearrow'
skillName = '爆裂箭'
} else if (skillId === 20) {
const missile = getMissileTxtData('lightningjavelin')
speed = missile.speedPxPerSec || 750
range = missile.distancePx || 750
damage = 35
delay = 0
pierce = true
missileType = 'lightningjavelin'
skillName = '闪电之枪'
} else if (skillId === 64) {
speed = 400
range = 480
damage = 35
delay = 25
skillName = '冰封球'
} else if (skillId === 36) {
const missile = getMissileTxtData('firebolt')
speed = missile.speedPxPerSec || 500
range = missile.distancePx || 1000
damage = 16
delay = 0
skillName = '火弹'
} else if (skillId === 39) {
speed = 450
range = 400
damage = 14
delay = 0
skillName = '冰弹'
} else if (skillId === 38) {
speed = 350
range = 350
damage = 12
delay = 0
skillName = '充能弹'
} else if (skillId === 49) {
speed = 600
range = 500
@ -1319,23 +1518,90 @@ export function castSkill(
const dx = targetX - player.x
const dy = targetY - player.y
const len = Math.hypot(dx, dy)
const baseAngle = Math.atan2(dy, dx)
const perTick = speed / 25
const ttl = Math.max(1, Math.round(range / perTick))
const projectile: Projectile = {
skillId: String(skillId),
x: player.x,
y: player.y,
vx: len > 0 ? (dx / len) * perTick : perTick,
vy: len > 0 ? (dy / len) * perTick : 0,
damage,
ttl,
fromPlayer: true,
missileType: skillId === 36 ? 'firebolt' : undefined,
if (isNova) {
for (let i = 0; i < multiCount; i++) {
const angle = (i / multiCount) * 2 * Math.PI
const pVx = Math.cos(angle) * perTick
const pVy = Math.sin(angle) * (perTick * ISO_GROUND_ASPECT_RATIO)
engine.projectiles.push({
skillId: String(skillId),
x: player.x,
y: player.y,
vx: pVx,
vy: pVy,
damage,
ttl,
fromPlayer: true,
missileType,
statusEffect,
statusDuration,
})
}
} else if (multiCount > 1) {
const startAngle = baseAngle - spreadAngle / 2
const step = spreadAngle / (multiCount - 1)
for (let i = 0; i < multiCount; i++) {
const angle = startAngle + i * step
const pVx = Math.cos(angle) * perTick
const pVy = Math.sin(angle) * perTick
engine.projectiles.push({
skillId: String(skillId),
x: player.x,
y: player.y,
vx: pVx,
vy: pVy,
damage,
ttl,
fromPlayer: true,
missileType,
jitter,
pierce,
aoeRadius,
statusEffect,
statusDuration,
isUndeadOnly,
})
}
} else {
let pVx: number
let pVy: number
let pX = player.x
let pY = player.y
if (spiral !== undefined) {
pX = spiral.originX + Math.cos(spiral.angle) * spiral.radius
pY = spiral.originY + Math.sin(spiral.angle) * (spiral.radius * ISO_GROUND_ASPECT_RATIO)
pVx = -Math.sin(spiral.angle) * 18
pVy = Math.cos(spiral.angle) * (18 * ISO_GROUND_ASPECT_RATIO)
} else {
const len = Math.hypot(dx, dy)
pVx = len > 0 ? (dx / len) * perTick : perTick
pVy = len > 0 ? (dy / len) * perTick : 0
}
engine.projectiles.push({
skillId: String(skillId),
x: pX,
y: pY,
vx: pVx,
vy: pVy,
damage,
ttl,
fromPlayer: true,
missileType,
pierce,
aoeRadius,
statusEffect,
statusDuration,
homing,
spiral,
jitter,
isUndeadOnly,
})
}
engine.projectiles.push(projectile)
player.cooldown = delay > 0 ? delay : 10
engine.metrics.casts += 1
status.textContent = `施展技能:${skillName}`
@ -1461,6 +1727,10 @@ export class SceneMouseController {
return
}
this.engine.groundItems.remove(item.id)
const gIdx = this.engine.ground.findIndex(g => Math.hypot(g.x - item.x, g.y - item.y) <= 32 || g.item === item.item)
if (gIdx !== -1) {
this.engine.ground.splice(gIdx, 1)
}
this.engine.metrics.pickups += 1
this.hudManager.syncPublishedState()
this.status.textContent = `拾起物品:${item.nameZh || item.name}`
@ -3249,7 +3519,7 @@ export async function loadMissileArtMap(
): Promise<Map<string, LoadedMissileArt>> {
const map = new Map<string, LoadedMissileArt>()
if (typeof fetch === 'undefined') return map
const targets = ['firebolt', 'fireball', 'fireexplode']
const targets = Object.keys(MISSILE_METAS)
const baseCandidates = [
'/missiles',
packBase ? `${packBase}/missiles` : '',
@ -3274,8 +3544,8 @@ export async function loadMissileArtMap(
} finally {
bitmap.close()
}
const frames: AtlasFrame[][] = meta.groups.map(group =>
group.map(([x, y, width, height, anchorX, anchorY]) => ({
const frames: AtlasFrame[][] = meta.groups.map((group: readonly MissileFrameTuple[]) =>
group.map(([x, y, width, height, anchorX, anchorY]: MissileFrameTuple) => ({
x,
y,
width,
@ -3297,11 +3567,11 @@ export async function loadMissileArtMap(
}
/**
* Render a Diablo II 1.13c style Fire Bolt projectile:
* - Uses authentic baked Firebolt.dcc sprite atlas when available.
* Render a Diablo II 1.13c missile projectile:
* - Uses authentic baked DCC sprite atlas when available.
* - Gracefully falls back to procedural glowing fireball & trailing particles in test/offline environments.
*/
export function drawFireboltProjectile(
export function drawMissileProjectile(
renderer: SpriteRenderer,
shot: Projectile,
missileArt?: LoadedMissileArt,
@ -3309,13 +3579,17 @@ export function drawFireboltProjectile(
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 frameIndex = Math.abs(Math.floor(((100 - 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 })
const isAdditive = missileArt.meta.name !== 'arrow'
renderer.draw(frame, drawX, drawY, {
atlas: missileArt.handle,
...(isAdditive ? { blendMode: 'additive' } : {}),
})
return
}
}
@ -3358,12 +3632,14 @@ export function drawFireboltProjectile(
renderer.drawSolid(x - 2, y - 2, 4, 4, [1.0, 1.0, 0.88, 0.98])
}
export const drawFireboltProjectile = drawMissileProjectile
/**
* Render a Diablo II 1.13c style fire explosion (fireexplode):
* - Uses authentic baked FireArrowExplode2.dcc sprite atlas when available.
* Render a Diablo II 1.13c style explosion:
* - Uses authentic baked DCC sprite atlas when available.
* - Falls back to procedural explosion in test/offline environments.
*/
export function drawFireExplosion(
export function drawExplosion(
renderer: SpriteRenderer,
exp: ActiveExplosion,
explosionArt?: LoadedMissileArt,
@ -3376,7 +3652,10 @@ export function drawFireExplosion(
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 })
renderer.draw(frame, drawX, drawY, {
atlas: explosionArt.handle,
blendMode: 'additive',
})
return
}
}
@ -3414,6 +3693,8 @@ export function drawFireExplosion(
}
}
export const drawFireExplosion = drawExplosion
/** Quality colors for ground item highlights and glints. */
export const GROUND_ITEM_QUALITY_COLORS: Record<string, readonly [number, number, number, number]> = {
normal: [0.88, 0.88, 0.88, 1.0],
@ -3428,16 +3709,55 @@ export const GROUND_ITEM_QUALITY_COLORS: Record<string, readonly [number, number
gold: [1.0, 0.84, 0.2, 1.0],
}
export let globalItemsAtlasHandle: AtlasHandle | undefined
/**
* Load authentic 1.13c baked items atlas from /ui/items-atlas.png.
*/
export async function loadItemsAtlas(
renderer: SpriteRenderer,
packBase?: string,
): Promise<AtlasHandle | undefined> {
if (globalItemsAtlasHandle !== undefined) return globalItemsAtlasHandle
if (typeof fetch === 'undefined' || typeof createImageBitmap === 'undefined') return undefined
const candidates = [
'/ui/items-atlas.png',
packBase ? `${packBase}/ui/items-atlas.png` : '',
'ui/items-atlas.png',
'public/ui/items-atlas.png',
].filter(Boolean)
for (const url of candidates) {
try {
const resp = await fetch(url)
if (!resp.ok) continue
const blob = await resp.blob()
const bitmap = await createImageBitmap(blob)
try {
const handle = renderer.addAtlas(bitmap, BAKED_UI_MANIFEST.atlasWidth, BAKED_UI_MANIFEST.atlasHeight)
globalItemsAtlasHandle = handle
return handle
} finally {
bitmap.close()
}
} catch {
// try next candidate
}
}
return undefined
}
/**
* Render a ground item entity with authentic Diablo II 1.13c appearance:
* - Parabolic bounce height offset (when dropping or inventory full)
* - Ground item icon / scaled silhouette
* - Authentic item sprite icon / scaled silhouette lying on the ground
* - Periodic glint/sparkle 4-point star animation
*/
export function drawGroundItem(
renderer: SpriteRenderer,
item: GroundItemEntity,
now: number,
itemsAtlas: AtlasHandle | undefined = globalItemsAtlasHandle,
): void {
const bounceH = item.bounceState ? calculateBounceHeight(item.bounceState, now) : 0
const baseY = item.y - bounceH
@ -3468,32 +3788,72 @@ export function drawGroundItem(
renderer.drawSolid(item.x - w / 2 + 1, baseY - h + 1, w - 2, Math.max(2, Math.floor(h / 3)), [1.0, 0.92, 0.45, 1.0])
} else {
// 2. Equipment / Potion / Rune Item Rendering
const w = item.invWidth > 1 ? 22 : 14
const h = item.invHeight > 2 ? 34 : item.invHeight > 1 ? 24 : 14
const sr = (itemsAtlas !== undefined && typeof (renderer as any).draw === 'function')
? resolveItemSpriteRect(item.item || (item as any), BAKED_UI_MANIFEST.itemRects)
: null
// Drop shadow: stays grounded on floor (item.y - 3), alpha fades slightly with height
const shadowAlpha = bounceH > 0 ? Math.max(0.15, 0.38 * (1 - bounceH / 45)) : 0.38
renderer.drawSolid(item.x - w / 2 - 2, item.y - 3, w + 4, 5, [0, 0, 0, shadowAlpha])
if (sr !== null && itemsAtlas !== undefined) {
// Authentic Diablo II ground item sprite icon:
// Scales sprite to lie on ground naturally (~16-34px)
const maxDim = Math.max(sr.w, sr.h)
const scale = Math.min(0.75, 34 / maxDim)
const baseW = Math.max(12, Math.round(sr.w * scale))
const baseH = Math.max(12, Math.round(sr.h * scale))
// Authentic flippy tumble width modulation during vertical bounce
let renderW = w
if (bounceH > 0 && item.bounceState) {
const elapsed = now - item.bounceState.startTime
// Spin oscillation creating flipping card / tumbling item illusion
const spinScale = Math.abs(Math.cos(elapsed * 0.018))
renderW = Math.max(6, Math.round(w * (0.4 + 0.6 * spinScale)))
// Drop shadow: stays grounded on floor (item.y - 2), alpha fades slightly with height
const shadowAlpha = bounceH > 0 ? Math.max(0.12, 0.42 * (1 - bounceH / 45)) : 0.42
const shadowW = Math.max(14, baseW + 6)
renderer.drawSolid(item.x - shadowW / 2, item.y - 2, shadowW, 4, [0, 0, 0, shadowAlpha])
// Authentic flippy tumble width modulation during vertical bounce
let renderW = baseW
if (bounceH > 0 && item.bounceState) {
const elapsed = now - item.bounceState.startTime
// Spin oscillation creating flipping card / tumbling item illusion
const spinScale = Math.abs(Math.cos(elapsed * 0.018))
renderW = Math.max(4, Math.round(baseW * (0.35 + 0.65 * spinScale)))
}
const frame: AtlasFrame = {
x: sr.x,
y: sr.y,
width: sr.w,
height: sr.h,
}
renderer.draw(frame, item.x - renderW / 2, baseY - baseH, {
atlas: itemsAtlas,
width: renderW,
height: baseH,
})
} else {
// Fallback for mock renderer or headless unit tests
const w = item.invWidth > 1 ? 22 : 14
const h = item.invHeight > 2 ? 34 : item.invHeight > 1 ? 24 : 14
// Drop shadow: stays grounded on floor (item.y - 3), alpha fades slightly with height
const shadowAlpha = bounceH > 0 ? Math.max(0.15, 0.38 * (1 - bounceH / 45)) : 0.38
renderer.drawSolid(item.x - w / 2 - 2, item.y - 3, w + 4, 5, [0, 0, 0, shadowAlpha])
// Authentic flippy tumble width modulation during vertical bounce
let renderW = w
if (bounceH > 0 && item.bounceState) {
const elapsed = now - item.bounceState.startTime
// Spin oscillation creating flipping card / tumbling item illusion
const spinScale = Math.abs(Math.cos(elapsed * 0.018))
renderW = Math.max(6, Math.round(w * (0.4 + 0.6 * spinScale)))
}
// Body
const qr = qColor[0]
const qg = qColor[1]
const qb = qColor[2]
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, h, [qr * 0.45, qg * 0.45, qb * 0.45, 0.9])
// Border rim
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 2, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x - renderW / 2, baseY - 2, renderW, 2, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x - renderW / 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x + renderW / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
}
// Body
const qr = qColor[0]
const qg = qColor[1]
const qb = qColor[2]
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, h, [qr * 0.45, qg * 0.45, qb * 0.45, 0.9])
// Border rim
renderer.drawSolid(item.x - renderW / 2, baseY - h, renderW, 2, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x - renderW / 2, baseY - 2, renderW, 2, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x - renderW / 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
renderer.drawSolid(item.x + renderW / 2 - 2, baseY - h, 2, h, [qr, qg, qb, 0.95])
}
// 3. Glint / Sparkle 4-Point Star Animation
@ -3561,6 +3921,10 @@ async function runScene(initialRuntime: MapRuntime, renderer: SpriteRenderer, st
void loadMissileArtMap(renderer, packEntityBase).then(map => {
missileArtMap = map
})
let itemsAtlasHandle: AtlasHandle | undefined
void loadItemsAtlas(renderer, packEntityBase).then(handle => {
itemsAtlasHandle = handle
})
let monsterWalkFrame = 0
const playerAnimator = new ActorAnimator()
@ -4612,10 +4976,10 @@ if (typeof window !== 'undefined') {
for (const shot of engine.projectiles) {
pushEntity(shot.x, shot.y, () => {
if (shot.skillId === '36' || shot.skillId === 'firebolt' || shot.missileType === 'firebolt') {
drawFireboltProjectile(renderer, shot, missileArtMap.get('firebolt'))
} else if (shot.skillId === '47' || shot.skillId === 'fireball' || shot.missileType === 'fireball') {
drawFireboltProjectile(renderer, shot, missileArtMap.get('fireball'))
const mType = shot.missileType ?? (shot.skillId === '36' ? 'firebolt' : shot.skillId === '47' ? 'fireball' : undefined)
const art = mType !== undefined ? missileArtMap.get(mType) : undefined
if (art !== undefined) {
drawMissileProjectile(renderer, shot, art)
} else {
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
}
@ -4624,25 +4988,17 @@ if (typeof window !== 'undefined') {
for (const exp of engine.explosions) {
pushEntity(exp.x, exp.y, () => {
drawFireExplosion(renderer, exp, missileArtMap.get('fireexplode'))
const art = missileArtMap.get(exp.missileType) ?? missileArtMap.get('fireexplode')
drawExplosion(renderer, exp, art)
})
}
for (const gItem of engine.groundItems.all) {
pushEntity(gItem.x, gItem.y, () => {
drawGroundItem(renderer, gItem, renderStarted)
drawGroundItem(renderer, gItem, renderStarted, itemsAtlasHandle)
})
}
if (engine.groundItems.count === 0 && engine.ground.length > 0) {
for (const entry of engine.ground) {
pushEntity(entry.x, entry.y, () => {
const size = 6
renderer.drawSolid(entry.x - size / 2, entry.y - size, size, size, [0.8, 0.8, 0.8, 1])
})
}
}
entities.sort((a, b) => a.depth - b.depth)
for (const entity of entities) {

View File

@ -35,13 +35,13 @@ export interface HotkeySkillEntry {
* left/right action buttons or appear in the Speedbar popup.
*/
export const PASSIVE_SKILL_IDS: ReadonlySet<number> = new Set<number>([
// Amazon (Tab 1 Passives)
// Amazon (Tab 1 Passives: Critical Strike 9, Dodge 13, Avoid 18, Penetrate 23, Evade 29, Pierce 33)
9, // Critical Strike
14, // Penetrate
17, // Dodge
22, // Avoid
27, // Evade
29, // Pierce
13, // Dodge
18, // Avoid
23, // Penetrate
29, // Evade
33, // Pierce
// Sorceress Passives
37, // Warmth
61, // Fire Mastery
@ -74,6 +74,24 @@ export function isPassiveSkill(skillId: number): boolean {
return PASSIVE_SKILL_IDS.has(skillId)
}
/**
* Diablo II v1.13c Skills usable on the Left-Click action button (Skills.txt `leftskill = 1`).
*/
export const LEFT_USABLE_SKILL_IDS: ReadonlySet<number> = new Set<number>([
0, 2, 6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35,
36, 38, 39, 41, 45, 47, 49, 53, 55, 56, 59, 64,
67, 73, 84, 93,
96, 97, 101, 106, 107, 111, 112, 116, 121,
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
225, 229, 230, 232, 238, 239, 240, 242, 243, 245, 248,
251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280,
])
export function isLeftUsableSkill(skillId: number): boolean {
if (isPassiveSkill(skillId)) return false
return LEFT_USABLE_SKILL_IDS.has(skillId)
}
/**
* Diablo II v1.13c Paladin Aura Skills (`Skills.txt` `aura = 1`, Tabs 1 & 2).
* Paladin Auras are highlighted in yellow on the Speedbar and Quickbar slots,
@ -190,20 +208,39 @@ export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48 } as co
export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as const
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
// Universal Skills
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
// Fire Spells (8 active)
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true },
{ skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true },
{ skillId: 46, name: 'Blaze', nameZh: '炽烈之径', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true },
{ skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true },
{ skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true },
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, rightUsable: true },
{ skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true },
// Lightning Spells (9 active)
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
{ skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true },
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true },
{ skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: true, rightUsable: true },
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
{ skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true },
{ skillId: 42, name: 'Static Field', nameZh: '静态力场', level: 5, manaCost: 9, leftUsable: false, rightUsable: true },
{ skillId: 43, name: 'Telekinesis', nameZh: '心灵传动', level: 1, manaCost: 7, leftUsable: false, rightUsable: true },
{ skillId: 48, name: 'Nova', nameZh: '新星', level: 1, manaCost: 15, leftUsable: false, rightUsable: true },
{ skillId: 49, name: 'Lightning', nameZh: '闪电', level: 7, manaCost: 11, leftUsable: true, rightUsable: true },
{ skillId: 53, name: 'Chain Lightning', nameZh: '连锁闪电', level: 1, manaCost: 9, leftUsable: true, rightUsable: true },
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 4, manaCost: 21, leftUsable: false, rightUsable: true },
{ skillId: 57, name: 'Thunder Storm', nameZh: '雷云风暴', level: 1, manaCost: 19, leftUsable: false, rightUsable: true },
{ skillId: 58, name: 'Energy Shield', nameZh: '能量护盾', level: 1, manaCost: 5, leftUsable: false, rightUsable: true },
// Cold Spells (9 active)
{ skillId: 39, name: 'Ice Bolt', nameZh: '冰弹', level: 1, manaCost: 3, leftUsable: true, rightUsable: true },
{ skillId: 40, name: 'Frozen Armor', nameZh: '冰封装甲', level: 3, manaCost: 7, leftUsable: false, rightUsable: true },
{ skillId: 44, name: 'Frost Nova', nameZh: '霜之新星', level: 3, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true },
{ skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true },
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: true, rightUsable: true },
{ skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true },
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
]
export class SkillHotkeysHud {
@ -345,6 +382,17 @@ export class SkillHotkeysHud {
this.preloadSkillIcons()
}
addOrUpdateSkill(entry: HotkeySkillEntry): void {
if (isPassiveSkill(entry.skillId) || entry.isPassive) return
const idx = this.availableSkills.findIndex(s => s.skillId === entry.skillId)
if (idx >= 0) {
this.availableSkills[idx] = entry
} else {
this.availableSkills.push(entry)
}
this.preloadSkillIcons()
}
getHotkeyLabelForSkill(side: 'left' | 'right', skillId: number): string | null {
for (const [key, binding] of this.bindings.entries()) {
if (binding.side === side && binding.skillId === skillId) return key

View File

@ -17,8 +17,10 @@
import { D2FontRenderer } from './font.ts'
import { GlobesHud } from './globes.ts'
import { BeltHud } from './belt.ts'
import { SkillHotkeysHud } from './hotkeys.ts'
import { SkillHotkeysHud, isPassiveSkill, isAuraSkill, isLeftUsableSkill } from './hotkeys.ts'
import { ControlBarHud, type MiniPanelAction } from './control-bar.ts'
import { SKILLS_BY_ID } from '../data/skills-catalog.ts'
import { calculateManaCost } from '../game/skill-calc-engine.ts'
import { InventoryPanel, type EquipSlotId, type UiInventoryItem, resolveItemSpriteRect, INV_GRID_ORIGIN } from './inventory.ts'
import { CharacterSheetPanel, type BaseStatKey } from './character-sheet.ts'
import { SkillTreePanel, SORCERESS_SKILL_TREE } from './skill-tree-panel.ts'
@ -810,7 +812,7 @@ export class HudManager {
this.syncPublishedState()
return
} else if (this.rightPanel === 'skill') {
this.skillTree.handleClick(rightX, pt.y)
this.skillTree.handleClick(rightX, pt.y, (skillId) => this.handleSkillAllocated(skillId))
if (!this.skillTree.visible) this.rightPanel = 'none'
this.syncPublishedState()
return
@ -819,6 +821,34 @@ export class HudManager {
})
}
private handleSkillAllocated(skillId: number): void {
if (isPassiveSkill(skillId)) return
const catalogEntry = SKILLS_BY_ID[skillId]
if (!catalogEntry) return
const currentHard = this.skillTree.hardPoints.get(skillId) ?? 1
const mana = calculateManaCost(catalogEntry, currentHard)
const leftUsable = isLeftUsableSkill(skillId)
this.hotkeys.addOrUpdateSkill({
skillId,
name: catalogEntry.name,
nameZh: catalogEntry.nameZh,
level: currentHard,
manaCost: mana,
leftUsable,
rightUsable: true,
isAura: isAuraSkill(skillId),
})
const profile = this.currentClass ? this.classProfiles.get(this.currentClass) : undefined
if (profile) {
profile.availableSkills = [...this.hotkeys.availableSkills]
profile.hardPoints = new Map(this.skillTree.hardPoints)
profile.unspentSkillPoints = this.skillTree.unspentSkillPoints
}
}
private buildControlBarState() {
return {
level: this.charSheet.attrs.level,

View File

@ -289,6 +289,35 @@ export function resolveItemSpriteRect(
return itemRects['invcrs'] ?? null
}
// Tier 4.5: Name and semantic keyword match for common weapons, armors, and potions
const nameStr = (
(item.name || '') + ' ' +
(item.id || '') + ' ' +
((item as any).nameZh || '') + ' ' +
((item as any).base?.name || '') + ' ' +
((item as any).base?.nameZh || '')
).toLowerCase()
if (nameStr.includes('sword') || nameStr.includes('剑')) return itemRects['invssd'] ?? itemRects['invbsd'] ?? itemRects['invcrs'] ?? null
if (nameStr.includes('axe') || nameStr.includes('斧')) return itemRects['invhax'] ?? itemRects['invbax'] ?? null
if (nameStr.includes('bow') || nameStr.includes('弓')) return itemRects['invsbw'] ?? itemRects['invhbw'] ?? null
if (nameStr.includes('shield') || nameStr.includes('盾')) return itemRects['invsml'] ?? itemRects['invlrg'] ?? itemRects['invkit'] ?? null
if (nameStr.includes('helm') || nameStr.includes('cap') || nameStr.includes('盔') || nameStr.includes('帽')) return itemRects['invcap'] ?? itemRects['invhlm'] ?? null
if (nameStr.includes('armor') || nameStr.includes('plate') || nameStr.includes('甲')) return itemRects['invlea'] ?? itemRects['invqui'] ?? itemRects['invfld'] ?? null
if (nameStr.includes('potion') || nameStr.includes('药')) {
if (nameStr.includes('mana') || nameStr.includes('法力') || nameStr.includes('蓝')) return itemRects['invmp1'] ?? itemRects['invhp1'] ?? null
if (nameStr.includes('rejuv') || nameStr.includes('活力') || nameStr.includes('紫')) return itemRects['invrvs'] ?? itemRects['invhp1'] ?? null
return itemRects['invhp1'] ?? null
}
if (nameStr.includes('ring') || nameStr.includes('戒')) return itemRects['invrin1'] ?? null
if (nameStr.includes('amulet') || nameStr.includes('项链')) return itemRects['invamu1'] ?? null
if (nameStr.includes('boot') || nameStr.includes('靴') || nameStr.includes('鞋')) return itemRects['invlbt'] ?? itemRects['invhbt'] ?? null
if (nameStr.includes('glove') || nameStr.includes('手套')) return itemRects['invlgl'] ?? itemRects['invhgl'] ?? null
if (nameStr.includes('belt') || nameStr.includes('腰带') || nameStr.includes('带')) return itemRects['invlbl'] ?? itemRects['invhbl'] ?? null
if (nameStr.includes('rune') || nameStr.includes('符文')) return itemRects['invr01'] ?? null
if (nameStr.includes('scroll') || nameStr.includes('卷轴')) return itemRects['invtsc'] ?? itemRects['invisc'] ?? null
if (nameStr.includes('gold') || nameStr.includes('金币')) return itemRects['invgld'] ?? null
// Tier 5: Dimension-based fallback guarantee (w x h in cells)
const w = item.invWidth ?? (item as any).width ?? 1
const h = item.invHeight ?? (item as any).height ?? 1

View File

@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { SkillHotkeysHud, DEFAULT_SORCERESS_SKILLS, LEFT_SKILL_BOUNDS, RIGHT_SKILL_BOUNDS } from '../src/ui/hotkeys.ts'
import { HudManager } from '../src/ui/hud-manager.ts'
import { CLASS_DEFAULT_SKILLS, createStarterProfileForClass } from '../src/game/class-starter-profiles.ts'
import { SKILLS_BY_ID } from '../src/data/skills-catalog.ts'
describe('Issue Fix: Glacial Spike (#55) and All Active Skills on Quickbar Speedbars', () => {
let hud: SkillHotkeysHud
beforeEach(() => {
hud = new SkillHotkeysHud()
})
describe('1. Sorceress Right-Click & Left-Click Quickbar includes Glacial Spike', () => {
it('has Glacial Spike (55) in DEFAULT_SORCERESS_SKILLS with level 10 and authentic mana', () => {
const entry = DEFAULT_SORCERESS_SKILLS.find(s => s.skillId === 55)
expect(entry).toBeDefined()
expect(entry!.name).toBe('Glacial Spike')
expect(entry!.nameZh).toBe('冰尖柱')
expect(entry!.level).toBe(10)
expect(entry!.manaCost).toBeCloseTo(14.5, 1)
expect(entry!.leftUsable).toBe(true)
expect(entry!.rightUsable).toBe(true)
})
it('shows Glacial Spike in right-click speedbar popup cells', () => {
const rightCells = hud.getSpeedbarSkills('right')
const spikeCell = rightCells.find(c => c.skill.skillId === 55)
expect(spikeCell).toBeDefined()
expect(spikeCell!.skill.name).toBe('Glacial Spike')
expect(spikeCell!.skill.nameZh).toBe('冰尖柱')
})
it('shows Glacial Spike in left-click speedbar popup cells', () => {
const leftCells = hud.getSpeedbarSkills('left')
const spikeCell = leftCells.find(c => c.skill.skillId === 55)
expect(spikeCell).toBeDefined()
expect(spikeCell!.skill.name).toBe('Glacial Spike')
})
it('assigns Glacial Spike to right-click slot when clicked in right speedbar', () => {
hud.openSpeedbar('right')
expect(hud.selectionOpen).toBe('right')
const rightCells = hud.getSpeedbarSkills('right')
const spikeCell = rightCells.find(c => c.skill.skillId === 55)!
expect(spikeCell).toBeDefined()
const handled = hud.handleClick(spikeCell.x + 10, spikeCell.y + 10)
expect(handled).toBe(true)
expect(hud.rightSkillId).toBe(55)
expect(hud.selectionOpen).toBeNull()
})
it('assigns Glacial Spike to left-click slot when clicked in left speedbar', () => {
hud.openSpeedbar('left')
expect(hud.selectionOpen).toBe('left')
const leftCells = hud.getSpeedbarSkills('left')
const spikeCell = leftCells.find(c => c.skill.skillId === 55)!
expect(spikeCell).toBeDefined()
const handled = hud.handleClick(spikeCell.x + 10, spikeCell.y + 10)
expect(handled).toBe(true)
expect(hud.leftSkillId).toBe(55)
expect(hud.selectionOpen).toBeNull()
})
})
describe('2. All 7 Classes Starter Profiles include all Active Skills', () => {
it('provides all 26 active skills on Sorceress right-click speedbar', () => {
const sorProfile = createStarterProfileForClass('sor')
const rightSkills = sorProfile.availableSkills.filter(s => s.rightUsable)
// 3 universal (Attack 0, Throw 2, Unsummon 3) + 26 Sorceress active = 29
expect(rightSkills.length).toBe(29)
// Glacial Spike is explicitly present
expect(rightSkills.some(s => s.skillId === 55)).toBe(true)
})
it('provides all active skills for all 7 classes without passives', () => {
const classes = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
const expectedActiveCounts: Record<string, number> = {
ama: 27, // 3 universal + 24 active
sor: 29, // 3 universal + 26 active
nec: 30, // 3 universal + 27 active
pal: 33, // 3 universal + 30 active
bar: 23, // 3 universal + 20 active
dru: 32, // 3 universal + 29 active
ass: 31, // 3 universal + 28 active
}
for (const cls of classes) {
const skills = CLASS_DEFAULT_SKILLS[cls]
expect(skills.length).toBe(expectedActiveCounts[cls])
// None should be passive
for (const s of skills) {
expect(s.isPassive).toBeFalsy()
}
}
})
})
describe('3. HudManager Skill Tree Allocation dynamically updates Available Skills', () => {
const mockCanvas = {
width: 800,
height: 600,
style: { cursor: '' },
addEventListener: () => {},
removeEventListener: () => {},
getContext: () => null,
} as unknown as HTMLCanvasElement
function createMockHud() {
const hud = new HudManager(mockCanvas, {
onToggleAutomap: () => {},
onWaypointTeleport: () => {},
})
hud.switchClass('sor')
return hud
}
it('initializes HudManager with Glacial Spike available in Sorceress profile', () => {
const hudManager = createMockHud()
expect(hudManager.hotkeys.availableSkills.some(s => s.skillId === 55)).toBe(true)
})
it('allocates new skill in skill tree and adds it to hotkeys.availableSkills', () => {
const hudManager = createMockHud()
// Open Skill tree panel
hudManager.rightPanel = 'skill'
hudManager.skillTree.visible = true
hudManager.skillTree.unspentSkillPoints = 5
// Select lightning tab (tab 1)
hudManager.skillTree.activeTab = 1
// Find Nova (48) which requires level 12 and has reqskills: Static Field (42) which is already allocated in starter
const nova = hudManager.hotkeys.availableSkills.find(s => s.skillId === 48)
expect(nova).toBeDefined()
// Allocate point into Nova by clicking on it
const node = hudManager.skillTree.getSkillTree().find(n => n.skillId === 48)!
expect(node).toBeDefined()
const prevPoints = hudManager.skillTree.hardPoints.get(48) ?? 0
const allocated = hudManager.skillTree.allocateSkill(48)
expect(allocated).toBe(true)
// Test handleSkillAllocated
const privateMethod = (hudManager as any).handleSkillAllocated.bind(hudManager)
privateMethod(48)
const updatedNova = hudManager.hotkeys.availableSkills.find(s => s.skillId === 48)
expect(updatedNova).toBeDefined()
expect(updatedNova!.level).toBe(prevPoints + 1)
})
})
})

View File

@ -230,5 +230,27 @@ describe('Ground Items Pickup & Pathfinding (Issue #391)', () => {
expect(engine.groundItems.count).toBe(0)
expect(status.textContent).toBe('拾起物品:戒指')
})
it('cleans up both groundItems and engine.ground on pickup, leaving zero residual dots', () => {
const engine = createTestEngine()
engine.world.player.x = 500
engine.world.player.y = 500
const entity = engine.dropItem(
{ id: 'sword', name: 'Short Sword', nameZh: '短剑', invWidth: 1, invHeight: 2 },
505,
505,
)
expect(engine.groundItems.count).toBe(1)
expect(engine.ground.length).toBe(1)
const { controller } = createMockController(engine)
controller.pickupGroundItem(entity)
// Verified: Both groundItems and engine.ground are cleanly cleared
expect(engine.groundItems.count).toBe(0)
expect(engine.ground.length).toBe(0)
})
})
})

View File

@ -270,4 +270,53 @@ describe('Issue #389 — Ground Item Rendering & Glint Sparkle Ground Truth', ()
expect(drawnQuads.length).toBe(6)
})
})
describe('5. Authentic Ground Item Sprite Parity (Anti-Blue-Box & Clean Pickup)', () => {
it('renders authentic item sprite using itemsAtlas instead of solid color box', () => {
const drawnQuads: { x: number; y: number; w: number; h: number; color: readonly [number, number, number, number] }[] = []
const drawnSprites: { frame: any; x: number; y: number; options: any }[] = []
const renderer = {
drawSolid(x: number, y: number, w: number, h: number, color: readonly [number, number, number, number]) {
drawnQuads.push({ x, y, w, h, color })
},
draw(frame: any, x: number, y: number, options: any) {
drawnSprites.push({ frame, x, y, options })
},
} as unknown as SpriteRenderer
const mockAtlas = { texture: {} as WebGLTexture, width: 1024, height: 1024 } as any
const magicSword: GroundItemEntity = {
id: 'magic_sword',
item: { name: 'Short Sword', code: 'ssd' },
name: 'Short Sword',
nameZh: '短剑',
quality: 'magic',
isGold: false,
amount: 1,
invWidth: 1,
invHeight: 3,
dropTime: 0,
x: 100,
y: 200,
cellX: 0,
cellY: 0,
sparklePhase: 0,
}
drawGroundItem(renderer, magicSword, 1000, mockAtlas)
// Verified: Sprite is drawn using itemsAtlas
expect(drawnSprites.length).toBe(1)
const sprite = drawnSprites[0]!
expect(sprite.options.atlas).toBe(mockAtlas)
expect(sprite.options.width).toBeGreaterThan(0)
expect(sprite.options.height).toBeGreaterThan(0)
// Drop shadow is drawn on ground
expect(drawnQuads.some(q => q.color[3] < 0.5 && q.y >= 198)).toBe(true)
// Crucial: No opaque blue body box drawn in quads
const blueBody = drawnQuads.find(q => q.color[2] > 0.8 && q.w > 10 && q.h > 10)
expect(blueBody).toBeUndefined()
})
})
})

View File

@ -33,14 +33,20 @@ describe('Diablo II v1.13c Quickbar & Speedbar Authentic Mechanics (Issue #382)'
})
describe('1. Passive Skills Filtering (被动技能不进入快捷栏)', () => {
it('identifies all 23 canonical Diablo II 1.13c passive skills', () => {
// Amazon passives
it('identifies all 26 canonical Diablo II 1.13c passive skills', () => {
// Amazon passives (Critical Strike 9, Dodge 13, Avoid 18, Penetrate 23, Evade 29, Pierce 33)
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
expect(isPassiveSkill(13)).toBe(true) // Dodge
expect(isPassiveSkill(18)).toBe(true) // Avoid
expect(isPassiveSkill(23)).toBe(true) // Penetrate
expect(isPassiveSkill(29)).toBe(true) // Evade
expect(isPassiveSkill(33)).toBe(true) // Pierce
// Amazon active skills that must not be marked as passive
expect(isPassiveSkill(14)).toBe(false) // Power Strike
expect(isPassiveSkill(17)).toBe(false) // Slow Missiles
expect(isPassiveSkill(22)).toBe(false) // Guided Arrow
expect(isPassiveSkill(27)).toBe(false) // Immolation Arrow
// Sorceress passives
expect(isPassiveSkill(37)).toBe(true) // Warmth

View File

@ -254,4 +254,93 @@ describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
hud.assignSkill('right', 3)
expect(hud.rightSkillId).toBe(3)
})
it('includes Glacial Spike (55) in right-click and left-click speedbars and allows assignment (Issue #385)', () => {
// 1. Verify Glacial Spike is in availableSkills
const glacialSpike = hud.availableSkills.find((s) => s.skillId === 55)
expect(glacialSpike).toBeDefined()
expect(glacialSpike!.name).toBe('Glacial Spike')
expect(glacialSpike!.nameZh).toBe('冰尖柱')
expect(glacialSpike!.level).toBe(10)
expect(glacialSpike!.leftUsable).toBe(true)
expect(glacialSpike!.rightUsable).toBe(true)
// 2. Right speedbar contains Glacial Spike
const rightSpeedbar = hud.getSpeedbarSkills('right')
const rightCell = rightSpeedbar.find((c) => c.skill.skillId === 55)
expect(rightCell).toBeDefined()
// Click on Glacial Spike in right speedbar
hud.openSpeedbar('right')
const clickedRight = hud.handleClick(rightCell!.x + 10, rightCell!.y + 10)
expect(clickedRight).toBe(true)
expect(hud.rightSkillId).toBe(55)
expect(hud.selectionOpen).toBeNull()
// 3. Left speedbar contains Glacial Spike
const leftSpeedbar = hud.getSpeedbarSkills('left')
const leftCell = leftSpeedbar.find((c) => c.skill.skillId === 55)
expect(leftCell).toBeDefined()
// Click on Glacial Spike in left speedbar
hud.openSpeedbar('left')
const clickedLeft = hud.handleClick(leftCell!.x + 10, leftCell!.y + 10)
expect(clickedLeft).toBe(true)
expect(hud.leftSkillId).toBe(55)
expect(hud.selectionOpen).toBeNull()
// 4. Icon resolution
const icon = resolveSkillIcon(55)
expect(icon.skillId).toBe(55)
expect(icon.iconPath).toBe('/skills/icon_55.png')
})
it('includes all 26 active Sorceress skills on right-click speedbar and excludes all 4 passives', () => {
const rightSkills = hud.getSpeedbarSkills('right')
// 3 universal (Attack 0, Throw 2, Unsummon 3) + 26 class active = 29 skills
expect(rightSkills.length).toBe(29)
// Verify all 26 active Sorceress skills are present
const activeIds = [
36, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50,
51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 62, 64,
]
for (const id of activeIds) {
expect(rightSkills.some((c) => c.skill.skillId === id)).toBe(true)
}
// Verify the 4 passives are NOT present
const passiveIds = [37, 61, 63, 65]
for (const id of passiveIds) {
expect(rightSkills.some((c) => c.skill.skillId === id)).toBe(false)
}
})
it('allows adding and updating skills dynamically via addOrUpdateSkill', () => {
// Add custom skill
hud.addOrUpdateSkill({
skillId: 48, // Nova
name: 'Nova',
nameZh: '新星',
level: 15,
manaCost: 20,
leftUsable: false,
rightUsable: true,
})
const nova = hud.availableSkills.find((s) => s.skillId === 48)
expect(nova).toBeDefined()
expect(nova!.level).toBe(15)
// Cannot add passive skill
hud.addOrUpdateSkill({
skillId: 37, // Warmth
name: 'Warmth',
nameZh: '暖气',
level: 5,
manaCost: 0,
leftUsable: false,
rightUsable: true,
})
expect(hud.availableSkills.some((s) => s.skillId === 37)).toBe(false)
})
})

File diff suppressed because it is too large Load Diff