Compare commits
2 Commits
39de19364f
...
84c7730382
| Author | SHA1 | Date |
|---|---|---|
|
|
84c7730382 | |
|
|
96eee679b4 |
|
|
@ -0,0 +1,309 @@
|
|||
{
|
||||
"name": "firemedium",
|
||||
"celFile": "groundFireMedium",
|
||||
"width": 1024,
|
||||
"height": 410,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 37,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
67,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
134,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
201,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
268,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
335,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
402,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
469,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
536,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
603,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
670,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
737,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
804,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
871,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
938,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
0,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
67,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
134,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
201,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
268,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
335,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
402,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
469,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
536,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
603,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
670,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
737,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
804,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
871,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
938,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
0,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
67,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
134,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
201,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
268,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
335,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
402,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 119 KiB |
|
|
@ -0,0 +1,309 @@
|
|||
{
|
||||
"name": "firesmall",
|
||||
"celFile": "groundFireSmall",
|
||||
"width": 512,
|
||||
"height": 539,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 37,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
67,
|
||||
0,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
134,
|
||||
0,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
201,
|
||||
0,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
268,
|
||||
0,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
335,
|
||||
0,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
402,
|
||||
0,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
0,
|
||||
90,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
67,
|
||||
90,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
134,
|
||||
90,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
201,
|
||||
90,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
268,
|
||||
90,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
335,
|
||||
90,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
402,
|
||||
90,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
0,
|
||||
180,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
67,
|
||||
180,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
134,
|
||||
180,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
201,
|
||||
180,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
268,
|
||||
180,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
335,
|
||||
180,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
402,
|
||||
180,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
0,
|
||||
270,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
67,
|
||||
270,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
134,
|
||||
270,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
201,
|
||||
270,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
268,
|
||||
270,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
335,
|
||||
270,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
402,
|
||||
270,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
0,
|
||||
360,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
67,
|
||||
360,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
134,
|
||||
360,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
201,
|
||||
360,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
268,
|
||||
360,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
335,
|
||||
360,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
402,
|
||||
360,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
0,
|
||||
450,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
],
|
||||
[
|
||||
67,
|
||||
450,
|
||||
66,
|
||||
89,
|
||||
-33,
|
||||
-77
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"name": "meteor",
|
||||
"celFile": "Meteor",
|
||||
"width": 512,
|
||||
"height": 22,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 12,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
23,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
46,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
69,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
92,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
115,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
138,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
161,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
184,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
207,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
230,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
],
|
||||
[
|
||||
253,
|
||||
0,
|
||||
22,
|
||||
22,
|
||||
-7,
|
||||
-55
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"name": "meteorcenter",
|
||||
"celFile": "MeteorTargetIndicator",
|
||||
"width": 512,
|
||||
"height": 374,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 17,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
113,
|
||||
0,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
226,
|
||||
0,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
339,
|
||||
0,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
0,
|
||||
75,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
113,
|
||||
75,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
226,
|
||||
75,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
339,
|
||||
75,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
0,
|
||||
150,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
113,
|
||||
150,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
226,
|
||||
150,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
339,
|
||||
150,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
0,
|
||||
225,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
113,
|
||||
225,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
226,
|
||||
225,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
339,
|
||||
225,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
0,
|
||||
300,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
|
@ -0,0 +1,141 @@
|
|||
{
|
||||
"name": "meteorexplode",
|
||||
"celFile": "ExpArrowExplode",
|
||||
"width": 1024,
|
||||
"height": 699,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 16,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
209,
|
||||
0,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
418,
|
||||
0,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
627,
|
||||
0,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
0,
|
||||
175,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
209,
|
||||
175,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
418,
|
||||
175,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
627,
|
||||
175,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
0,
|
||||
350,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
209,
|
||||
350,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
418,
|
||||
350,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
627,
|
||||
350,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
0,
|
||||
525,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
209,
|
||||
525,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
418,
|
||||
525,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
],
|
||||
[
|
||||
627,
|
||||
525,
|
||||
208,
|
||||
174,
|
||||
-108,
|
||||
-123
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 106 KiB |
|
|
@ -0,0 +1,309 @@
|
|||
{
|
||||
"name": "meteorfire",
|
||||
"celFile": "groundFireMedium",
|
||||
"width": 1024,
|
||||
"height": 410,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 37,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
67,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
134,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
201,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
268,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
335,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
402,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
469,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
536,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
603,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
670,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
737,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
804,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
871,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
938,
|
||||
0,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
0,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
67,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
134,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
201,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
268,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
335,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
402,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
469,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
536,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
603,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
670,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
737,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
804,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
871,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
938,
|
||||
137,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
0,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
67,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
134,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
201,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
268,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
335,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
],
|
||||
[
|
||||
402,
|
||||
274,
|
||||
66,
|
||||
136,
|
||||
-33,
|
||||
-125
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 119 KiB |
|
|
@ -0,0 +1,109 @@
|
|||
{
|
||||
"name": "meteortail",
|
||||
"celFile": "MeteorTail",
|
||||
"width": 512,
|
||||
"height": 251,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 12,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
78,
|
||||
0,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
156,
|
||||
0,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
234,
|
||||
0,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
312,
|
||||
0,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
390,
|
||||
0,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
0,
|
||||
126,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
78,
|
||||
126,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
156,
|
||||
126,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
234,
|
||||
126,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
312,
|
||||
126,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
],
|
||||
[
|
||||
390,
|
||||
126,
|
||||
77,
|
||||
125,
|
||||
-49,
|
||||
-143
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 64 KiB |
|
|
@ -0,0 +1,149 @@
|
|||
{
|
||||
"name": "meteortargetindicator",
|
||||
"celFile": "MeteorTargetIndicator",
|
||||
"width": 512,
|
||||
"height": 374,
|
||||
"directions": 1,
|
||||
"framesPerDirection": 17,
|
||||
"animSpeed": 16,
|
||||
"groups": [
|
||||
[
|
||||
[
|
||||
0,
|
||||
0,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
113,
|
||||
0,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
226,
|
||||
0,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
339,
|
||||
0,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
0,
|
||||
75,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
113,
|
||||
75,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
226,
|
||||
75,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
339,
|
||||
75,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
0,
|
||||
150,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
113,
|
||||
150,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
226,
|
||||
150,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
339,
|
||||
150,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
0,
|
||||
225,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
113,
|
||||
225,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
226,
|
||||
225,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
339,
|
||||
225,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
],
|
||||
[
|
||||
0,
|
||||
300,
|
||||
112,
|
||||
74,
|
||||
-56,
|
||||
-43
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 42 KiB |
|
|
@ -96,6 +96,14 @@ const MISSILE_TARGETS = [
|
|||
{ name: 'icearrow', celFile: 'IceArrow', animSpeed: 16 },
|
||||
{ name: 'arrow', celFile: 'Arrow', animSpeed: 16, trans: 0 },
|
||||
{ name: 'lightningjavelin', celFile: 'LightningJavelin', animSpeed: 16 },
|
||||
|
||||
// Meteor (Skill 56)
|
||||
{ name: 'meteor', celFile: 'Meteor', animSpeed: 16 },
|
||||
{ name: 'meteortail', celFile: 'MeteorTail', animSpeed: 16 },
|
||||
{ name: 'meteorcenter', celFile: 'MeteorTargetIndicator', animSpeed: 16 },
|
||||
{ name: 'meteorexplode', celFile: 'ExpArrowExplode', animSpeed: 16 },
|
||||
{ name: 'meteorfire', celFile: 'groundFireMedium', animSpeed: 16 },
|
||||
{ name: 'firesmall', celFile: 'groundFireSmall', animSpeed: 16 },
|
||||
] as const
|
||||
|
||||
const PAGE_SIZE = 4096
|
||||
|
|
@ -324,6 +332,8 @@ export async function bakeMissiles(projectRoot: string = process.cwd()): Promise
|
|||
}
|
||||
copyAlias('blizzard4', 'blizzard3')
|
||||
copyAlias('blizzardexplode3', 'blizzardexplode2')
|
||||
copyAlias('meteortargetindicator', 'meteorcenter')
|
||||
copyAlias('firemedium', 'meteorfire')
|
||||
|
||||
// Generate src/render/missiles-meta.ts
|
||||
const metaExports = MISSILE_TARGETS.map(t => {
|
||||
|
|
@ -445,12 +455,24 @@ export const HYDRAFIRE_META: MissileMeta = {
|
|||
name: 'hydrafire',
|
||||
}
|
||||
|
||||
export const METEORTARGETINDICATOR_META: MissileMeta = {
|
||||
...METEORCENTER_META,
|
||||
name: 'meteortargetindicator',
|
||||
}
|
||||
|
||||
export const FIREMEDIUM_META: MissileMeta = {
|
||||
...METEORFIRE_META,
|
||||
name: 'firemedium',
|
||||
}
|
||||
|
||||
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
||||
${mapEntries}
|
||||
'blizzard4': BLIZZARD4_META,
|
||||
'blizzardexplode3': BLIZZARDEXPLODE3_META,
|
||||
'hydra': HYDRA_META,
|
||||
'hydrafire': HYDRAFIRE_META,
|
||||
'meteortargetindicator': METEORTARGETINDICATOR_META,
|
||||
'firemedium': FIREMEDIUM_META,
|
||||
})
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -428,6 +428,9 @@ export class GameEngine {
|
|||
const outcome = tickProjectiles(this.projectiles, targets, this.terrain)
|
||||
this.projectiles.length = 0
|
||||
this.projectiles.push(...outcome.alive)
|
||||
if (outcome.spawnedProjectiles && outcome.spawnedProjectiles.length > 0) {
|
||||
this.projectiles.push(...outcome.spawnedProjectiles)
|
||||
}
|
||||
for (const hit of outcome.hits) {
|
||||
const targetMonster = this.world.monsters.find(m => m.index === hit.targetIndex)
|
||||
if (hit.isUndeadOnly) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Meteor Ground Fire Subtile Geometry.
|
||||
*
|
||||
* Ground Truth Reference:
|
||||
* - D2Game.dll VA 0x6FC5CEC0 (MISSMODE_CreateMeteor_MoltenBoulderSubmissiles)
|
||||
* - D2Game.dll VA 0x6FD1C918 (int16_t pXOffsets[18])
|
||||
* - D2Game.dll VA 0x6FD1C8D0 (int16_t pYOffsets[18])
|
||||
*
|
||||
* Each subtile offset is scaled to world pixels:
|
||||
* targetX = gx + offset.x * 20
|
||||
* targetY = gy + offset.y * 10 (20 * ISO_GROUND_ASPECT_RATIO, 2:1 isometric aspect ratio)
|
||||
*/
|
||||
|
||||
export interface MeteorFireSubtileOffset {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
}
|
||||
|
||||
export const METEOR_FIRE_SUBTILE_OFFSETS: readonly MeteorFireSubtileOffset[] = Object.freeze([
|
||||
{ x: 2, y: -2 }, // [0] Inner Ring
|
||||
{ x: -2, y: -2 }, // [1] Inner Ring
|
||||
{ x: 0, y: 2 }, // [2] Inner Ring
|
||||
{ x: 0, y: 5 }, // [3] North Tip
|
||||
{ x: -3, y: 3 }, // [4] North-West Outer
|
||||
{ x: 0, y: 3 }, // [5] Center-North
|
||||
{ x: 3, y: 3 }, // [6] North-East Outer
|
||||
{ x: -1, y: 2 }, // [7] Center Cluster
|
||||
{ x: 1, y: 1 }, // [8] Center Cluster
|
||||
{ x: -1, y: -1 }, // [9] Center Cluster
|
||||
{ x: 2, y: -1 }, // [10] Center Cluster
|
||||
{ x: -4, y: -2 }, // [11] West Tip
|
||||
{ x: -3, y: -2 }, // [12] West Outer
|
||||
{ x: -1, y: -3 }, // [13] South-West Outer
|
||||
{ x: 0, y: -4 }, // [14] South Tip
|
||||
{ x: 1, y: -3 }, // [15] South-East Outer
|
||||
{ x: 3, y: -3 }, // [16] East Outer
|
||||
{ x: 4, y: -2 }, // [17] East Tip
|
||||
])
|
||||
|
|
@ -17,6 +17,8 @@
|
|||
import type { D2DataRegistry, MissileRecord } from './data-registry.ts'
|
||||
import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.ts'
|
||||
import { getBlizzardDropOffset } from './blizzard-table.ts'
|
||||
import { METEOR_FIRE_SUBTILE_OFFSETS } from './meteor-table.ts'
|
||||
import { calculateMeteorResidualFireDamage } from '../skills.ts'
|
||||
|
||||
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||
|
||||
|
|
@ -69,6 +71,12 @@ export interface ActiveMissile {
|
|||
isTurret?: boolean | undefined
|
||||
fireCadenceTicks?: number | undefined
|
||||
spawnMissileType?: string | undefined
|
||||
altitude?: number | undefined
|
||||
}
|
||||
|
||||
export interface MissileTerrainCollision {
|
||||
readonly overlap?: ((x: number, y: number) => number) | undefined
|
||||
readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined
|
||||
}
|
||||
|
||||
export interface MissileStepResult {
|
||||
|
|
@ -86,9 +94,15 @@ export class MissileEngine {
|
|||
private readonly missiles: ActiveMissile[] = []
|
||||
private readonly nextDelayExpireByTarget = new Map<string, number>()
|
||||
private uidCounter = 1
|
||||
private terrain?: MissileTerrainCollision | undefined
|
||||
|
||||
constructor(registry: D2DataRegistry) {
|
||||
constructor(registry: D2DataRegistry, terrain?: MissileTerrainCollision) {
|
||||
this.registry = registry
|
||||
this.terrain = terrain
|
||||
}
|
||||
|
||||
setTerrain(terrain?: MissileTerrainCollision): void {
|
||||
this.terrain = terrain
|
||||
}
|
||||
|
||||
spawnMissile(params: {
|
||||
|
|
@ -107,6 +121,7 @@ export class MissileEngine {
|
|||
fireCadenceTicks?: number
|
||||
spawnMissileType?: string
|
||||
maxRangeTicks?: number
|
||||
altitude?: number
|
||||
}): ActiveMissile | null {
|
||||
const rec =
|
||||
typeof params.missileNameOrId === 'number'
|
||||
|
|
@ -251,7 +266,8 @@ export class MissileEngine {
|
|||
const isStationary =
|
||||
isTurret ||
|
||||
effectiveRec.vel === 0 ||
|
||||
effectiveRec.name.toLowerCase().startsWith('blizzard')
|
||||
effectiveRec.name.toLowerCase().startsWith('blizzard') ||
|
||||
effectiveRec.name.toLowerCase().startsWith('meteor')
|
||||
const speedPxPerTick = isStationary ? 0 : Math.max(8, effectiveRec.vel || 18)
|
||||
const vx = isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick
|
||||
const vy = isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick
|
||||
|
|
@ -289,6 +305,7 @@ export class MissileEngine {
|
|||
chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0),
|
||||
expired: false,
|
||||
isTurret,
|
||||
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
|
||||
...(params.fireCadenceTicks !== undefined
|
||||
? { fireCadenceTicks: params.fireCadenceTicks }
|
||||
: isTurret
|
||||
|
|
@ -317,7 +334,12 @@ export class MissileEngine {
|
|||
* Step all active missiles by 1 tick (`25 Hz`), resolving movement (`pSrvDoFunc`),
|
||||
* collisions (`pSrvHitFunc`), `NextDelay` gating, `Pierce`, and sub-missiles.
|
||||
*/
|
||||
tick(currentTick: number, targets: readonly CombatUnitContext[], targetPositions: ReadonlyMap<string, { x: number; y: number }>): MissileStepResult {
|
||||
tick(
|
||||
currentTick: number,
|
||||
targets: readonly CombatUnitContext[],
|
||||
targetPositions: ReadonlyMap<string, { x: number; y: number }>,
|
||||
collision?: MissileTerrainCollision,
|
||||
): MissileStepResult {
|
||||
const hits: { missileName: string; targetId: string; damage: number; immune: boolean }[] = []
|
||||
const subMissilesSpawned: string[] = []
|
||||
|
||||
|
|
@ -325,6 +347,11 @@ export class MissileEngine {
|
|||
if (m.expired) continue
|
||||
m.ageTicks += 1
|
||||
|
||||
if (m.name === 'meteor' || m.name === 'meteortail') {
|
||||
if (m.altitude === undefined) m.altitude = 60 * 25
|
||||
m.altitude = Math.max(0, m.altitude - 25)
|
||||
}
|
||||
|
||||
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `frozenorbbolt` sub-missiles while flying
|
||||
// Authentic D2Game.0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb):
|
||||
// - 64-entry discrete circle lookup table (radius 30)
|
||||
|
|
@ -454,6 +481,9 @@ export class MissileEngine {
|
|||
}
|
||||
|
||||
// Check collision against alive enemy targets
|
||||
if (m.name === 'meteorfire' && m.ageTicks % 5 === 0) {
|
||||
m.hitTargetIds.clear()
|
||||
}
|
||||
const hitRadius = Math.max(24, (m.record.size || 2) * 14)
|
||||
for (const target of targets) {
|
||||
if (target.statList.getHp256() <= 0) continue
|
||||
|
|
@ -467,19 +497,26 @@ export class MissileEngine {
|
|||
continue
|
||||
}
|
||||
|
||||
// Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), and Hydra turret emitters
|
||||
// do not deal direct contact damage; all damage is delivered through emitted child bolts / shards.
|
||||
// Main Frozen Orb (pSrvDoFunc === 15), Blizzard Center (pSrvDoFunc === 10), Meteor Center (pSrvHitFunc === 14 / meteorcenter),
|
||||
// and Hydra turret emitters do not deal direct contact damage; all damage is delivered through emitted child bolts / shards.
|
||||
if (
|
||||
m.record.pSrvDoFunc === 15 ||
|
||||
m.record.pSrvDoFunc === 10 ||
|
||||
m.name === 'blizzardcenter' ||
|
||||
m.record.pSrvHitFunc === 14 ||
|
||||
m.name === 'meteorcenter' ||
|
||||
m.name === 'meteor' ||
|
||||
m.name === 'meteortail' ||
|
||||
isTurretEmitter
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
||||
const dist = Math.hypot(pos.x - m.x, pos.y - m.y)
|
||||
const dist =
|
||||
m.name === 'meteorfire'
|
||||
? Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO)
|
||||
: Math.hypot(pos.x - m.x, pos.y - m.y)
|
||||
if (dist <= hitRadius) {
|
||||
// Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0`
|
||||
if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) {
|
||||
|
|
@ -586,7 +623,9 @@ export class MissileEngine {
|
|||
}
|
||||
|
||||
// Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit)
|
||||
if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
||||
if (m.name === 'meteorfire') {
|
||||
// Ground fire persists for its full duration and does not die on hit
|
||||
} else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
||||
m.pierceCount += 1
|
||||
} else if (m.record.collideKill || !m.canPierce) {
|
||||
if (
|
||||
|
|
@ -615,6 +654,85 @@ export class MissileEngine {
|
|||
}
|
||||
|
||||
if (m.ageTicks >= m.maxRangeTicks) {
|
||||
// `pSrvHitFunc = 14` (`Meteor` detonation and ground fire patch cascade)
|
||||
// Authentic D2Game 0x6FC5DEF0 (MISSMODE_SrvHit14_MeteorCenter) & 0x6FC5CEC0:
|
||||
if (
|
||||
(m.record.pSrvHitFunc === 14 || m.name === 'meteorcenter') &&
|
||||
(!m.currentHitFrame || m.currentHitFrame === 0)
|
||||
) {
|
||||
m.currentHitFrame = 1
|
||||
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
|
||||
const blastRadius = 120
|
||||
for (const target of targets) {
|
||||
if (target.statList.getHp256() <= 0) continue
|
||||
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
||||
const inRange =
|
||||
Math.hypot(pos.x - m.x, (pos.y - m.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
|
||||
if (inRange) {
|
||||
const dmgOut = executeSUnitDmg(m.owner, target, m.dmgPacket)
|
||||
hits.push({
|
||||
missileName: 'meteorexplode',
|
||||
targetId: target.id,
|
||||
damage: dmgOut.totalDamage,
|
||||
immune: dmgOut.immuneToElem || dmgOut.immuneToPhys,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Spawn explosion missile: meteorexplode (ID 103)
|
||||
const expName = m.record.explosionMissile || 'meteorexplode'
|
||||
this.spawnMissile({
|
||||
missileNameOrId: expName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: m.x,
|
||||
startY: m.y,
|
||||
targetX: m.x,
|
||||
targetY: m.y,
|
||||
dmgPacket: m.dmgPacket,
|
||||
})
|
||||
subMissilesSpawned.push(expName)
|
||||
|
||||
// 3. Spawn 18 meteorfire submissiles using 18-offset table
|
||||
const subFireName = m.record.hitSubMissile1 || 'meteorfire'
|
||||
const fireDuration = 30 + (Math.max(1, m.slvl) - 1) * 15
|
||||
const fireDmg = calculateMeteorResidualFireDamage(m.slvl)
|
||||
const fireDmgPacket: SUnitDmgPacket = {
|
||||
skillId: m.sourceSkillId,
|
||||
attackKind: 'spell',
|
||||
elemType: 'fire',
|
||||
elemMin256: fireDmg.min * 8, // HitShift 3: << 3 = * 8
|
||||
elemMax256: fireDmg.max * 8,
|
||||
}
|
||||
const effectiveTerrain = collision ?? this.terrain
|
||||
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
|
||||
const fx = m.x + off.x * 20
|
||||
const fy = m.y + off.y * 10
|
||||
if (effectiveTerrain !== undefined) {
|
||||
const blocked =
|
||||
effectiveTerrain.isMissileBlocked !== undefined
|
||||
? effectiveTerrain.isMissileBlocked(fx, fy, m.x, m.y)
|
||||
: effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(fx, fy) > 0
|
||||
if (blocked) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
this.spawnMissile({
|
||||
missileNameOrId: subFireName,
|
||||
sourceSkillId: m.sourceSkillId,
|
||||
slvl: m.slvl,
|
||||
owner: m.owner,
|
||||
startX: fx,
|
||||
startY: fy,
|
||||
targetX: fx,
|
||||
targetY: fy,
|
||||
dmgPacket: fireDmgPacket,
|
||||
maxRangeTicks: fireDuration,
|
||||
})
|
||||
subMissilesSpawned.push(subFireName)
|
||||
}
|
||||
}
|
||||
// Blizzard falling shard ground impact explosion (Missiles 159..162)
|
||||
if (
|
||||
m.name.startsWith('blizzard') &&
|
||||
|
|
|
|||
|
|
@ -32,6 +32,14 @@ import {
|
|||
getBlizzardDropOffset,
|
||||
} from './engine/blizzard-table.ts'
|
||||
export { BLIZZARD_CIRCLE_X, BLIZZARD_CIRCLE_Y, getBlizzardDropOffset } from './engine/blizzard-table.ts'
|
||||
import {
|
||||
METEOR_FIRE_SUBTILE_OFFSETS,
|
||||
type MeteorFireSubtileOffset,
|
||||
} from './engine/meteor-table.ts'
|
||||
export {
|
||||
METEOR_FIRE_SUBTILE_OFFSETS,
|
||||
type MeteorFireSubtileOffset,
|
||||
} from './engine/meteor-table.ts'
|
||||
|
||||
/** One castable skill, read from a table row. */
|
||||
export interface SkillDef {
|
||||
|
|
@ -123,6 +131,12 @@ export interface Projectile {
|
|||
readonly ageTicks?: number | undefined
|
||||
/** Altitude above ground in pixels for falling missiles (e.g. Blizzard shards). */
|
||||
readonly altitude?: number | undefined
|
||||
/** Skill level of the source skill (for dynamic duration/damage scaling). */
|
||||
readonly slvl?: number | undefined
|
||||
/** Residual ground fire duration in ticks. */
|
||||
readonly fireDuration?: number | undefined
|
||||
/** Residual ground fire damage per tick. */
|
||||
readonly residualDamage?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -667,6 +681,106 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
|||
speedPxPerSec: 400,
|
||||
distancePx: 480,
|
||||
},
|
||||
meteor: {
|
||||
name: 'meteor',
|
||||
id: 100,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 60,
|
||||
levRange: 0,
|
||||
celFile: 'Meteor',
|
||||
animLen: 12,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 1,
|
||||
explosionMissile: 'meteorexplode',
|
||||
light: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
meteorcenter: {
|
||||
name: 'meteorcenter',
|
||||
id: 101,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 60,
|
||||
levRange: 0,
|
||||
celFile: 'MeteorTargetIndicator',
|
||||
animLen: 17,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 1,
|
||||
explosionMissile: 'meteorexplode',
|
||||
light: 4,
|
||||
red: 255,
|
||||
green: 64,
|
||||
blue: 64,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
meteortail: {
|
||||
name: 'meteortail',
|
||||
id: 102,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 60,
|
||||
levRange: 0,
|
||||
celFile: 'MeteorTail',
|
||||
animLen: 12,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 0,
|
||||
red: 0,
|
||||
green: 0,
|
||||
blue: 0,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
meteorexplode: {
|
||||
name: 'meteorexplode',
|
||||
id: 103,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 16,
|
||||
levRange: 0,
|
||||
celFile: 'ExpArrowExplode',
|
||||
animLen: 16,
|
||||
animSpeed: 16,
|
||||
loopAnim: 0,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 13,
|
||||
red: 255,
|
||||
green: 178,
|
||||
blue: 64,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
meteorfire: {
|
||||
name: 'meteorfire',
|
||||
id: 240,
|
||||
vel: 0,
|
||||
maxVel: 0,
|
||||
range: 30,
|
||||
levRange: 15,
|
||||
celFile: 'groundFireMedium',
|
||||
animLen: 37,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 1,
|
||||
explosionMissile: '',
|
||||
light: 3,
|
||||
red: 255,
|
||||
green: 178,
|
||||
blue: 64,
|
||||
speedPxPerSec: 0,
|
||||
distancePx: 0,
|
||||
},
|
||||
chargedbolt: {
|
||||
name: 'chargedbolt',
|
||||
id: 56,
|
||||
|
|
@ -3492,7 +3606,8 @@ export function tickProjectiles(
|
|||
let wallHits = 0
|
||||
|
||||
for (const projectile of projectiles) {
|
||||
if (projectile.ttl <= 0) {
|
||||
const isExpired = projectile.missileType === 'meteorcenter' ? projectile.ttl <= 1 : projectile.ttl <= 0
|
||||
if (isExpired) {
|
||||
expired += 1
|
||||
if (projectile.missileType === 'frozenorb') {
|
||||
// Authentic D2 1.13c MISSMODE_SrvHit29_FrozenOrb (0x6FC5CE10):
|
||||
|
|
@ -3542,6 +3657,77 @@ export function tickProjectiles(
|
|||
aoeRadius: 32,
|
||||
pierce: false,
|
||||
})
|
||||
} else if (projectile.missileType === 'meteorcenter') {
|
||||
// Authentic D2 1.13c MISSMODE_SrvHit14_MeteorCenter (0x6FC5DEF0) & 0x6FC5CEC0:
|
||||
// 1. Primary explosion AOE damage (Radius = 6 subtiles = 120px) in 2:1 isometric ground perspective
|
||||
const blastRadius = projectile.aoeRadius ?? 120
|
||||
for (const target of targets) {
|
||||
if (!target.alive) continue
|
||||
const inRange =
|
||||
Math.hypot(target.x - projectile.x, (target.y - projectile.y) / ISO_GROUND_ASPECT_RATIO) <= blastRadius
|
||||
if (inRange) {
|
||||
hits.push({
|
||||
targetIndex: target.index,
|
||||
damage: projectile.damage,
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
skillId: projectile.skillId,
|
||||
missileType: 'meteorexplode',
|
||||
aoeRadius: blastRadius,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Spawn explosion missile: meteorexplode (ID 103, Range 16)
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x: projectile.x,
|
||||
y: projectile.y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 16,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'meteorexplode',
|
||||
aoeRadius: blastRadius,
|
||||
pierce: true,
|
||||
})
|
||||
wallHitEvents.push({
|
||||
x: projectile.x,
|
||||
y: projectile.y,
|
||||
skillId: projectile.skillId,
|
||||
missileType: 'meteorexplode',
|
||||
aoeRadius: blastRadius,
|
||||
})
|
||||
|
||||
// 3. Spawn 18 meteorfire submissiles using 18-offset table
|
||||
const slvl = Math.max(1, projectile.slvl ?? 1)
|
||||
const fireDuration = projectile.fireDuration ?? (30 + (slvl - 1) * 15)
|
||||
const residualDmg = projectile.residualDamage ?? calculateMeteorResidualFireDamage(slvl).min
|
||||
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
|
||||
const fx = projectile.x + off.x * 20
|
||||
const fy = projectile.y + off.y * 10
|
||||
const blocked =
|
||||
options.isMissileBlocked !== undefined
|
||||
? options.isMissileBlocked(fx, fy, projectile.x, projectile.y)
|
||||
: options.overlap(fx, fy) > 0
|
||||
if (!blocked) {
|
||||
spawned.push({
|
||||
skillId: projectile.skillId,
|
||||
x: fx,
|
||||
y: fy,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: residualDmg,
|
||||
ttl: fireDuration,
|
||||
fromPlayer: projectile.fromPlayer,
|
||||
missileType: 'meteorfire',
|
||||
aoeRadius: 16,
|
||||
pierce: true,
|
||||
slvl,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
|
@ -3735,9 +3921,16 @@ export function tickProjectiles(
|
|||
if (isBlizzardShard(projectile.missileType)) {
|
||||
if (altitude === undefined) altitude = 120
|
||||
altitude = Math.max(0, altitude - (120 / 9))
|
||||
} else if (projectile.missileType === 'meteor' || projectile.missileType === 'meteortail') {
|
||||
if (altitude === undefined) altitude = 60 * 25
|
||||
altitude = Math.max(0, altitude - 25)
|
||||
}
|
||||
|
||||
const hitTargets = projectile.hitTargets ?? new Set<number>()
|
||||
const currentAge = (projectile.ageTicks ?? 0) + 1
|
||||
if (projectile.missileType === 'meteorfire' && currentAge % 5 === 0) {
|
||||
hitTargets.clear()
|
||||
}
|
||||
const moved: Projectile = {
|
||||
...projectile,
|
||||
x,
|
||||
|
|
@ -3748,15 +3941,17 @@ export function tickProjectiles(
|
|||
subMissileIdx,
|
||||
altitude,
|
||||
hitTargets,
|
||||
ageTicks: (projectile.ageTicks ?? 0) + 1,
|
||||
ageTicks: currentAge,
|
||||
ttl: projectile.ttl - 1,
|
||||
}
|
||||
|
||||
// Walls first: a projectile dies against missile barriers (solid walls, closed doors)
|
||||
const isAirborne = altitude !== undefined && altitude > 0
|
||||
const blockedByTerrain =
|
||||
options.isMissileBlocked !== undefined
|
||||
!isAirborne &&
|
||||
(options.isMissileBlocked !== undefined
|
||||
? options.isMissileBlocked(x, y, projectile.x, projectile.y)
|
||||
: options.overlap(x, y) > 0
|
||||
: options.overlap(x, y) > 0)
|
||||
if (blockedByTerrain) {
|
||||
wallHits += 1
|
||||
wallHitEvents.push({
|
||||
|
|
@ -3813,12 +4008,19 @@ export function tickProjectiles(
|
|||
const isHarmlessEmitter =
|
||||
projectile.missileType === 'frozenorb' ||
|
||||
projectile.missileType === 'blizzardcenter' ||
|
||||
projectile.missileType === 'meteorcenter' ||
|
||||
projectile.missileType === 'meteor' ||
|
||||
projectile.missileType === 'meteortail' ||
|
||||
isTurretEmitter
|
||||
if (!isHarmlessEmitter) {
|
||||
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
|
||||
const dist =
|
||||
projectile.missileType === 'meteorfire'
|
||||
? Math.hypot(target.x - x, (target.y - y) / ISO_GROUND_ASPECT_RATIO)
|
||||
: Math.hypot(target.x - x, target.y - y)
|
||||
if (dist > target.radius + radius) continue
|
||||
|
||||
const statusEffect =
|
||||
projectile.statusEffect ??
|
||||
|
|
@ -3930,6 +4132,8 @@ export interface Batch1SkillDef {
|
|||
readonly range?: number
|
||||
readonly speed?: number
|
||||
readonly castOverlay?: string
|
||||
readonly srvdofunc?: number | undefined
|
||||
readonly castDelay?: number | undefined
|
||||
}
|
||||
|
||||
export const BATCH1_SKILLS: Readonly<Record<number, Batch1SkillDef>> = Object.freeze({
|
||||
|
|
@ -4010,6 +4214,11 @@ export function isBatch1Skill(skillId: number): boolean {
|
|||
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
|
||||
*/
|
||||
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
|
||||
if (skillId === 56) {
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
// 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5)
|
||||
return (34 + (lvl - 1)) * 0.5
|
||||
}
|
||||
const def = getBatch1SkillDef(skillId)
|
||||
if (!def) return 0
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
|
|
@ -4024,9 +4233,13 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
* - Werewolf: 25 frames (1.0s)
|
||||
* - Firestorm: 15 frames (0.6s)
|
||||
* - Molten Boulder: 50 frames (2.0s)
|
||||
* - Meteor: 30 frames (1.2s)
|
||||
* - All other Batch 1 skills: 0 frames
|
||||
*/
|
||||
export function getSkillCooldownTicks(skillId: number): number {
|
||||
if (skillId === 56) {
|
||||
return 30
|
||||
}
|
||||
const def = getBatch1SkillDef(skillId)
|
||||
return def?.delay ?? 0
|
||||
}
|
||||
|
|
@ -4047,12 +4260,19 @@ export function calculateSkillDamage(
|
|||
readonly iceBolt?: number | undefined
|
||||
readonly iceBlast?: number | undefined
|
||||
readonly glacialSpike?: number | undefined
|
||||
readonly fireBolt?: number | undefined
|
||||
readonly fireBall?: number | undefined
|
||||
readonly fireMastery?: number | undefined
|
||||
readonly inferno?: number | undefined
|
||||
},
|
||||
weaponDamage?: { min: number; max: number }
|
||||
): { min: number; max: number } {
|
||||
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
|
||||
return calculateBlizzardDamage(slvl, synergies)
|
||||
}
|
||||
if (skillId === 56 || skillId === '56' || skillId === 'meteor') {
|
||||
return calculateMeteorDamage(slvl, synergies as any)
|
||||
}
|
||||
|
||||
const numericSkillId = typeof skillId === 'number' ? skillId : Number(skillId)
|
||||
const def = Number.isFinite(numericSkillId) ? getBatch1SkillDef(numericSkillId) : undefined
|
||||
|
|
@ -4191,6 +4411,199 @@ export function calculateBlizzardDamage(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Meteor (Skill 56) synergy multiplier:
|
||||
* +5% fire damage per hard point in Fire Bolt (36) and Fire Ball (47).
|
||||
* Fire Mastery (61): +30% base, +7%/lvl if present.
|
||||
*/
|
||||
export function calculateMeteorSynergyMultiplier(synergies: {
|
||||
readonly fireBolt?: number | undefined
|
||||
readonly fireBall?: number | undefined
|
||||
readonly fireMastery?: number | undefined
|
||||
}): number {
|
||||
const points = (synergies.fireBolt ?? 0) + (synergies.fireBall ?? 0)
|
||||
const synPct = points * 5 // +5% per hard point
|
||||
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
|
||||
? 30 + (synergies.fireMastery - 1) * 7 // Fire Mastery: +30% base, +7%/lvl
|
||||
: 0
|
||||
return (1.0 + synPct / 100) * (1.0 + masteryPct / 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Meteor (Skill 56) primary detonation fire damage with 1.13c 5-band level scaling and synergies.
|
||||
* Base: 80 - 100
|
||||
* 5 bands: [23, 39, 79, 81, 83] min / [25, 41, 81, 83, 85] max.
|
||||
*/
|
||||
export function calculateMeteorDamage(
|
||||
effectiveLevel: number,
|
||||
synergies: {
|
||||
readonly fireBolt?: number | undefined
|
||||
readonly fireBall?: number | undefined
|
||||
readonly fireMastery?: number | undefined
|
||||
} | number = {},
|
||||
): { min: number; max: number } {
|
||||
let syn = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
||||
} else if (synergies && typeof synergies === 'object') {
|
||||
syn = calculateMeteorSynergyMultiplier(synergies)
|
||||
}
|
||||
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
let min = 80
|
||||
let max = 100
|
||||
|
||||
if (lvl > 1) {
|
||||
const b1 = Math.min(lvl - 1, 7)
|
||||
min += b1 * 23
|
||||
max += b1 * 25
|
||||
|
||||
if (lvl > 8) {
|
||||
const b2 = Math.min(lvl - 8, 8)
|
||||
min += b2 * 39
|
||||
max += b2 * 41
|
||||
}
|
||||
if (lvl > 16) {
|
||||
const b3 = Math.min(lvl - 16, 6)
|
||||
min += b3 * 79
|
||||
max += b3 * 81
|
||||
}
|
||||
if (lvl > 22) {
|
||||
const b4 = Math.min(lvl - 22, 6)
|
||||
min += b4 * 81
|
||||
max += b4 * 83
|
||||
}
|
||||
if (lvl > 28) {
|
||||
const b5 = lvl - 28
|
||||
min += b5 * 83
|
||||
max += b5 * 85
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
min: Math.floor(min * syn),
|
||||
max: Math.floor(max * syn),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Meteor residual ground fire (meteorfire, ID 240) periodic damage.
|
||||
* 1.13c Ground Truth:
|
||||
* - Base: 15 - 25
|
||||
* - 5 bands: [4, 5, 6, 6, 6] min/max
|
||||
* - Synergies: +3% per hard point of Inferno (41); Fire Mastery (61) applies.
|
||||
* - HitShift: 3 (8/256 internal precision per tick).
|
||||
* - Per-second multiplier: 25 * 8 / 256 = 200 / 256 = 0.78125.
|
||||
*/
|
||||
export function calculateMeteorResidualFireDamage(
|
||||
effectiveLevel: number,
|
||||
synergies: {
|
||||
readonly inferno?: number | undefined
|
||||
readonly fireMastery?: number | undefined
|
||||
} | number = {},
|
||||
): {
|
||||
min: number
|
||||
max: number
|
||||
perSecMin: number
|
||||
perSecMax: number
|
||||
} {
|
||||
let syn = 1.0
|
||||
if (typeof synergies === 'number') {
|
||||
syn = Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0
|
||||
} else if (synergies && typeof synergies === 'object') {
|
||||
const infernoPts = synergies.inferno ?? 0
|
||||
const infernoPct = infernoPts * 3 // +3% per point
|
||||
const masteryPct = synergies.fireMastery && synergies.fireMastery > 0
|
||||
? 30 + (synergies.fireMastery - 1) * 7
|
||||
: 0
|
||||
syn = (1.0 + infernoPct / 100) * (1.0 + masteryPct / 100)
|
||||
}
|
||||
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
let min = 15
|
||||
let max = 25
|
||||
|
||||
if (lvl > 1) {
|
||||
const b1 = Math.min(lvl - 1, 7)
|
||||
min += b1 * 4
|
||||
max += b1 * 4
|
||||
|
||||
if (lvl > 8) {
|
||||
const b2 = Math.min(lvl - 8, 8)
|
||||
min += b2 * 5
|
||||
max += b2 * 5
|
||||
}
|
||||
if (lvl > 16) {
|
||||
const b3 = Math.min(lvl - 16, 6)
|
||||
min += b3 * 6
|
||||
max += b3 * 6
|
||||
}
|
||||
if (lvl > 22) {
|
||||
const b4 = Math.min(lvl - 22, 6)
|
||||
min += b4 * 6
|
||||
max += b4 * 6
|
||||
}
|
||||
if (lvl > 28) {
|
||||
const b5 = lvl - 28
|
||||
min += b5 * 6
|
||||
max += b5 * 6
|
||||
}
|
||||
}
|
||||
|
||||
const rawMin = Math.floor(min * syn)
|
||||
const rawMax = Math.floor(max * syn)
|
||||
|
||||
// In 1.13c HitShift is 3 (8/256) at 25 fps: per-second multiplier is 200/256 = 0.78125
|
||||
const perSecMin = Math.floor((rawMin * 200) / 256)
|
||||
const perSecMax = Math.floor((rawMax * 200) / 256)
|
||||
|
||||
return {
|
||||
min: rawMin,
|
||||
max: rawMax,
|
||||
perSecMin,
|
||||
perSecMax,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates Meteor detailed damage profile including impact fire, optional physical split,
|
||||
* and residual ground fire damage per second.
|
||||
*/
|
||||
export function calculateMeteorDetailedDamage(
|
||||
effectiveLevel: number,
|
||||
synergies: {
|
||||
readonly fireBolt?: number | undefined
|
||||
readonly fireBall?: number | undefined
|
||||
readonly fireMastery?: number | undefined
|
||||
readonly inferno?: number | undefined
|
||||
} = {},
|
||||
options?: { readonly physRatio?: number | undefined }
|
||||
): {
|
||||
impactFire: { min: number; max: number }
|
||||
impactPhys: { min: number; max: number }
|
||||
residualFire: { min: number; max: number; perSecMin: number; perSecMax: number }
|
||||
} {
|
||||
const det = calculateMeteorDamage(effectiveLevel, synergies)
|
||||
const physRatio = Math.max(0, Math.min(1, options?.physRatio ?? 0)) // 0 in vanilla 1.13c
|
||||
const fireRatio = 1.0 - physRatio
|
||||
|
||||
const impactFire = {
|
||||
min: Math.floor(det.min * fireRatio),
|
||||
max: Math.floor(det.max * fireRatio),
|
||||
}
|
||||
const impactPhys = {
|
||||
min: Math.floor(det.min * physRatio),
|
||||
max: Math.floor(det.max * physRatio),
|
||||
}
|
||||
const residualFire = calculateMeteorResidualFireDamage(effectiveLevel, synergies)
|
||||
|
||||
return {
|
||||
impactFire,
|
||||
impactPhys,
|
||||
residualFire,
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of executing a Batch 1 skill. */
|
||||
export type SkillExecutionResult =
|
||||
| { readonly kind: 'cooldown'; readonly ticksLeft: number }
|
||||
|
|
|
|||
|
|
@ -4947,6 +4947,221 @@ export const LIGHTNINGJAVELIN_META: MissileMeta = {
|
|||
],
|
||||
}
|
||||
|
||||
export const METEOR_META: MissileMeta = {
|
||||
name: "meteor",
|
||||
celFile: "Meteor",
|
||||
width: 512,
|
||||
height: 22,
|
||||
directions: 1,
|
||||
framesPerDirection: 12,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 22, 22, -7, -55],
|
||||
[23, 0, 22, 22, -7, -55],
|
||||
[46, 0, 22, 22, -7, -55],
|
||||
[69, 0, 22, 22, -7, -55],
|
||||
[92, 0, 22, 22, -7, -55],
|
||||
[115, 0, 22, 22, -7, -55],
|
||||
[138, 0, 22, 22, -7, -55],
|
||||
[161, 0, 22, 22, -7, -55],
|
||||
[184, 0, 22, 22, -7, -55],
|
||||
[207, 0, 22, 22, -7, -55],
|
||||
[230, 0, 22, 22, -7, -55],
|
||||
[253, 0, 22, 22, -7, -55]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const METEORTAIL_META: MissileMeta = {
|
||||
name: "meteortail",
|
||||
celFile: "MeteorTail",
|
||||
width: 512,
|
||||
height: 251,
|
||||
directions: 1,
|
||||
framesPerDirection: 12,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 77, 125, -49, -143],
|
||||
[78, 0, 77, 125, -49, -143],
|
||||
[156, 0, 77, 125, -49, -143],
|
||||
[234, 0, 77, 125, -49, -143],
|
||||
[312, 0, 77, 125, -49, -143],
|
||||
[390, 0, 77, 125, -49, -143],
|
||||
[0, 126, 77, 125, -49, -143],
|
||||
[78, 126, 77, 125, -49, -143],
|
||||
[156, 126, 77, 125, -49, -143],
|
||||
[234, 126, 77, 125, -49, -143],
|
||||
[312, 126, 77, 125, -49, -143],
|
||||
[390, 126, 77, 125, -49, -143]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const METEORCENTER_META: MissileMeta = {
|
||||
name: "meteorcenter",
|
||||
celFile: "MeteorTargetIndicator",
|
||||
width: 512,
|
||||
height: 374,
|
||||
directions: 1,
|
||||
framesPerDirection: 17,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 112, 74, -56, -43],
|
||||
[113, 0, 112, 74, -56, -43],
|
||||
[226, 0, 112, 74, -56, -43],
|
||||
[339, 0, 112, 74, -56, -43],
|
||||
[0, 75, 112, 74, -56, -43],
|
||||
[113, 75, 112, 74, -56, -43],
|
||||
[226, 75, 112, 74, -56, -43],
|
||||
[339, 75, 112, 74, -56, -43],
|
||||
[0, 150, 112, 74, -56, -43],
|
||||
[113, 150, 112, 74, -56, -43],
|
||||
[226, 150, 112, 74, -56, -43],
|
||||
[339, 150, 112, 74, -56, -43],
|
||||
[0, 225, 112, 74, -56, -43],
|
||||
[113, 225, 112, 74, -56, -43],
|
||||
[226, 225, 112, 74, -56, -43],
|
||||
[339, 225, 112, 74, -56, -43],
|
||||
[0, 300, 112, 74, -56, -43]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const METEOREXPLODE_META: MissileMeta = {
|
||||
name: "meteorexplode",
|
||||
celFile: "ExpArrowExplode",
|
||||
width: 1024,
|
||||
height: 699,
|
||||
directions: 1,
|
||||
framesPerDirection: 16,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 208, 174, -108, -123],
|
||||
[209, 0, 208, 174, -108, -123],
|
||||
[418, 0, 208, 174, -108, -123],
|
||||
[627, 0, 208, 174, -108, -123],
|
||||
[0, 175, 208, 174, -108, -123],
|
||||
[209, 175, 208, 174, -108, -123],
|
||||
[418, 175, 208, 174, -108, -123],
|
||||
[627, 175, 208, 174, -108, -123],
|
||||
[0, 350, 208, 174, -108, -123],
|
||||
[209, 350, 208, 174, -108, -123],
|
||||
[418, 350, 208, 174, -108, -123],
|
||||
[627, 350, 208, 174, -108, -123],
|
||||
[0, 525, 208, 174, -108, -123],
|
||||
[209, 525, 208, 174, -108, -123],
|
||||
[418, 525, 208, 174, -108, -123],
|
||||
[627, 525, 208, 174, -108, -123]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const METEORFIRE_META: MissileMeta = {
|
||||
name: "meteorfire",
|
||||
celFile: "groundFireMedium",
|
||||
width: 1024,
|
||||
height: 410,
|
||||
directions: 1,
|
||||
framesPerDirection: 37,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 66, 136, -33, -125],
|
||||
[67, 0, 66, 136, -33, -125],
|
||||
[134, 0, 66, 136, -33, -125],
|
||||
[201, 0, 66, 136, -33, -125],
|
||||
[268, 0, 66, 136, -33, -125],
|
||||
[335, 0, 66, 136, -33, -125],
|
||||
[402, 0, 66, 136, -33, -125],
|
||||
[469, 0, 66, 136, -33, -125],
|
||||
[536, 0, 66, 136, -33, -125],
|
||||
[603, 0, 66, 136, -33, -125],
|
||||
[670, 0, 66, 136, -33, -125],
|
||||
[737, 0, 66, 136, -33, -125],
|
||||
[804, 0, 66, 136, -33, -125],
|
||||
[871, 0, 66, 136, -33, -125],
|
||||
[938, 0, 66, 136, -33, -125],
|
||||
[0, 137, 66, 136, -33, -125],
|
||||
[67, 137, 66, 136, -33, -125],
|
||||
[134, 137, 66, 136, -33, -125],
|
||||
[201, 137, 66, 136, -33, -125],
|
||||
[268, 137, 66, 136, -33, -125],
|
||||
[335, 137, 66, 136, -33, -125],
|
||||
[402, 137, 66, 136, -33, -125],
|
||||
[469, 137, 66, 136, -33, -125],
|
||||
[536, 137, 66, 136, -33, -125],
|
||||
[603, 137, 66, 136, -33, -125],
|
||||
[670, 137, 66, 136, -33, -125],
|
||||
[737, 137, 66, 136, -33, -125],
|
||||
[804, 137, 66, 136, -33, -125],
|
||||
[871, 137, 66, 136, -33, -125],
|
||||
[938, 137, 66, 136, -33, -125],
|
||||
[0, 274, 66, 136, -33, -125],
|
||||
[67, 274, 66, 136, -33, -125],
|
||||
[134, 274, 66, 136, -33, -125],
|
||||
[201, 274, 66, 136, -33, -125],
|
||||
[268, 274, 66, 136, -33, -125],
|
||||
[335, 274, 66, 136, -33, -125],
|
||||
[402, 274, 66, 136, -33, -125]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const FIRESMALL_META: MissileMeta = {
|
||||
name: "firesmall",
|
||||
celFile: "groundFireSmall",
|
||||
width: 512,
|
||||
height: 539,
|
||||
directions: 1,
|
||||
framesPerDirection: 37,
|
||||
animSpeed: 16,
|
||||
groups: [
|
||||
[
|
||||
[0, 0, 66, 89, -33, -77],
|
||||
[67, 0, 66, 89, -33, -77],
|
||||
[134, 0, 66, 89, -33, -77],
|
||||
[201, 0, 66, 89, -33, -77],
|
||||
[268, 0, 66, 89, -33, -77],
|
||||
[335, 0, 66, 89, -33, -77],
|
||||
[402, 0, 66, 89, -33, -77],
|
||||
[0, 90, 66, 89, -33, -77],
|
||||
[67, 90, 66, 89, -33, -77],
|
||||
[134, 90, 66, 89, -33, -77],
|
||||
[201, 90, 66, 89, -33, -77],
|
||||
[268, 90, 66, 89, -33, -77],
|
||||
[335, 90, 66, 89, -33, -77],
|
||||
[402, 90, 66, 89, -33, -77],
|
||||
[0, 180, 66, 89, -33, -77],
|
||||
[67, 180, 66, 89, -33, -77],
|
||||
[134, 180, 66, 89, -33, -77],
|
||||
[201, 180, 66, 89, -33, -77],
|
||||
[268, 180, 66, 89, -33, -77],
|
||||
[335, 180, 66, 89, -33, -77],
|
||||
[402, 180, 66, 89, -33, -77],
|
||||
[0, 270, 66, 89, -33, -77],
|
||||
[67, 270, 66, 89, -33, -77],
|
||||
[134, 270, 66, 89, -33, -77],
|
||||
[201, 270, 66, 89, -33, -77],
|
||||
[268, 270, 66, 89, -33, -77],
|
||||
[335, 270, 66, 89, -33, -77],
|
||||
[402, 270, 66, 89, -33, -77],
|
||||
[0, 360, 66, 89, -33, -77],
|
||||
[67, 360, 66, 89, -33, -77],
|
||||
[134, 360, 66, 89, -33, -77],
|
||||
[201, 360, 66, 89, -33, -77],
|
||||
[268, 360, 66, 89, -33, -77],
|
||||
[335, 360, 66, 89, -33, -77],
|
||||
[402, 360, 66, 89, -33, -77],
|
||||
[0, 450, 66, 89, -33, -77],
|
||||
[67, 450, 66, 89, -33, -77]
|
||||
]
|
||||
],
|
||||
}
|
||||
|
||||
export const BLIZZARD4_META: MissileMeta = {
|
||||
...BLIZZARD3_META,
|
||||
name: 'blizzard4',
|
||||
|
|
@ -4967,6 +5182,16 @@ export const HYDRAFIRE_META: MissileMeta = {
|
|||
name: 'hydrafire',
|
||||
}
|
||||
|
||||
export const METEORTARGETINDICATOR_META: MissileMeta = {
|
||||
...METEORCENTER_META,
|
||||
name: 'meteortargetindicator',
|
||||
}
|
||||
|
||||
export const FIREMEDIUM_META: MissileMeta = {
|
||||
...METEORFIRE_META,
|
||||
name: 'firemedium',
|
||||
}
|
||||
|
||||
export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freeze({
|
||||
'firebolt': FIREBOLT_META,
|
||||
'fireball': FIREBALL_META,
|
||||
|
|
@ -4997,8 +5222,16 @@ export const MISSILE_METAS: Readonly<Record<string, MissileMeta>> = Object.freez
|
|||
'icearrow': ICEARROW_META,
|
||||
'arrow': ARROW_META,
|
||||
'lightningjavelin': LIGHTNINGJAVELIN_META,
|
||||
'meteor': METEOR_META,
|
||||
'meteortail': METEORTAIL_META,
|
||||
'meteorcenter': METEORCENTER_META,
|
||||
'meteorexplode': METEOREXPLODE_META,
|
||||
'meteorfire': METEORFIRE_META,
|
||||
'firesmall': FIRESMALL_META,
|
||||
'blizzard4': BLIZZARD4_META,
|
||||
'blizzardexplode3': BLIZZARDEXPLODE3_META,
|
||||
'hydra': HYDRA_META,
|
||||
'hydrafire': HYDRAFIRE_META,
|
||||
'meteortargetindicator': METEORTARGETINDICATOR_META,
|
||||
'firemedium': FIREMEDIUM_META,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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, calculateSkillDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts'
|
||||
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill } from '../game/skills.ts'
|
||||
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||
import {
|
||||
|
|
@ -1082,7 +1082,7 @@ export function getSkillMana(skillId: number, hudManager?: HudManager | null): n
|
|||
case 60: return 12
|
||||
case 62: return 20
|
||||
case 64: return 30
|
||||
case 56: return 22
|
||||
case 56: return 17
|
||||
case 49: return 11
|
||||
case 38: return 4
|
||||
default: return 10
|
||||
|
|
@ -1323,6 +1323,39 @@ export function castSkill(
|
|||
return true
|
||||
}
|
||||
|
||||
if (skillId === 56) {
|
||||
// Meteor (Skill 56): spawn meteorcenter target emitter missile at target coordinates
|
||||
// 1.13c Skills.txt: Param1: 6 (radius subtiles), Param3: 30, Param4: 15, delay: 30 (1.2s cooldown)
|
||||
// 1.13c Missiles.txt: meteorcenter (ID 101), Range: 60 ticks (2.4s duration), Vel: 0
|
||||
const slvl = 1
|
||||
const dmgRange = calculateSkillDamage(56, slvl)
|
||||
const meteorDamage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||
const resFire = calculateMeteorResidualFireDamage(slvl)
|
||||
const fireDmg = Math.round((resFire.min + resFire.max) / 2)
|
||||
const fireDuration = 30 + (slvl - 1) * 15
|
||||
|
||||
engine.projectiles.push({
|
||||
skillId: '56',
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: meteorDamage,
|
||||
ttl: 60, // 2.40s at 25 fps per Missiles.txt Range = 60
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl,
|
||||
fireDuration,
|
||||
residualDamage: fireDmg,
|
||||
subMissileIdx: 0,
|
||||
})
|
||||
|
||||
player.cooldown = 30
|
||||
engine.metrics.casts += 1
|
||||
status.textContent = '施展陨石'
|
||||
return true
|
||||
}
|
||||
|
||||
if (skillId === 40) {
|
||||
// Frozen Armor
|
||||
player.cooldown = 10
|
||||
|
|
@ -3692,6 +3725,7 @@ export function drawMissileProjectile(
|
|||
renderer: SpriteRenderer,
|
||||
shot: Projectile,
|
||||
missileArt?: LoadedMissileArt,
|
||||
missileArtMap?: Map<string, LoadedMissileArt>,
|
||||
): void {
|
||||
if (shot.missileType === 'blizzardcenter') {
|
||||
return // Invisible controller missile
|
||||
|
|
@ -3702,7 +3736,24 @@ export function drawMissileProjectile(
|
|||
if (missileArt !== undefined) {
|
||||
const dccDir = velocityToDccDirection(shot.vx, shot.vy, missileArt.meta.directions)
|
||||
const frameCount = Math.max(1, missileArt.meta.framesPerDirection)
|
||||
const frameIndex = Math.abs(Math.floor(((100 - shot.ttl) * missileArt.meta.animSpeed) / 16)) % frameCount
|
||||
|
||||
let frameIndex: number
|
||||
if (missileArt.meta.name === 'meteorcenter') {
|
||||
// For meteorcenter: frame indexing must start at frame 0 and advance forward: (60 - shot.ttl) % numFrames
|
||||
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
||||
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
||||
} else if (missileArt.meta.name === 'meteorfire' && frameCount === 37) {
|
||||
// For meteorfire: use elapsed ticks (shot.ageTicks or duration - shot.ttl).
|
||||
// Frames 0..11 play emergence once, and frames >= 12 loop infinitely within 12..36 (12 + ((elapsed - 12) % 25)).
|
||||
const duration = shot.fireDuration ?? (30 + (Math.max(1, shot.slvl ?? 1) - 1) * 15)
|
||||
const elapsed = shot.ageTicks ?? Math.max(0, duration - shot.ttl)
|
||||
const animStep = Math.floor((elapsed * missileArt.meta.animSpeed) / 16)
|
||||
frameIndex = animStep < 12 ? animStep : 12 + ((animStep - 12) % 25)
|
||||
} else {
|
||||
const elapsed = shot.ageTicks ?? Math.max(0, 100 - shot.ttl)
|
||||
frameIndex = Math.floor((elapsed * missileArt.meta.animSpeed) / 16) % frameCount
|
||||
}
|
||||
|
||||
const group = missileArt.frames[dccDir] ?? missileArt.frames[0]
|
||||
const frame = group?.[frameIndex] ?? group?.[0]
|
||||
if (frame !== undefined) {
|
||||
|
|
@ -3713,6 +3764,53 @@ export function drawMissileProjectile(
|
|||
atlas: missileArt.handle,
|
||||
...(isAdditive ? { blendMode: 'additive' } : {}),
|
||||
})
|
||||
|
||||
// Dual Sprite Falling Trajectory & Rendering:
|
||||
// During the 60-tick countdown of meteorcenter, render the falling meteor fireball (meteor, ID 100)
|
||||
// and atmospheric flame tail (meteortail, ID 102) descending diagonally from sky altitude to the ground indicator.
|
||||
if (shot.missileType === 'meteorcenter' && missileArtMap !== undefined && shot.ttl > 0) {
|
||||
const remainingTicks = shot.ttl
|
||||
const fallAltitude = remainingTicks * 25
|
||||
const slideX = -remainingTicks * 15
|
||||
const meteorX = shot.x + slideX
|
||||
const meteorY = shot.y
|
||||
const elapsed = shot.ageTicks ?? Math.max(0, 60 - shot.ttl)
|
||||
|
||||
// 1. Atmospheric flame tail (`meteortail`, ID 102)
|
||||
const tailArt = missileArtMap.get('meteortail')
|
||||
if (tailArt !== undefined) {
|
||||
const tailGroup = tailArt.frames[0]
|
||||
const tailFrameCount = Math.max(1, tailArt.meta.framesPerDirection)
|
||||
const tailFrameIndex = Math.floor((elapsed * tailArt.meta.animSpeed) / 16) % tailFrameCount
|
||||
const tailFrame = tailGroup?.[tailFrameIndex] ?? tailGroup?.[0]
|
||||
if (tailFrame !== undefined) {
|
||||
const tailDrawX = tailFrame.anchorX !== undefined ? meteorX + tailFrame.anchorX : meteorX - tailFrame.width / 2
|
||||
const tailDrawY = (tailFrame.anchorY !== undefined ? meteorY + tailFrame.anchorY : meteorY - tailFrame.height / 2) - fallAltitude
|
||||
renderer.draw(tailFrame, tailDrawX, tailDrawY, {
|
||||
atlas: tailArt.handle,
|
||||
blendMode: 'additive',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Falling meteor core fireball (`meteor`, ID 100)
|
||||
const meteorArt = missileArtMap.get('meteor')
|
||||
if (meteorArt !== undefined) {
|
||||
const meteorGroup = meteorArt.frames[0]
|
||||
const meteorFrameCount = Math.max(1, meteorArt.meta.framesPerDirection)
|
||||
const meteorFrameIndex = Math.floor((elapsed * meteorArt.meta.animSpeed) / 16) % meteorFrameCount
|
||||
const meteorFrame = meteorGroup?.[meteorFrameIndex] ?? meteorGroup?.[0]
|
||||
if (meteorFrame !== undefined) {
|
||||
const meteorDrawX = meteorFrame.anchorX !== undefined ? meteorX + meteorFrame.anchorX : meteorX - meteorFrame.width / 2
|
||||
const meteorDrawY = (meteorFrame.anchorY !== undefined ? meteorY + meteorFrame.anchorY : meteorY - meteorFrame.height / 2) - fallAltitude
|
||||
renderer.draw(meteorFrame, meteorDrawX, meteorDrawY, {
|
||||
atlas: meteorArt.handle,
|
||||
blendMode: 'additive',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -3720,6 +3818,25 @@ export function drawMissileProjectile(
|
|||
const x = shot.x
|
||||
const y = shot.y - altitude
|
||||
|
||||
if (shot.missileType === 'meteorcenter') {
|
||||
// Procedural ground reticle (2:1 isometric ground ellipse)
|
||||
renderer.drawSolid(shot.x - 20, shot.y - 10, 40, 20, [1.0, 0.4, 0.1, 0.4])
|
||||
renderer.drawSolid(shot.x - 10, shot.y - 5, 20, 10, [1.0, 0.7, 0.2, 0.6])
|
||||
if (shot.ttl > 0) {
|
||||
const remainingTicks = shot.ttl
|
||||
const fallAltitude = remainingTicks * 25
|
||||
const slideX = -remainingTicks * 15
|
||||
const mx = shot.x + slideX
|
||||
const my = shot.y - fallAltitude
|
||||
// Falling fireball core
|
||||
renderer.drawSolid(mx - 8, my - 8, 16, 16, [1.0, 0.5, 0.1, 0.8])
|
||||
renderer.drawSolid(mx - 4, my - 4, 8, 8, [1.0, 0.9, 0.3, 0.95])
|
||||
// Trailing flame tail
|
||||
renderer.drawSolid(mx - 12, my - 12 + fallAltitude * 0.05, 24, 24, [0.9, 0.3, 0.05, 0.5])
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (shot.isTurret || shot.missileType === 'hydra_turret') {
|
||||
// Authentic stationary hydra head turret plume
|
||||
const wobble = Math.sin((shot.ageTicks ?? 0) * 0.3 + (shot.headIndex ?? 0) * 1.5) * 2.5
|
||||
|
|
@ -5288,7 +5405,7 @@ if (typeof window !== 'undefined') {
|
|||
mType === 'blizzardexplode3' ? missileArtMap.get('blizzardexplode2') : undefined))
|
||||
: undefined
|
||||
if (art !== undefined) {
|
||||
drawMissileProjectile(renderer, shot, art)
|
||||
drawMissileProjectile(renderer, shot, art, missileArtMap)
|
||||
} else {
|
||||
renderer.drawSolid(shot.x - 4, shot.y - 4, 8, 8, [0.9, 0.9, 0.2, 1])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,521 @@
|
|||
/**
|
||||
* Independent Adversarial Verification Harness — Skill #056: Meteor (1.13c Parity)
|
||||
*
|
||||
* Authored by: teamwork_preview_challenger_meteor_1 (Empirical Challenger)
|
||||
* Verification Scope:
|
||||
* 1. 5-band level scaling: slvl 1, 5, 10, 20, 25, 30 against 1.13c ground truth formulas.
|
||||
* Deep investigation of slvl 25 exact formula (1270-1338) vs explorer handoff arithmetic shortcut (1274-1342).
|
||||
* 2. Synergy multipliers: Fire Bolt (+5%/blvl), Fire Ball (+5%/blvl), Fire Mastery multiplicative bonus,
|
||||
* and Inferno (+3%/blvl) for residual ground fire.
|
||||
* 3. Soft points edge cases: verifying hard points (blvl) strictly govern synergy, while soft points (+skills/charges)
|
||||
* grant ZERO synergy bonuses.
|
||||
* 4. Residual fire patch mechanics: HitShift 3 (Raw * 8 in 256ths), duration scaling 30 + (slvl - 1) * 15 ticks.
|
||||
* 5. NextDelay verification: NextDelay = 0 in data tables and runtime simulation (overlapping patches simultaneously damage targets).
|
||||
* 6. Adversarial boundaries: NaN, negative, 0, extreme levels (slvl 99), and wall clipping.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../src/game/engine/stat-list.ts'
|
||||
import { compute5BandScaling } from '../src/game/engine/calc-ast.ts'
|
||||
import {
|
||||
calculateMeteorDamage,
|
||||
calculateMeteorSynergyMultiplier,
|
||||
calculateMeteorResidualFireDamage,
|
||||
calculateMeteorDetailedDamage,
|
||||
calculateSkillDamage,
|
||||
getSkillManaCost,
|
||||
getSkillCooldownTicks,
|
||||
METEOR_FIRE_SUBTILE_OFFSETS,
|
||||
ISO_GROUND_ASPECT_RATIO,
|
||||
parseMissilesTxt,
|
||||
tickProjectiles,
|
||||
type Projectile,
|
||||
} from '../src/game/skills.ts'
|
||||
import { skillModule } from '../src/game/skills/impl/sor/skill-056-meteor.ts'
|
||||
import { MissileEngine } from '../src/game/engine/missile-engine.ts'
|
||||
import type { CombatUnitContext, SUnitDmgPacket } from '../src/game/engine/combat-pipeline.ts'
|
||||
|
||||
describe('Adversarial Stress Test: Sorceress Skill 56 (Meteor) 1.13c Math & Mechanics', () => {
|
||||
|
||||
// =========================================================================
|
||||
// Challenge Dimension 1: 5-Band Level Scaling & Slvl 25 Ground Truth Analysis
|
||||
// =========================================================================
|
||||
describe('Dimension 1: 5-Band Level Scaling & Slvl 25 Analysis', () => {
|
||||
it('verifies exact 5-band scaling across benchmark levels: 1, 5, 10, 20, 30', () => {
|
||||
// 1.13c Skills.txt Row 56 parameters:
|
||||
// Base: EMin = 80, EMax = 100
|
||||
// Band 1 (slvl 2..8, 7 levels): EMinLev1 = 23, EMaxLev1 = 25
|
||||
// Band 2 (slvl 9..16, 8 levels): EMinLev2 = 39, EMaxLev2 = 41
|
||||
// Band 3 (slvl 17..22, 6 levels): EMinLev3 = 79, EMaxLev3 = 81
|
||||
// Band 4 (slvl 23..28, 6 levels): EMinLev4 = 81, EMaxLev4 = 83
|
||||
// Band 5 (slvl 29+, beyond): EMinLev5 = 83, EMaxLev5 = 85
|
||||
|
||||
// slvl 1: 80 - 100
|
||||
const d1 = calculateMeteorDamage(1)
|
||||
expect(d1).toEqual({ min: 80, max: 100 })
|
||||
|
||||
// slvl 5: 80 + 4*23 = 172, 100 + 4*25 = 200
|
||||
const d5 = calculateMeteorDamage(5)
|
||||
expect(d5).toEqual({ min: 172, max: 200 })
|
||||
|
||||
// slvl 10: 80 + 7*23 + 2*39 = 319, 100 + 7*25 + 2*41 = 357
|
||||
const d10 = calculateMeteorDamage(10)
|
||||
expect(d10).toEqual({ min: 319, max: 357 })
|
||||
|
||||
// slvl 20: 80 + 7*23 + 8*39 + 4*79 = 869, 100 + 7*25 + 8*41 + 4*81 = 927
|
||||
const d20 = calculateMeteorDamage(20)
|
||||
expect(d20).toEqual({ min: 869, max: 927 })
|
||||
|
||||
// slvl 30: 869 + 2*79 (slvl 21-22) + 6*81 (slvl 23-28) + 2*83 (slvl 29-30) = 1679
|
||||
// max: 927 + 2*81 + 6*83 + 2*85 = 1757
|
||||
const d30 = calculateMeteorDamage(30)
|
||||
expect(d30).toEqual({ min: 1679, max: 1757 })
|
||||
})
|
||||
|
||||
it('proves slvl 25 mathematical ground truth: 1270-1338 vs explorer report 1274-1342', () => {
|
||||
// Deep Mathematical Proof:
|
||||
// Band 3 comprises slvl 17..22 (total 6 levels: 17, 18, 19, 20, 21, 22) with rate 79/81.
|
||||
// At slvl 20, only 4 levels of Band 3 have elapsed (17..20).
|
||||
// Slvl 21 and 22 STILL belong to Band 3!
|
||||
// Therefore:
|
||||
// slvl 22 = slvl 20 + 2 * 79 = 869 + 158 = 1027 min (max = 927 + 2*81 = 1089)
|
||||
// Band 4 begins at slvl 23!
|
||||
// At slvl 25, exactly 3 levels of Band 4 have elapsed (slvl 23, 24, 25) with rate 81/83.
|
||||
// slvl 25 = 1027 + 3 * 81 = 1027 + 243 = 1270 min
|
||||
// slvl 25 max = 1089 + 3 * 83 = 1089 + 249 = 1338 max
|
||||
//
|
||||
// Origin of Explorer's 1274-1342:
|
||||
// The explorer calculated: slvl 20 (869) + 5 * 81 = 869 + 405 = 1274
|
||||
// and max: 927 + 5 * 83 = 927 + 415 = 1342.
|
||||
// This erroneously treated slvl 21 and 22 as Band 4 (+81) rather than Band 3 (+79),
|
||||
// introducing an off-by-4 error (+2 per level for 2 levels = +4).
|
||||
// If 1274 were correct, slvl 30 would be 1274 + 3*81 + 2*83 = 1274 + 243 + 166 = 1683 (NOT 1679).
|
||||
// Only 1270 correctly reconciles with the undisputed slvl 30 ground truth of 1679!
|
||||
|
||||
const d25 = calculateMeteorDamage(25)
|
||||
expect(d25.min).toBe(1270)
|
||||
expect(d25.max).toBe(1338)
|
||||
|
||||
// Verification via core compute5BandScaling function
|
||||
const c5bMin = compute5BandScaling(25, 80, 23, 39, 79, 81, 83)
|
||||
const c5bMax = compute5BandScaling(25, 100, 25, 41, 81, 83, 85)
|
||||
expect(c5bMin).toBe(1270)
|
||||
expect(c5bMax).toBe(1338)
|
||||
|
||||
// Assert that 1270 + remainder of Band 4 (3 levels) + 2 levels of Band 5 = slvl 30
|
||||
expect(1270 + 3 * 81 + 2 * 83).toBe(1679)
|
||||
expect(1338 + 3 * 83 + 2 * 85).toBe(1757)
|
||||
})
|
||||
|
||||
it('exhaustively tests 5-band monotonicity across slvl 1 to 99', () => {
|
||||
let prevMin = 0
|
||||
let prevMax = 0
|
||||
for (let lvl = 1; lvl <= 99; lvl++) {
|
||||
const dmg = calculateMeteorDamage(lvl)
|
||||
expect(dmg.min).toBeGreaterThan(prevMin)
|
||||
expect(dmg.max).toBeGreaterThan(prevMax)
|
||||
expect(dmg.max).toBeGreaterThan(dmg.min)
|
||||
prevMin = dmg.min
|
||||
prevMax = dmg.max
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Challenge Dimension 2: Synergy Multipliers & Multiplicative Fire Mastery
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Synergy Multipliers & Mastery Scaling', () => {
|
||||
it('verifies +5% per hard point in Fire Bolt (36) and Fire Ball (47)', () => {
|
||||
// 0 synergies: 1.0x
|
||||
expect(calculateMeteorSynergyMultiplier({})).toBe(1.0)
|
||||
|
||||
// Fire Bolt 1 pt: 1.05x
|
||||
expect(calculateMeteorSynergyMultiplier({ fireBolt: 1 })).toBe(1.05)
|
||||
|
||||
// Fire Bolt 20 pts: 2.00x (+100%)
|
||||
expect(calculateMeteorSynergyMultiplier({ fireBolt: 20 })).toBe(2.00)
|
||||
|
||||
// Fire Ball 1 pt: 1.05x
|
||||
expect(calculateMeteorSynergyMultiplier({ fireBall: 1 })).toBe(1.05)
|
||||
|
||||
// Fire Ball 20 pts: 2.00x (+100%)
|
||||
expect(calculateMeteorSynergyMultiplier({ fireBall: 20 })).toBe(2.00)
|
||||
|
||||
// Fire Bolt 20 + Fire Ball 20: 3.00x (+200%)
|
||||
expect(calculateMeteorSynergyMultiplier({ fireBolt: 20, fireBall: 20 })).toBe(3.00)
|
||||
|
||||
// Damage checks at slvl 20 (base 869-927)
|
||||
const d20Bolt20 = calculateMeteorDamage(20, { fireBolt: 20 })
|
||||
expect(d20Bolt20.min).toBe(Math.floor(869 * 2.0)) // 1738
|
||||
expect(d20Bolt20.max).toBe(Math.floor(927 * 2.0)) // 1854
|
||||
|
||||
const d20Both20 = calculateMeteorDamage(20, { fireBolt: 20, fireBall: 20 })
|
||||
expect(d20Both20.min).toBe(Math.floor(869 * 3.0)) // 2607
|
||||
expect(d20Both20.max).toBe(Math.floor(927 * 3.0)) // 2781
|
||||
})
|
||||
|
||||
it('verifies Fire Mastery (61) multiplicative scaling with synergies', () => {
|
||||
// In 1.13c, Fire Mastery: 30% base (lvl 1) + 7%/lvl
|
||||
// Level 1: +30% -> (1 + 0.30) = 1.30
|
||||
// Level 20: 30 + 19 * 7 = 163% -> (1 + 1.63) = 2.63
|
||||
const synMastery1 = calculateMeteorSynergyMultiplier({ fireMastery: 1 })
|
||||
expect(synMastery1).toBeCloseTo(1.30, 4)
|
||||
|
||||
const synMastery20 = calculateMeteorSynergyMultiplier({ fireMastery: 20 })
|
||||
expect(synMastery20).toBeCloseTo(2.63, 4)
|
||||
|
||||
// Synergies + Mastery are multiplicative:
|
||||
// (1 + (20+20)*0.05) * (1 + 1.63) = 3.0 * 2.63 = 7.89
|
||||
const synFull = calculateMeteorSynergyMultiplier({ fireBolt: 20, fireBall: 20, fireMastery: 20 })
|
||||
expect(synFull).toBeCloseTo(7.89, 4)
|
||||
|
||||
const d20Full = calculateMeteorDamage(20, { fireBolt: 20, fireBall: 20, fireMastery: 20 })
|
||||
expect(d20Full.min).toBe(Math.floor(869 * 7.89)) // 6856
|
||||
expect(d20Full.max).toBe(Math.floor(927 * 7.89)) // 7314
|
||||
})
|
||||
|
||||
it('verifies residual fire synergy: Inferno (41) +3%/lvl and Fire Mastery', () => {
|
||||
// Base residual fire at slvl 1: 15 - 25
|
||||
const resBase = calculateMeteorResidualFireDamage(1)
|
||||
expect(resBase.min).toBe(15)
|
||||
expect(resBase.max).toBe(25)
|
||||
|
||||
// Inferno 1 pt: +3% -> 1.03x
|
||||
const resInf1 = calculateMeteorResidualFireDamage(1, { inferno: 1 })
|
||||
expect(resInf1.min).toBe(Math.floor(15 * 1.03)) // 15
|
||||
expect(resInf1.max).toBe(Math.floor(25 * 1.03)) // 25
|
||||
|
||||
// Inferno 20 pts: +60% -> 1.60x
|
||||
const resInf20 = calculateMeteorResidualFireDamage(1, { inferno: 20 })
|
||||
expect(resInf20.min).toBe(Math.floor(15 * 1.60)) // 24
|
||||
expect(resInf20.max).toBe(Math.floor(25 * 1.60)) // 40
|
||||
|
||||
// Inferno 20 pts + Fire Mastery 20 (+163%): 1.60 * 2.63 = 4.208
|
||||
const resBoth20 = calculateMeteorResidualFireDamage(1, { inferno: 20, fireMastery: 20 })
|
||||
expect(resBoth20.min).toBe(Math.floor(15 * 1.60 * 2.63)) // 63
|
||||
expect(resBoth20.max).toBe(Math.floor(25 * 1.60 * 2.63)) // 105
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Challenge Dimension 3: Soft Points vs Hard Points (Marrowwalk Synergy Rule)
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Soft Points vs Hard Points Edge Cases', () => {
|
||||
it('verifies that item soft points (+skills, +fireskills, charges) provide ZERO synergy bonus', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(56)!
|
||||
|
||||
// Case A: 20 Base Points in Meteor, 0 Base in Fire Bolt, but +20 allskills & +33 charged skill
|
||||
const statsSoft = new UnitStatList()
|
||||
statsSoft.setBaseSkillLevel(56, 20)
|
||||
statsSoft.addStat('item_allskills', 20)
|
||||
statsSoft.addStat('item_sorceress_skills', 10)
|
||||
statsSoft.setChargedSkillLevel(36, 33) // Fire Bolt charges (Marrowwalk bug test)
|
||||
|
||||
const evalSoft = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 50, // 20 base + 30 soft
|
||||
blvl: 20,
|
||||
statList: statsSoft,
|
||||
})
|
||||
|
||||
// Synergy bonus percentage MUST BE 0!
|
||||
expect(evalSoft.synergyBonusPct).toBe(0)
|
||||
|
||||
// Case B: 20 Base Points in Fire Bolt (hard points) without soft points
|
||||
const statsHard = new UnitStatList()
|
||||
statsHard.setBaseSkillLevel(56, 20)
|
||||
statsHard.setBaseSkillLevel(36, 20) // 20 hard points in Fire Bolt
|
||||
|
||||
const evalHard = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsHard,
|
||||
})
|
||||
|
||||
// Synergy bonus percentage MUST BE exactly 100% (+5% * 20)
|
||||
expect(evalHard.synergyBonusPct).toBe(100)
|
||||
|
||||
// Case C: 20 Base Points in Fire Bolt PLUS +20 soft points in allskills
|
||||
const statsMixed = new UnitStatList()
|
||||
statsMixed.setBaseSkillLevel(56, 20)
|
||||
statsMixed.setBaseSkillLevel(36, 20) // 20 hard points
|
||||
statsMixed.addStat('item_allskills', 20) // +20 soft points
|
||||
|
||||
const evalMixed = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 40,
|
||||
blvl: 20,
|
||||
statList: statsMixed,
|
||||
})
|
||||
|
||||
// Synergy bonus percentage MUST STILL BE exactly 100%, NOT 200%!
|
||||
expect(evalMixed.synergyBonusPct).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Challenge Dimension 4: Residual Fire Patch Math & Duration Scaling
|
||||
// =========================================================================
|
||||
describe('Dimension 4: Residual Fire Patch Math & Duration Scaling', () => {
|
||||
it('verifies 5-band residual fire damage progression: [4, 5, 6, 6, 6]', () => {
|
||||
// Base: 15-25
|
||||
// slvl 1: 15 - 25
|
||||
const r1 = calculateMeteorResidualFireDamage(1)
|
||||
expect(r1.min).toBe(15)
|
||||
expect(r1.max).toBe(25)
|
||||
|
||||
// slvl 5: 15 + 4*4 = 31, 25 + 4*4 = 41
|
||||
const r5 = calculateMeteorResidualFireDamage(5)
|
||||
expect(r5.min).toBe(31)
|
||||
expect(r5.max).toBe(41)
|
||||
|
||||
// slvl 10: 15 + 7*4 + 2*5 = 53, 25 + 7*4 + 2*5 = 63
|
||||
const r10 = calculateMeteorResidualFireDamage(10)
|
||||
expect(r10.min).toBe(53)
|
||||
expect(r10.max).toBe(63)
|
||||
|
||||
// slvl 20: 15 + 7*4 + 8*5 + 4*6 = 107, 25 + 7*4 + 8*5 + 4*6 = 117
|
||||
const r20 = calculateMeteorResidualFireDamage(20)
|
||||
expect(r20.min).toBe(107)
|
||||
expect(r20.max).toBe(117)
|
||||
|
||||
// slvl 25: 107 + 2*6 + 3*6 = 137, 117 + 2*6 + 3*6 = 147
|
||||
const r25 = calculateMeteorResidualFireDamage(25)
|
||||
expect(r25.min).toBe(137)
|
||||
expect(r25.max).toBe(147)
|
||||
|
||||
// slvl 30: 107 + 2*6 + 6*6 + 2*6 = 167, 117 + 2*6 + 6*6 + 2*6 = 177
|
||||
const r30 = calculateMeteorResidualFireDamage(30)
|
||||
expect(r30.min).toBe(167)
|
||||
expect(r30.max).toBe(177)
|
||||
})
|
||||
|
||||
it('verifies HitShift 3 precision (Raw * 8 in 256ths) and per-second conversion (Raw * 200 / 256)', () => {
|
||||
// 1.13c HitShift = 3: Damage256 = Raw * 8
|
||||
for (const slvl of [1, 5, 10, 20, 25, 30]) {
|
||||
const res = calculateMeteorResidualFireDamage(slvl)
|
||||
const tickMin256 = res.min * 8
|
||||
const tickMax256 = res.max * 8
|
||||
expect(tickMin256).toBe(res.min << 3)
|
||||
expect(tickMax256).toBe(res.max << 3)
|
||||
|
||||
// 25 ticks/sec -> perSec = floor(Damage256 * 25 / 256) = floor(Raw * 200 / 256)
|
||||
expect(res.perSecMin).toBe(Math.floor((res.min * 200) / 256))
|
||||
expect(res.perSecMax).toBe(Math.floor((res.max * 200) / 256))
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies exact residual fire duration scaling: 30 + (slvl - 1) * 15 ticks', () => {
|
||||
const expectedDurations = [
|
||||
{ slvl: 1, ticks: 30, sec: 1.20 },
|
||||
{ slvl: 5, ticks: 90, sec: 3.60 },
|
||||
{ slvl: 10, ticks: 165, sec: 6.60 },
|
||||
{ slvl: 20, ticks: 315, sec: 12.60 },
|
||||
{ slvl: 25, ticks: 390, sec: 15.60 },
|
||||
{ slvl: 30, ticks: 465, sec: 18.60 },
|
||||
]
|
||||
|
||||
for (const exp of expectedDurations) {
|
||||
const dur = 30 + (exp.slvl - 1) * 15
|
||||
expect(dur).toBe(exp.ticks)
|
||||
expect(dur / 25).toBeCloseTo(exp.sec, 2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Challenge Dimension 5: NextDelay = 0 & Overlapping Fire Patch Multi-Hit
|
||||
// =========================================================================
|
||||
describe('Dimension 5: NextDelay = 0 & Overlapping Fire Patches', () => {
|
||||
it('verifies from authentic Missiles.txt that all Meteor missiles have NextDelay = 0', () => {
|
||||
const missilesTxtPath = path.resolve(__dirname, '../samples/fixtures/data/global/excel/Missiles.txt')
|
||||
const content = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||
const parsed = parseMissilesTxt(content)
|
||||
|
||||
const meteorMissiles = ['meteorcenter', 'meteor', 'meteortail', 'meteorexplode', 'meteorfire']
|
||||
for (const name of meteorMissiles) {
|
||||
const m = parsed.get(name)
|
||||
expect(m, `Missile ${name} not found in Missiles.txt`).toBeDefined()
|
||||
// In 1.13c Missiles.txt, NextDelay and NextHit are either 0 or undefined for Meteor suite
|
||||
const rec = m as any
|
||||
expect(rec.nextDelay ?? 0).toBe(0)
|
||||
expect(rec.nextHit ?? 0).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('simulates multiple overlapping meteorfire patches hitting the same target simultaneously without NextDelay lockout', () => {
|
||||
// Create 3 overlapping meteorfire projectiles at the exact same location
|
||||
const p1: Projectile = {
|
||||
skillId: '56',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 10,
|
||||
ttl: 30,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorfire',
|
||||
pierce: true,
|
||||
}
|
||||
const p2: Projectile = {
|
||||
skillId: '56',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 15,
|
||||
ttl: 30,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorfire',
|
||||
pierce: true,
|
||||
}
|
||||
const p3: Projectile = {
|
||||
skillId: '56',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 20,
|
||||
ttl: 30,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorfire',
|
||||
pierce: true,
|
||||
}
|
||||
|
||||
const monster = [{ index: 42, x: 200, y: 200, radius: 20, alive: true }]
|
||||
const outcome = tickProjectiles([p1, p2, p3], monster, { overlap: () => 0 })
|
||||
|
||||
// Because NextDelay = 0, all 3 overlapping patches MUST deal damage in the exact same tick!
|
||||
expect(outcome.hits.length).toBe(3)
|
||||
const dmgDealt = outcome.hits.map(h => h.damage).sort((a, b) => a - b)
|
||||
expect(dmgDealt).toEqual([10, 15, 20])
|
||||
expect(outcome.alive.length).toBe(3)
|
||||
})
|
||||
|
||||
it('simulates periodic burn over 20 ticks: verifies burn cadence every 5 ticks', () => {
|
||||
let p: Projectile = {
|
||||
skillId: '56',
|
||||
x: 150,
|
||||
y: 150,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 25,
|
||||
ttl: 30,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorfire',
|
||||
pierce: true,
|
||||
}
|
||||
const monster = [{ index: 99, x: 150, y: 150, radius: 20, alive: true }]
|
||||
|
||||
let totalHits = 0
|
||||
for (let t = 1; t <= 20; t++) {
|
||||
const out = tickProjectiles([p], monster, { overlap: () => 0 })
|
||||
p = out.alive[0]!
|
||||
totalHits += out.hits.length
|
||||
}
|
||||
|
||||
// Cadence: hits on tick 1, 5, 10, 15, 20 = 5 total hits over 20 ticks
|
||||
expect(totalHits).toBe(5)
|
||||
expect(p.ttl).toBe(10) // 30 - 20 = 10 ticks remaining
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Challenge Dimension 6: Adversarial Boundary & Resilience Inputs
|
||||
// =========================================================================
|
||||
describe('Dimension 6: Adversarial Boundary & Input Fuzzing', () => {
|
||||
it('handles non-finite, zero, and negative skill levels gracefully (clamps to 1)', () => {
|
||||
const dZero = calculateMeteorDamage(0)
|
||||
const dNeg = calculateMeteorDamage(-10)
|
||||
const dNaN = calculateMeteorDamage(NaN)
|
||||
const dInf = calculateMeteorDamage(Infinity)
|
||||
|
||||
expect(dZero).toEqual({ min: 80, max: 100 })
|
||||
expect(dNeg).toEqual({ min: 80, max: 100 })
|
||||
expect(dNaN).toEqual({ min: 80, max: 100 })
|
||||
|
||||
const mZero = getSkillManaCost(56, 0)
|
||||
const mNeg = getSkillManaCost(56, -5)
|
||||
const mNaN = getSkillManaCost(56, NaN)
|
||||
expect(mZero).toBe(17)
|
||||
expect(mNeg).toBe(17)
|
||||
expect(mNaN).toBe(17)
|
||||
})
|
||||
|
||||
it('handles negative or invalid synergy values safely', () => {
|
||||
const synNeg = calculateMeteorSynergyMultiplier({ fireBolt: -5, fireBall: -10 })
|
||||
// Even with negative points, multiplier should not drop below 0 or break calculations
|
||||
expect(Number.isFinite(synNeg)).toBe(true)
|
||||
|
||||
const dNegSyn = calculateMeteorDamage(20, -1)
|
||||
expect(dNegSyn).toEqual({ min: 869, max: 927 }) // invalid number reverts to 1.0x
|
||||
})
|
||||
|
||||
it('verifies generic calculateSkillDamage router accepts all valid representations', () => {
|
||||
const resNum = calculateSkillDamage(56, 10)
|
||||
const resStr = calculateSkillDamage('56', 10)
|
||||
const resName = calculateSkillDamage('meteor', 10)
|
||||
expect(resNum).toEqual({ min: 319, max: 357 })
|
||||
expect(resStr).toEqual({ min: 319, max: 357 })
|
||||
expect(resName).toEqual({ min: 319, max: 357 })
|
||||
})
|
||||
|
||||
it('verifies 18-offset ground fire geometry matches D2Game.dll 0x6FD1C918 / 0x6FD1C8D0', () => {
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS.length).toBe(18)
|
||||
const expected = [
|
||||
{ x: 2, y: -2 },
|
||||
{ x: -2, y: -2 },
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 0, y: 5 },
|
||||
{ x: -3, y: 3 },
|
||||
{ x: 0, y: 3 },
|
||||
{ x: 3, y: 3 },
|
||||
{ x: -1, y: 2 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: -1, y: -1 },
|
||||
{ x: 2, y: -1 },
|
||||
{ x: -4, y: -2 },
|
||||
{ x: -3, y: -2 },
|
||||
{ x: -1, y: -3 },
|
||||
{ x: 0, y: -4 },
|
||||
{ x: 1, y: -3 },
|
||||
{ x: 3, y: -3 },
|
||||
{ x: 4, y: -2 },
|
||||
]
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS).toEqual(expected)
|
||||
})
|
||||
|
||||
it('verifies wall collision suppresses out-of-bounds fire patch generation', () => {
|
||||
const center: Projectile = {
|
||||
skillId: '56',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 200,
|
||||
ttl: 0, // detonates immediately
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl: 1,
|
||||
}
|
||||
|
||||
// Block everywhere where x > 100 (half the circle blocked by wall)
|
||||
const outcome = tickProjectiles([center], [], {
|
||||
overlap: (x, y) => (x > 100 ? 1 : 0),
|
||||
})
|
||||
const flames = outcome.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||
expect(flames.length).toBeLessThan(18)
|
||||
expect(flames.length).toBeGreaterThan(0)
|
||||
for (const flame of flames) {
|
||||
expect(flame.x).toBeLessThanOrEqual(100)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,573 @@
|
|||
/**
|
||||
* Diablo II: Lord of Destruction v1.13c — Skill #056: Meteor
|
||||
* Empirical Stress Testing & Adversarial Verification Suite
|
||||
*
|
||||
* Requirements (User Request & Solution Stress Testing Playbook):
|
||||
* 1. Countdown timing: verify meteor lands and detonates exactly at tick 60, not sooner or later.
|
||||
* 2. Dual sprite trajectory: verify meteor core (ID 100) and tail (ID 102) maintain synchronized coordinate descent.
|
||||
* 3. Exact 18 residual fire patch offsets spawned around impact point matching 0x6FD1C918 and 0x6FD1C8D0.
|
||||
* 4. Wall collision drop: fire patches spawned in wall cells are rejected (COLLIDE_WALL | COLLIDE_BLANK).
|
||||
* 5. 50,000-tick continuous rapid-casting stress test: verify zero unhandled exceptions, zero NaN coordinates,
|
||||
* clean missile disposal, and bounded memory.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
tickProjectiles,
|
||||
ISO_GROUND_ASPECT_RATIO,
|
||||
calculateMeteorDamage,
|
||||
calculateMeteorResidualFireDamage,
|
||||
calculateMeteorDetailedDamage,
|
||||
METEOR_FIRE_SUBTILE_OFFSETS,
|
||||
type Projectile,
|
||||
type ProjectileTarget,
|
||||
} from '../src/game/skills.ts'
|
||||
import { MissileEngine } from '../src/game/engine/missile-engine.ts'
|
||||
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../src/game/engine/stat-list.ts'
|
||||
import { StateBus } from '../src/game/engine/state-bus.ts'
|
||||
import type { CombatUnitContext } from '../src/game/engine/combat-pipeline.ts'
|
||||
|
||||
describe('[Skill #056] Meteor — Empirical Stress & Physics Challenger Suite', () => {
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 1: Countdown Timing Parity (Detonates Exactly at Tick 60)
|
||||
// =========================================================================
|
||||
describe('Dimension 1: Countdown Timing Parity (Exact Tick 60 Detonation)', () => {
|
||||
it('verifies in MissileEngine that meteorcenter detonates at tick 60 and not at tick 59 or 61', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const engine = new MissileEngine(registry)
|
||||
|
||||
const ownerStatList = new UnitStatList(registry, {})
|
||||
const owner: CombatUnitContext = {
|
||||
id: 'sorc',
|
||||
name: 'Sorceress',
|
||||
isUndead: false,
|
||||
isDemon: false,
|
||||
statList: ownerStatList,
|
||||
stateBus: new StateBus(ownerStatList, registry),
|
||||
}
|
||||
|
||||
const dummyStatList = new UnitStatList(registry, { maxhp: 50000 * 256, hitpoints: 50000 * 256 })
|
||||
const dummy: CombatUnitContext = {
|
||||
id: 'target-dummy',
|
||||
name: 'TargetDummy',
|
||||
isUndead: false,
|
||||
isDemon: false,
|
||||
statList: dummyStatList,
|
||||
stateBus: new StateBus(dummyStatList, registry),
|
||||
}
|
||||
|
||||
const targetPositions = new Map([['target-dummy', { x: 300, y: 300 }]])
|
||||
|
||||
// Spawn meteorcenter at (300, 300)
|
||||
engine.spawnMissile({
|
||||
missileNameOrId: 'meteorcenter',
|
||||
sourceSkillId: 56,
|
||||
slvl: 1,
|
||||
owner,
|
||||
startX: 300,
|
||||
startY: 300,
|
||||
targetX: 300,
|
||||
targetY: 300,
|
||||
dmgPacket: {
|
||||
skillId: 56,
|
||||
attackKind: 'spell',
|
||||
elemType: 'fire',
|
||||
elemMin256: 80 * 256,
|
||||
elemMax256: 100 * 256,
|
||||
autoHit: true,
|
||||
},
|
||||
})
|
||||
|
||||
let detonationTick: number | null = null
|
||||
let spawnedSubmissilesOnDetonation: string[] = []
|
||||
|
||||
// Step simulation tick by tick from 1 to 65
|
||||
for (let tick = 1; tick <= 65; tick++) {
|
||||
const step = engine.tick(tick, [dummy], targetPositions)
|
||||
|
||||
if (step.hits.length > 0) {
|
||||
if (detonationTick === null) {
|
||||
detonationTick = tick
|
||||
spawnedSubmissilesOnDetonation = [...step.subMissilesSpawned]
|
||||
}
|
||||
}
|
||||
|
||||
if (tick < 60) {
|
||||
// In ticks 1..59, meteorcenter must still be falling/waiting, 0 damage inflicted
|
||||
expect(step.hits.length, `Premature hit at tick ${tick}`).toBe(0)
|
||||
const center = engine.getActiveMissiles().find(m => m.name === 'meteorcenter')
|
||||
expect(center, `meteorcenter disappeared before tick 60 at tick ${tick}`).toBeDefined()
|
||||
expect(center!.ageTicks).toBe(tick)
|
||||
} else if (tick === 60) {
|
||||
// Exactly on tick 60: primary detonation must trigger!
|
||||
expect(step.hits.length, `Expected detonation at tick 60, but got 0 hits`).toBe(1)
|
||||
expect(step.hits[0]!.missileName).toBe('meteorexplode')
|
||||
expect(step.hits[0]!.damage).toBeGreaterThan(0)
|
||||
} else {
|
||||
// Past tick 60: meteorcenter must be expired and removed from active missiles
|
||||
const center = engine.getActiveMissiles().find(m => m.name === 'meteorcenter')
|
||||
expect(center, `meteorcenter lingered past tick 60 at tick ${tick}`).toBeUndefined()
|
||||
}
|
||||
}
|
||||
|
||||
expect(detonationTick, 'Detonation must happen exactly at tick 60').toBe(60)
|
||||
expect(spawnedSubmissilesOnDetonation).toContain('meteorexplode')
|
||||
expect(spawnedSubmissilesOnDetonation.filter(s => s === 'meteorfire').length).toBe(18)
|
||||
})
|
||||
|
||||
it('verifies in tickProjectiles whether meteorcenter detonates at tick 60 or tick 61', () => {
|
||||
let center: Projectile = {
|
||||
skillId: '56',
|
||||
x: 300,
|
||||
y: 300,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 500,
|
||||
ttl: 60,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl: 1,
|
||||
}
|
||||
|
||||
const dummyMonster = [{ index: 0, x: 300, y: 300, radius: 20, alive: true }]
|
||||
|
||||
let detonationTick: number | null = null
|
||||
|
||||
for (let tick = 1; tick <= 65; tick++) {
|
||||
const outcome = tickProjectiles([center], dummyMonster, openTerrain)
|
||||
|
||||
if (outcome.hits.length > 0 && detonationTick === null) {
|
||||
detonationTick = tick
|
||||
}
|
||||
|
||||
if (outcome.alive.length > 0) {
|
||||
center = outcome.alive[0]!
|
||||
} else {
|
||||
center = { ...center, ttl: -1 }
|
||||
}
|
||||
}
|
||||
|
||||
// 1.13c Ground Truth: Meteor has Range: 60 ticks.
|
||||
// If tickProjectiles detonates at tick 61 instead of tick 60, this test detects the off-by-one tick delay.
|
||||
expect(detonationTick, `tickProjectiles detonation tick should be 60, but was ${detonationTick}`).toBe(60)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 2: Dual Sprite Trajectory Synchronization (Core & Tail)
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Dual Sprite Trajectory Synchronization (Core & Tail)', () => {
|
||||
it('verifies meteor core (ID 100) and tail (ID 102) exist and maintain synchronized coordinate descent', () => {
|
||||
// 1.13c Ground Truth (D2Client.dll 0x6FB68840 / Missiles.txt):
|
||||
// When meteorcenter (101) is cast, client children meteor (100) and meteortail (102)
|
||||
// are spawned and descend from the sky to the ground target over 60 ticks.
|
||||
// Altitude: Z(t) = (60 - t) * 25
|
||||
// Coordinate descent: X(t) = targetX - (60 - t) * 15, Y(t) = targetY - (60 - t) * 15
|
||||
// Both core and tail must share synchronized trajectory.
|
||||
|
||||
const targetX = 400
|
||||
const targetY = 300
|
||||
|
||||
// Create core and tail projectiles
|
||||
const core: Projectile = {
|
||||
skillId: '56',
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 60,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteor',
|
||||
altitude: 60 * 25,
|
||||
}
|
||||
|
||||
const tail: Projectile = {
|
||||
skillId: '56',
|
||||
x: targetX,
|
||||
y: targetY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 60,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteortail',
|
||||
altitude: 60 * 25,
|
||||
}
|
||||
|
||||
let activeProjectiles: Projectile[] = [core, tail]
|
||||
|
||||
for (let t = 1; t <= 60; t++) {
|
||||
const step = tickProjectiles(activeProjectiles, [], openTerrain)
|
||||
activeProjectiles = step.alive as Projectile[]
|
||||
|
||||
const curCore = activeProjectiles.find(p => p.missileType === 'meteor')
|
||||
const curTail = activeProjectiles.find(p => p.missileType === 'meteortail')
|
||||
|
||||
// If core and tail are implemented in simulation:
|
||||
if (curCore !== undefined && curTail !== undefined) {
|
||||
// Verify synchronized coordinates
|
||||
expect(curCore.x).toBe(curTail.x)
|
||||
expect(curCore.y).toBe(curTail.y)
|
||||
expect(curCore.altitude).toBe(curTail.altitude)
|
||||
|
||||
// Verify monotonic descent
|
||||
if (curCore.altitude !== undefined) {
|
||||
expect(curCore.altitude).toBeLessThanOrEqual(60 * 25)
|
||||
if (t === 60) {
|
||||
expect(curCore.altitude).toBeLessThanOrEqual(0)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// If neither core nor tail is updated/supported, fail and report missing dual sprite trajectory
|
||||
expect(curCore, `meteor (ID 100) core missile missing from simulation at tick ${t}`).toBeDefined()
|
||||
expect(curTail, `meteortail (ID 102) tail missile missing from simulation at tick ${t}`).toBeDefined()
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 3: Exact 18 Residual Fire Patch Offsets Matching 0x6FD1C918/8D0
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Exact 18 Residual Fire Patch Offsets (0x6FD1C918 / 0x6FD1C8D0)', () => {
|
||||
it('verifies verbatim match of all 18 subtile offset pairs against D2Game.dll ground truth', () => {
|
||||
const GROUND_TRUTH_OFFSETS = [
|
||||
{ x: 2, y: -2 }, // [0] Inner Ring
|
||||
{ x: -2, y: -2 }, // [1] Inner Ring
|
||||
{ x: 0, y: 2 }, // [2] Inner Ring
|
||||
{ x: 0, y: 5 }, // [3] North Tip
|
||||
{ x: -3, y: 3 }, // [4] North-West Outer
|
||||
{ x: 0, y: 3 }, // [5] Center-North
|
||||
{ x: 3, y: 3 }, // [6] North-East Outer
|
||||
{ x: -1, y: 2 }, // [7] Center Cluster
|
||||
{ x: 1, y: 1 }, // [8] Center Cluster
|
||||
{ x: -1, y: -1 }, // [9] Center Cluster
|
||||
{ x: 2, y: -1 }, // [10] Center Cluster
|
||||
{ x: -4, y: -2 }, // [11] West Tip
|
||||
{ x: -3, y: -2 }, // [12] West Outer
|
||||
{ x: -1, y: -3 }, // [13] South-West Outer
|
||||
{ x: 0, y: -4 }, // [14] South Tip
|
||||
{ x: 1, y: -3 }, // [15] South-East Outer
|
||||
{ x: 3, y: -3 }, // [16] East Outer
|
||||
{ x: 4, y: -2 }, // [17] East Tip
|
||||
]
|
||||
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS.length).toBe(18)
|
||||
for (let i = 0; i < 18; i++) {
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS[i]).toEqual(GROUND_TRUTH_OFFSETS[i])
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies that detonation spawns exactly 18 meteorfire submissiles at correctly scaled pixel coordinates', () => {
|
||||
const centerX = 500
|
||||
const centerY = 500
|
||||
|
||||
const center: Projectile = {
|
||||
skillId: '56',
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 300,
|
||||
ttl: 0, // detonates immediately
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl: 1,
|
||||
}
|
||||
|
||||
const outcome = tickProjectiles([center], [], openTerrain)
|
||||
const spawnedFlames = outcome.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||
|
||||
expect(spawnedFlames.length).toBe(18)
|
||||
|
||||
for (let i = 0; i < 18; i++) {
|
||||
const off = METEOR_FIRE_SUBTILE_OFFSETS[i]!
|
||||
const expectedX = centerX + off.x * 20
|
||||
const expectedY = centerY + off.y * 10
|
||||
|
||||
const flame = spawnedFlames.find(f => f.x === expectedX && f.y === expectedY)
|
||||
expect(flame, `Missing meteorfire at offset [${i}] (${expectedX}, ${expectedY})`).toBeDefined()
|
||||
expect(flame!.ttl).toBe(30) // slvl 1: 30 ticks
|
||||
expect(flame!.pierce).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: Wall Collision Drop (COLLIDE_WALL | COLLIDE_BLANK)
|
||||
// =========================================================================
|
||||
describe('Dimension 4: Wall Collision Drop (COLLIDE_WALL | COLLIDE_BLANK)', () => {
|
||||
it('verifies fire patches falling inside solid wall cells are completely rejected in tickProjectiles', () => {
|
||||
const centerX = 300
|
||||
const centerY = 300
|
||||
|
||||
const center: Projectile = {
|
||||
skillId: '56',
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 300,
|
||||
ttl: 0,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl: 1,
|
||||
}
|
||||
|
||||
// Case 1: East half is a wall (x > centerX = 300)
|
||||
const halfWallTerrain = {
|
||||
overlap: (x: number, _y: number) => (x > 300 ? 1 : 0),
|
||||
}
|
||||
|
||||
const resHalf = tickProjectiles([center], [], halfWallTerrain)
|
||||
const flamesHalf = resHalf.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||
|
||||
expect(flamesHalf.length).toBeGreaterThan(0)
|
||||
expect(flamesHalf.length).toBeLessThan(18)
|
||||
for (const f of flamesHalf) {
|
||||
expect(f.x).toBeLessThanOrEqual(300)
|
||||
}
|
||||
|
||||
// Case 2: Entire area is a solid wall
|
||||
const fullWallTerrain = {
|
||||
overlap: () => 1,
|
||||
}
|
||||
|
||||
const resFull = tickProjectiles([center], [], fullWallTerrain)
|
||||
const flamesFull = resFull.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||
expect(flamesFull.length).toBe(0) // All 18 fire patches dropped!
|
||||
|
||||
// Case 3: Selective pinpoint wall blocking North Tip [3] (0, 5) -> (300, 350)
|
||||
const northTipX = centerX + 0 * 20
|
||||
const northTipY = centerY + 5 * 10
|
||||
const selectiveTerrain = {
|
||||
overlap: (x: number, y: number) => (x === northTipX && y === northTipY ? 1 : 0),
|
||||
}
|
||||
|
||||
const resSelective = tickProjectiles([center], [], selectiveTerrain)
|
||||
const flamesSelective = resSelective.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||
expect(flamesSelective.length).toBe(17)
|
||||
expect(flamesSelective.some(f => f.x === northTipX && f.y === northTipY)).toBe(false)
|
||||
})
|
||||
|
||||
it('checks whether MissileEngine implements wall collision filtering for meteorfire spawning', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const engine = new MissileEngine(registry)
|
||||
|
||||
const ownerStatList = new UnitStatList(registry, {})
|
||||
const owner: CombatUnitContext = {
|
||||
id: 'sorc',
|
||||
name: 'Sorceress',
|
||||
isUndead: false,
|
||||
isDemon: false,
|
||||
statList: ownerStatList,
|
||||
stateBus: new StateBus(ownerStatList, registry),
|
||||
}
|
||||
|
||||
engine.spawnMissile({
|
||||
missileNameOrId: 'meteorcenter',
|
||||
sourceSkillId: 56,
|
||||
slvl: 1,
|
||||
owner,
|
||||
startX: 200,
|
||||
startY: 200,
|
||||
targetX: 200,
|
||||
targetY: 200,
|
||||
dmgPacket: {
|
||||
skillId: 56,
|
||||
attackKind: 'spell',
|
||||
elemType: 'fire',
|
||||
elemMin256: 80 * 256,
|
||||
elemMax256: 100 * 256,
|
||||
},
|
||||
})
|
||||
|
||||
// Tick 60 to trigger detonation
|
||||
for (let t = 1; t <= 60; t++) {
|
||||
engine.tick(t, [], new Map())
|
||||
}
|
||||
|
||||
const activeFires = engine.getActiveMissiles().filter(m => m.name === 'meteorfire')
|
||||
// When no collision terrain is provided, all 18 submissiles spawn
|
||||
expect(activeFires.length).toBe(18)
|
||||
})
|
||||
|
||||
it('verifies in MissileEngine that meteorfire submissiles landing in solid walls are dropped', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
// Terrain with eastern wall (x > 200)
|
||||
const halfWallTerrain = {
|
||||
overlap: (x: number, _y: number) => (x > 200 ? 1 : 0),
|
||||
}
|
||||
const engine = new MissileEngine(registry, halfWallTerrain)
|
||||
|
||||
const ownerStatList = new UnitStatList(registry, {})
|
||||
const owner: CombatUnitContext = {
|
||||
id: 'sorc',
|
||||
name: 'Sorceress',
|
||||
isUndead: false,
|
||||
isDemon: false,
|
||||
statList: ownerStatList,
|
||||
stateBus: new StateBus(ownerStatList, registry),
|
||||
}
|
||||
|
||||
engine.spawnMissile({
|
||||
missileNameOrId: 'meteorcenter',
|
||||
sourceSkillId: 56,
|
||||
slvl: 1,
|
||||
owner,
|
||||
startX: 200,
|
||||
startY: 200,
|
||||
targetX: 200,
|
||||
targetY: 200,
|
||||
dmgPacket: {
|
||||
skillId: 56,
|
||||
attackKind: 'spell',
|
||||
elemType: 'fire',
|
||||
elemMin256: 80 * 256,
|
||||
elemMax256: 100 * 256,
|
||||
},
|
||||
})
|
||||
|
||||
// Tick 60 to trigger detonation
|
||||
for (let t = 1; t <= 60; t++) {
|
||||
engine.tick(t, [], new Map())
|
||||
}
|
||||
|
||||
const activeFires = engine.getActiveMissiles().filter(m => m.name === 'meteorfire')
|
||||
expect(activeFires.length).toBeGreaterThan(0)
|
||||
expect(activeFires.length).toBeLessThan(18)
|
||||
expect(activeFires.every(m => m.x <= 200)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: 50,000-Tick Continuous Rapid-Casting Stress Simulation
|
||||
// =========================================================================
|
||||
describe('Dimension 5: 50,000-Tick Continuous Rapid-Casting Stress Simulation', () => {
|
||||
it('runs 50,000 ticks of continuous Meteor casting without crashes, NaNs, or memory leaks', () => {
|
||||
const simStartMs = Date.now()
|
||||
const heapBefore = process.memoryUsage().heapUsed
|
||||
|
||||
let activeProjectiles: Projectile[] = []
|
||||
let totalCasts = 0
|
||||
let totalHits = 0
|
||||
let totalWallHits = 0
|
||||
let totalExpired = 0
|
||||
let peakActiveCount = 0
|
||||
|
||||
// Dynamic moving targets in combat arena
|
||||
const targets: ProjectileTarget[] = [
|
||||
{ index: 0, x: 250, y: 250, radius: 25, alive: true },
|
||||
{ index: 1, x: 270, y: 260, radius: 20, alive: true },
|
||||
{ index: 2, x: 230, y: 240, radius: 20, alive: true },
|
||||
{ index: 3, x: 260, y: 230, radius: 20, alive: true },
|
||||
{ index: 4, x: 350, y: 350, radius: 30, alive: true },
|
||||
]
|
||||
|
||||
// Semi-solid map terrain with boundary walls and interior pillar
|
||||
const hybridTerrain = {
|
||||
overlap: (x: number, y: number) => {
|
||||
if (x < 30 || x > 470 || y < 30 || y > 470) return 1 // perimeter walls
|
||||
if (x >= 280 && x <= 320 && y >= 280 && y <= 320) return 1 // central obstacle pillar
|
||||
return 0
|
||||
},
|
||||
}
|
||||
|
||||
// 50,000 ticks = 2,000 seconds = 33.3 minutes of non-stop game simulation
|
||||
for (let tick = 0; tick < 50000; tick++) {
|
||||
// Cast Meteor on cooldown (every 30 ticks = 1.20s cast delay)
|
||||
if (tick % 30 === 0) {
|
||||
totalCasts++
|
||||
// Slight spatial variation across casts
|
||||
const cx = 250 + ((tick / 30) % 7) * 8 - 24
|
||||
const cy = 250 + ((tick / 30) % 5) * 10 - 20
|
||||
|
||||
activeProjectiles.push({
|
||||
skillId: '56',
|
||||
x: cx,
|
||||
y: cy,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 200,
|
||||
ttl: 60, // 2.40s countdown
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl: 1,
|
||||
residualDamage: 25,
|
||||
fireDuration: 30,
|
||||
})
|
||||
}
|
||||
|
||||
// Monsters move slightly every 8 ticks
|
||||
if (tick % 8 === 0) {
|
||||
for (let m = 0; m < targets.length; m++) {
|
||||
const angle = (tick * 0.04) + m * 1.2
|
||||
targets[m] = {
|
||||
...targets[m]!,
|
||||
x: 250 + Math.cos(angle) * 35,
|
||||
y: 250 + Math.sin(angle) * 20 * ISO_GROUND_ASPECT_RATIO,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Advance simulation tick
|
||||
const outcome = tickProjectiles(activeProjectiles, targets, hybridTerrain)
|
||||
activeProjectiles = outcome.alive as Projectile[]
|
||||
totalHits += outcome.hits.length
|
||||
totalWallHits += outcome.wallHits
|
||||
totalExpired += outcome.expired
|
||||
|
||||
if (activeProjectiles.length > peakActiveCount) {
|
||||
peakActiveCount = activeProjectiles.length
|
||||
}
|
||||
|
||||
// INVARIANT 1: Bounded missile count — no runaway projectile accumulation!
|
||||
// At any tick:
|
||||
// - At most 2 falling meteorcenters (ttl 60 / 30 = 2)
|
||||
// - At most 1 meteorexplode (ttl 16)
|
||||
// - At most 18-36 active residual fire patches (ttl 30)
|
||||
// Peak active projectiles should never exceed 100.
|
||||
if (activeProjectiles.length > 100) {
|
||||
throw new Error(`Runaway projectile leak at tick ${tick}: active count reached ${activeProjectiles.length}`)
|
||||
}
|
||||
|
||||
// INVARIANT 2: Zero NaNs or non-finite numbers in any active missile
|
||||
for (const p of activeProjectiles) {
|
||||
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) {
|
||||
throw new Error(`NaN detected in position at tick ${tick}: x=${p.x}, y=${p.y}`)
|
||||
}
|
||||
if (p.altitude !== undefined && (!Number.isFinite(p.altitude) || p.altitude < 0)) {
|
||||
throw new Error(`Invalid altitude at tick ${tick}: altitude=${p.altitude}`)
|
||||
}
|
||||
if (!Number.isFinite(p.ttl) || p.ttl < 0) {
|
||||
throw new Error(`Invalid ttl at tick ${tick}: ttl=${p.ttl}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const simElapsedMs = Date.now() - simStartMs
|
||||
const heapAfter = process.memoryUsage().heapUsed
|
||||
const heapDiffMb = (heapAfter - heapBefore) / (1024 * 1024)
|
||||
|
||||
// Assertions on 50,000 tick completion
|
||||
expect(totalCasts).toBe(Math.floor(50000 / 30) + 1) // 1,667 Meteor casts
|
||||
expect(totalHits).toBeGreaterThan(1000) // thousands of hits registered
|
||||
expect(peakActiveCount).toBeLessThanOrEqual(65) // comfortably bounded
|
||||
expect(activeProjectiles.length).toBeLessThanOrEqual(40) // final active count bounded
|
||||
expect(heapDiffMb).toBeLessThan(25) // stable heap (< 25 MB delta)
|
||||
|
||||
console.log(`[50,000-Tick Meteor Stress Sim Completed]`)
|
||||
console.log(` Duration: ${simElapsedMs} ms (${(50000 / (simElapsedMs / 1000)).toFixed(0)} ticks/sec)`)
|
||||
console.log(` Total Casts: ${totalCasts}`)
|
||||
console.log(` Total Hits: ${totalHits}`)
|
||||
console.log(` Total Wall Hits: ${totalWallHits}`)
|
||||
console.log(` Total Expired Missiles: ${totalExpired}`)
|
||||
console.log(` Peak Active Projectiles: ${peakActiveCount}`)
|
||||
console.log(` Final Active Projectiles: ${activeProjectiles.length}`)
|
||||
console.log(` Heap Diff: ${heapDiffMb.toFixed(2)} MB`)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,14 +1,51 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #056: Meteor (SOR)
|
||||
* Gitea Tracking Issue: #210
|
||||
* Gitea Tracking Issue: #210 & #401
|
||||
*
|
||||
* 7-Dimensional Verification Scope:
|
||||
* Dimension 1: Static data table parsing (Skills.txt row 56, Missiles.txt IDs 100, 101, 102, 103, 240).
|
||||
* Dimension 2: Visual assets & atlas metadata on disk (PNG/JSON exist with non-zero size).
|
||||
* Dimension 3: Harmless emitter countdown (60 ticks / 2.40s) without premature contact damage.
|
||||
* Dimension 4: Primary detonation damage scaling & synergies (5-band formula: 80-100 base).
|
||||
* Dimension 5: Detonation AOE 120px with 2:1 isometric ground ratio (ISO_GROUND_ASPECT_RATIO = 0.5).
|
||||
* Dimension 6: 18-offset ground fire geometry and duration scaling (30 + (slvl - 1) * 15 ticks).
|
||||
* Dimension 7: Residual ground fire periodic burn damage with NextDelay = 0.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-056-meteor.ts'
|
||||
import {
|
||||
parseMissilesTxt,
|
||||
getMissileTxtData,
|
||||
CANONICAL_113C_MISSILES,
|
||||
tickProjectiles,
|
||||
ISO_GROUND_ASPECT_RATIO,
|
||||
calculateMeteorDamage,
|
||||
calculateMeteorSynergyMultiplier,
|
||||
calculateMeteorResidualFireDamage,
|
||||
calculateMeteorDetailedDamage,
|
||||
calculateSkillDamage,
|
||||
getSkillManaCost,
|
||||
getSkillCooldownTicks,
|
||||
METEOR_FIRE_SUBTILE_OFFSETS,
|
||||
type Projectile,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import {
|
||||
METEOR_META,
|
||||
METEORTAIL_META,
|
||||
METEORCENTER_META,
|
||||
METEOREXPLODE_META,
|
||||
METEORFIRE_META,
|
||||
MISSILE_METAS,
|
||||
} from '../../../src/render/missiles-meta.ts'
|
||||
import { drawMissileProjectile, type LoadedMissileArt } from '../../../src/scene/act-scene.ts'
|
||||
import type { SpriteRenderer, AtlasHandle } from '../../../src/render/renderer.ts'
|
||||
|
||||
describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210)', () => {
|
||||
describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210 & #401)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(56)!
|
||||
|
|
@ -106,4 +143,510 @@ describe('[Skill #056] Meteor (SOR) — 1.13c Parity (Issue #210)', () => {
|
|||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// 7-Dimensional Verification Suite (Issue #401)
|
||||
// ==========================================================================
|
||||
|
||||
it('Dimension 1: Parses authentic 1.13c Missiles.txt & Skills.txt for Meteor suite', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
|
||||
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||
const map = parseMissilesTxt(missilesTxtContent)
|
||||
|
||||
// meteor (ID 100)
|
||||
const meteor = map.get('meteor')
|
||||
expect(meteor).toBeDefined()
|
||||
expect(meteor!.id).toBe(100)
|
||||
expect(meteor!.vel).toBe(0)
|
||||
expect(meteor!.range).toBe(60)
|
||||
expect(meteor!.celFile.toLowerCase()).toBe('meteor')
|
||||
expect(meteor!.animLen).toBe(12)
|
||||
|
||||
// meteorcenter (ID 101)
|
||||
const center = map.get('meteorcenter')
|
||||
expect(center).toBeDefined()
|
||||
expect(center!.id).toBe(101)
|
||||
expect(center!.vel).toBe(0)
|
||||
expect(center!.range).toBe(60)
|
||||
expect(center!.celFile.toLowerCase()).toBe('meteortargetindicator')
|
||||
expect(center!.animLen).toBe(17)
|
||||
expect(center!.light).toBe(4)
|
||||
expect(center!.red).toBe(255)
|
||||
expect(center!.green).toBe(64)
|
||||
expect(center!.blue).toBe(64)
|
||||
|
||||
// meteortail (ID 102)
|
||||
const tail = map.get('meteortail')
|
||||
expect(tail).toBeDefined()
|
||||
expect(tail!.id).toBe(102)
|
||||
expect(tail!.range).toBe(60)
|
||||
expect(tail!.celFile.toLowerCase()).toBe('meteortail')
|
||||
expect(tail!.animLen).toBe(12)
|
||||
|
||||
// meteorexplode (ID 103)
|
||||
const explode = map.get('meteorexplode')
|
||||
expect(explode).toBeDefined()
|
||||
expect(explode!.id).toBe(103)
|
||||
expect(explode!.range).toBe(16)
|
||||
expect(explode!.celFile.toLowerCase()).toBe('exparrowexplode')
|
||||
expect(explode!.animLen).toBe(16)
|
||||
expect(explode!.light).toBe(13)
|
||||
|
||||
// meteorfire (ID 240)
|
||||
const fire = map.get('meteorfire')
|
||||
expect(fire).toBeDefined()
|
||||
expect(fire!.id).toBe(240)
|
||||
expect(fire!.celFile.toLowerCase()).toBe('groundfiremedium')
|
||||
expect(fire!.animLen).toBe(37)
|
||||
expect(fire!.light).toBe(3)
|
||||
|
||||
// Skills.txt metadata specification check
|
||||
const skillRec = registry.getSkillById(56)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillRec.name).toBe('Meteor')
|
||||
expect(skillRec.delay).toBe('30')
|
||||
expect(skillRec.mana).toBe(34)
|
||||
expect(skillRec.lvlMana).toBe(1)
|
||||
expect(skillRec.manaShift).toBe(7)
|
||||
expect(skillRec.eMin).toBe(80)
|
||||
expect(skillRec.eMax).toBe(100)
|
||||
expect(skillRec.srvDoFunc).toBe(28)
|
||||
expect(skillRec.castOverlay).toBe('fire_cast_2')
|
||||
|
||||
// Mana cost formula: (34 + (lvl - 1)) * 0.5
|
||||
expect(getSkillManaCost(56, 1)).toBe(17)
|
||||
expect(getSkillManaCost(56, 10)).toBe(21.5)
|
||||
expect(getSkillManaCost(56, 20)).toBe(26.5)
|
||||
expect(getSkillCooldownTicks(56)).toBe(30)
|
||||
})
|
||||
|
||||
it('Dimension 2: Baked DCC missile atlases and metadata exist on disk with non-zero size', () => {
|
||||
expect(METEOR_META.name).toBe('meteor')
|
||||
expect(METEOR_META.celFile).toBe('Meteor')
|
||||
expect(METEOR_META.framesPerDirection).toBe(12)
|
||||
|
||||
expect(METEORTAIL_META.name).toBe('meteortail')
|
||||
expect(METEORTAIL_META.celFile).toBe('MeteorTail')
|
||||
expect(METEORTAIL_META.framesPerDirection).toBe(12)
|
||||
|
||||
expect(METEORCENTER_META.name).toBe('meteorcenter')
|
||||
expect(METEORCENTER_META.celFile).toBe('MeteorTargetIndicator')
|
||||
expect(METEORCENTER_META.framesPerDirection).toBe(17)
|
||||
|
||||
expect(METEOREXPLODE_META.name).toBe('meteorexplode')
|
||||
expect(METEOREXPLODE_META.celFile).toBe('ExpArrowExplode')
|
||||
expect(METEOREXPLODE_META.framesPerDirection).toBe(16)
|
||||
|
||||
expect(METEORFIRE_META.name).toBe('meteorfire')
|
||||
expect(METEORFIRE_META.celFile).toBe('groundFireMedium')
|
||||
expect(METEORFIRE_META.framesPerDirection).toBe(37)
|
||||
|
||||
expect(MISSILE_METAS['meteor']).toBe(METEOR_META)
|
||||
expect(MISSILE_METAS['meteortail']).toBe(METEORTAIL_META)
|
||||
expect(MISSILE_METAS['meteorcenter']).toBe(METEORCENTER_META)
|
||||
expect(MISSILE_METAS['meteorexplode']).toBe(METEOREXPLODE_META)
|
||||
expect(MISSILE_METAS['meteorfire']).toBe(METEORFIRE_META)
|
||||
|
||||
const missileFiles = ['meteor', 'meteortail', 'meteorcenter', 'meteorexplode', 'meteorfire']
|
||||
for (const name of missileFiles) {
|
||||
const pngPath = path.resolve(__dirname, `../../../public/missiles/${name}.png`)
|
||||
const jsonPath = path.resolve(__dirname, `../../../public/missiles/${name}.json`)
|
||||
expect(fs.existsSync(pngPath), `Missing ${pngPath}`).toBe(true)
|
||||
expect(fs.statSync(pngPath).size).toBeGreaterThan(1000)
|
||||
expect(fs.existsSync(jsonPath), `Missing ${jsonPath}`).toBe(true)
|
||||
expect(fs.statSync(jsonPath).size).toBeGreaterThan(100)
|
||||
}
|
||||
})
|
||||
|
||||
it('Dimension 3: Harmless emitter countdown (60 ticks / 2.40s) without premature contact damage', () => {
|
||||
let center: Projectile = {
|
||||
skillId: '56',
|
||||
x: 300,
|
||||
y: 300,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 500,
|
||||
ttl: 60, // 2.40s at 25 fps
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl: 1,
|
||||
}
|
||||
|
||||
const dummyMonster = [{ index: 0, x: 300, y: 300, radius: 30, alive: true }]
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
// Over ticks 1 to 59, meteorcenter must not damage monster despite standing at exact same coordinates
|
||||
for (let tick = 1; tick < 60; tick += 1) {
|
||||
const outcome = tickProjectiles([center], dummyMonster, openTerrain)
|
||||
expect(outcome.hits.length).toBe(0)
|
||||
expect(outcome.alive.length).toBe(1)
|
||||
center = outcome.alive[0]!
|
||||
expect(center.missileType).toBe('meteorcenter')
|
||||
expect(center.ttl).toBe(60 - tick)
|
||||
}
|
||||
|
||||
// Exactly on tick 60, center expires and detonates
|
||||
const finalOutcome = tickProjectiles([center], dummyMonster, openTerrain)
|
||||
expect(finalOutcome.expired).toBe(1)
|
||||
expect(finalOutcome.hits.length).toBe(1) // monster takes detonation damage on expiry!
|
||||
expect(finalOutcome.hits[0]!.damage).toBe(500)
|
||||
expect(finalOutcome.hits[0]!.missileType).toBe('meteorexplode')
|
||||
})
|
||||
|
||||
it('Dimension 4: Primary detonation damage scaling with 5-band level progression & synergies', () => {
|
||||
// slvl 1: 80 - 100
|
||||
const d1 = calculateMeteorDamage(1)
|
||||
expect(d1.min).toBe(80)
|
||||
expect(d1.max).toBe(100)
|
||||
|
||||
// slvl 5: 80 + 4*23 = 172, 100 + 4*25 = 200
|
||||
const d5 = calculateMeteorDamage(5)
|
||||
expect(d5.min).toBe(172)
|
||||
expect(d5.max).toBe(200)
|
||||
|
||||
// slvl 10: 80 + 7*23 + 2*39 = 319, 100 + 7*25 + 2*41 = 357
|
||||
const d10 = calculateMeteorDamage(10)
|
||||
expect(d10.min).toBe(319)
|
||||
expect(d10.max).toBe(357)
|
||||
|
||||
// slvl 20: 80 + 7*23 + 8*39 + 4*79 = 869, 100 + 7*25 + 8*41 + 4*81 = 927
|
||||
const d20 = calculateMeteorDamage(20)
|
||||
expect(d20.min).toBe(869)
|
||||
expect(d20.max).toBe(927)
|
||||
|
||||
// slvl 30: 869 + 2*79 + 6*81 + 2*83 = 1679, 927 + 2*81 + 6*83 + 2*85 = 1757
|
||||
const d30 = calculateMeteorDamage(30)
|
||||
expect(d30.min).toBe(1679)
|
||||
expect(d30.max).toBe(1757)
|
||||
|
||||
// Synergies:
|
||||
// Fire Bolt: 20 pts (+100% = 2.0x)
|
||||
const syn20 = calculateMeteorSynergyMultiplier({ fireBolt: 20 })
|
||||
expect(syn20).toBe(2.0)
|
||||
const d20Syn = calculateMeteorDamage(20, { fireBolt: 20 })
|
||||
expect(d20Syn.min).toBe(1738) // 869 * 2.0
|
||||
expect(d20Syn.max).toBe(1854) // 927 * 2.0
|
||||
|
||||
// Full synergies: Fire Bolt 20, Fire Ball 20 (+200% = 3.0x)
|
||||
const syn40 = calculateMeteorSynergyMultiplier({ fireBolt: 20, fireBall: 20 })
|
||||
expect(syn40).toBe(3.0)
|
||||
const d20Full = calculateMeteorDamage(20, { fireBolt: 20, fireBall: 20 })
|
||||
expect(d20Full.min).toBe(2607) // 869 * 3.0
|
||||
expect(d20Full.max).toBe(2781) // 927 * 3.0
|
||||
|
||||
// Generic calculateSkillDamage router checks
|
||||
expect(calculateSkillDamage(56, 1)).toEqual(d1)
|
||||
expect(calculateSkillDamage('56', 10)).toEqual(d10)
|
||||
expect(calculateSkillDamage('meteor', 20)).toEqual(d20)
|
||||
})
|
||||
|
||||
it('Dimension 5: Detonation AOE 120px with 2:1 isometric ground foreshortening', () => {
|
||||
// 6 subtiles = 120px ground radius.
|
||||
// In 2:1 isometric projection: ellipse is dx^2 + (dy / 0.5)^2 <= 120^2.
|
||||
const center: Projectile = {
|
||||
skillId: '56',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 400,
|
||||
ttl: 0, // detonates immediately on tick
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl: 1,
|
||||
}
|
||||
|
||||
const monsters = [
|
||||
{ index: 1, x: 200, y: 250, radius: 10, alive: true }, // dx=0, dy=50: dy/0.5 = 100 <= 120 (INSIDE)
|
||||
{ index: 2, x: 200, y: 310, radius: 10, alive: true }, // dx=0, dy=110: dy/0.5 = 220 > 120 (OUTSIDE)
|
||||
{ index: 3, x: 300, y: 200, radius: 10, alive: true }, // dx=100, dy=0: dx = 100 <= 120 (INSIDE)
|
||||
{ index: 4, x: 330, y: 200, radius: 10, alive: true }, // dx=130, dy=0: dx = 130 > 120 (OUTSIDE)
|
||||
]
|
||||
|
||||
const outcome = tickProjectiles([center], monsters, { overlap: () => 0 })
|
||||
expect(outcome.hits.length).toBe(2)
|
||||
const hitIndices = outcome.hits.map(h => h.targetIndex)
|
||||
expect(hitIndices).toContain(1)
|
||||
expect(hitIndices).toContain(3)
|
||||
expect(hitIndices).not.toContain(2)
|
||||
expect(hitIndices).not.toContain(4)
|
||||
|
||||
// Visual meteorexplode missile spawned
|
||||
expect(outcome.spawnedProjectiles?.some(p => p.missileType === 'meteorexplode')).toBe(true)
|
||||
})
|
||||
|
||||
it('Dimension 6: 18-offset ground fire geometry and duration scaling (30 + (slvl-1)*15)', () => {
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS.length).toBe(18)
|
||||
|
||||
// Check specific cardinal offsets from D2Game.dll 0x6FD1C918 and 0x6FD1C8D0
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS[3]).toEqual({ x: 0, y: 5 }) // North Tip
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS[11]).toEqual({ x: -4, y: -2 }) // West Tip
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS[14]).toEqual({ x: 0, y: -4 }) // South Tip
|
||||
expect(METEOR_FIRE_SUBTILE_OFFSETS[17]).toEqual({ x: 4, y: -2 }) // East Tip
|
||||
|
||||
// Verify duration scaling: slvl 1: 30 ticks; slvl 10: 165 ticks; slvl 20: 315 ticks
|
||||
const centerSlvl10: Projectile = {
|
||||
skillId: '56',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 300,
|
||||
ttl: 0,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorcenter',
|
||||
slvl: 10,
|
||||
}
|
||||
|
||||
const outcome10 = tickProjectiles([centerSlvl10], [], { overlap: () => 0 })
|
||||
const flames10 = outcome10.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||
expect(flames10.length).toBe(18)
|
||||
for (const f of flames10) {
|
||||
expect(f.ttl).toBe(30 + 9 * 15) // 165 ticks = 6.60 seconds
|
||||
expect(f.pierce).toBe(true)
|
||||
}
|
||||
|
||||
// Verify wall collision filtering: flames inside walls are suppressed
|
||||
const outcomeWall = tickProjectiles([centerSlvl10], [], {
|
||||
overlap: (x, y) => (x > 140 ? 1 : 0), // wall on eastern side
|
||||
})
|
||||
const flamesWall = outcomeWall.spawnedProjectiles?.filter(p => p.missileType === 'meteorfire') ?? []
|
||||
expect(flamesWall.length).toBeLessThan(18)
|
||||
expect(flamesWall.every(f => f.x <= 140)).toBe(true)
|
||||
})
|
||||
|
||||
it('Dimension 7: Residual ground fire periodic burn damage with NextDelay = 0', () => {
|
||||
// Residual fire formulas
|
||||
const res1 = calculateMeteorResidualFireDamage(1)
|
||||
expect(res1.min).toBe(15)
|
||||
expect(res1.max).toBe(25)
|
||||
expect(res1.perSecMin).toBe(11) // floor(15 * 200 / 256)
|
||||
expect(res1.perSecMax).toBe(19) // floor(25 * 200 / 256)
|
||||
|
||||
const res10 = calculateMeteorResidualFireDamage(10)
|
||||
expect(res10.min).toBe(53)
|
||||
expect(res10.max).toBe(63)
|
||||
expect(res10.perSecMin).toBe(41)
|
||||
expect(res10.perSecMax).toBe(49)
|
||||
|
||||
const res20 = calculateMeteorResidualFireDamage(20)
|
||||
expect(res20.min).toBe(107)
|
||||
expect(res20.max).toBe(117)
|
||||
expect(res20.perSecMin).toBe(83)
|
||||
expect(res20.perSecMax).toBe(91)
|
||||
|
||||
// Detailed damage breakdown
|
||||
const detailed = calculateMeteorDetailedDamage(20, { fireBolt: 20 })
|
||||
expect(detailed.impactFire.min).toBe(1738)
|
||||
expect(detailed.impactPhys.min).toBe(0) // 1.13c vanilla has 0 physical damage
|
||||
expect(detailed.residualFire.min).toBe(107)
|
||||
|
||||
// Optional physical split support (e.g. 50/50 mod support)
|
||||
const splitDetailed = calculateMeteorDetailedDamage(20, {}, { physRatio: 0.5 })
|
||||
expect(splitDetailed.impactFire.min).toBe(434)
|
||||
expect(splitDetailed.impactPhys.min).toBe(434)
|
||||
|
||||
// Simulation of residual flame patch over 10 ticks
|
||||
let flame: Projectile = {
|
||||
skillId: '56',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 20,
|
||||
ttl: 30,
|
||||
fromPlayer: true,
|
||||
missileType: 'meteorfire',
|
||||
pierce: true,
|
||||
}
|
||||
|
||||
const dummyMonster = [{ index: 5, x: 100, y: 100, radius: 15, alive: true }]
|
||||
let hitCount = 0
|
||||
|
||||
for (let t = 1; t <= 10; t += 1) {
|
||||
const outcome = tickProjectiles([flame], dummyMonster, { overlap: () => 0 })
|
||||
flame = outcome.alive[0]!
|
||||
hitCount += outcome.hits.length
|
||||
}
|
||||
|
||||
// Over 10 ticks with 5-tick periodic burn cadence, monster is damaged 3 times (tick 1, 5, 10)
|
||||
expect(hitCount).toBe(3)
|
||||
expect(flame.ttl).toBe(20) // remains alive, doesn't vanish on hit
|
||||
})
|
||||
|
||||
it('Dimension 8: Dual Sprite Falling Trajectory & Trans: 1 Additive Blending in drawMissileProjectile', () => {
|
||||
const drawCalls: { frame: any; x: number; y: number; options?: any }[] = []
|
||||
const mockRenderer = {
|
||||
draw(frame: any, x: number, y: number, options?: any) {
|
||||
drawCalls.push({ frame, x, y, options })
|
||||
},
|
||||
drawSolid: vi.fn(),
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
// Build mock art map for meteor, meteortail, meteorcenter
|
||||
const makeMockArt = (meta: any): LoadedMissileArt => ({
|
||||
meta,
|
||||
handle: 1 as unknown as AtlasHandle,
|
||||
frames: [
|
||||
meta.groups[0].map(([x, y, w, h, ax, ay]: number[]) => ({
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
anchorX: ax,
|
||||
anchorY: ay,
|
||||
})),
|
||||
],
|
||||
})
|
||||
|
||||
const artMap = new Map<string, LoadedMissileArt>([
|
||||
['meteor', makeMockArt(METEOR_META)],
|
||||
['meteortail', makeMockArt(METEORTAIL_META)],
|
||||
['meteorcenter', makeMockArt(METEORCENTER_META)],
|
||||
])
|
||||
|
||||
const centerShot: Projectile = {
|
||||
skillId: '56',
|
||||
missileType: 'meteorcenter',
|
||||
x: 400,
|
||||
y: 300,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 500,
|
||||
ttl: 60, // At initial cast (60 remaining ticks)
|
||||
fromPlayer: true,
|
||||
slvl: 1,
|
||||
}
|
||||
|
||||
// Tick 0 (ttl = 60): Ground indicator + falling tail + fireball core at altitude 1500px, slideX -900px
|
||||
drawMissileProjectile(mockRenderer, centerShot, artMap.get('meteorcenter'), artMap)
|
||||
expect(drawCalls.length).toBe(3) // 1 ground indicator, 1 flame tail, 1 meteor core
|
||||
|
||||
// 1. Ground target indicator (MeteorTargetIndicator)
|
||||
expect(drawCalls[0]!.options?.blendMode).toBe('additive')
|
||||
expect(drawCalls[0]!.x).toBe(400 - 56) // anchorX = -56
|
||||
expect(drawCalls[0]!.y).toBe(300 - 43) // anchorY = -43
|
||||
|
||||
// 2. Atmospheric flame tail (MeteorTail)
|
||||
expect(drawCalls[1]!.options?.blendMode).toBe('additive')
|
||||
// slideX = -60 * 15 = -900 -> meteorX = 400 - 900 = -500; anchorX = -49 -> drawX = -549
|
||||
expect(drawCalls[1]!.x).toBe(400 - 60 * 15 - 49)
|
||||
// altitude = 60 * 25 = 1500 -> meteorY = 300 - 143 - 1500 = -1343
|
||||
expect(drawCalls[1]!.y).toBe(300 - 143 - 60 * 25)
|
||||
|
||||
// 3. Falling meteor fireball core (Meteor)
|
||||
expect(drawCalls[2]!.options?.blendMode).toBe('additive')
|
||||
expect(drawCalls[2]!.x).toBe(400 - 60 * 15 - 7) // anchorX = -7
|
||||
expect(drawCalls[2]!.y).toBe(300 - 55 - 60 * 25) // anchorY = -55
|
||||
|
||||
// Tick 30 (ttl = 30): Mid-flight descent (altitude = 750px, slideX = -450px)
|
||||
drawCalls.length = 0
|
||||
const midShot: Projectile = { ...centerShot, ttl: 30 }
|
||||
drawMissileProjectile(mockRenderer, midShot, artMap.get('meteorcenter'), artMap)
|
||||
expect(drawCalls.length).toBe(3)
|
||||
expect(drawCalls[1]!.x).toBe(400 - 30 * 15 - 49)
|
||||
expect(drawCalls[1]!.y).toBe(300 - 143 - 30 * 25)
|
||||
expect(drawCalls[2]!.x).toBe(400 - 30 * 15 - 7)
|
||||
expect(drawCalls[2]!.y).toBe(300 - 55 - 30 * 25)
|
||||
|
||||
// Tick 60 (ttl = 0 / impact): Falling fireball hits ground, only ground indicator rendered
|
||||
drawCalls.length = 0
|
||||
const impactShot: Projectile = { ...centerShot, ttl: 0 }
|
||||
drawMissileProjectile(mockRenderer, impactShot, artMap.get('meteorcenter'), artMap)
|
||||
expect(drawCalls.length).toBe(1) // Only ground indicator, zero sky fireball
|
||||
})
|
||||
|
||||
it('Dimension 9: Animation frame indexing progression for meteorcenter and meteorfire sublooping', () => {
|
||||
const drawnFrames: number[] = []
|
||||
const mockRenderer = {
|
||||
draw(frame: any) {
|
||||
drawnFrames.push(frame.frameIdx)
|
||||
},
|
||||
drawSolid: vi.fn(),
|
||||
} as unknown as SpriteRenderer
|
||||
|
||||
// Build art with tracked frameIdx
|
||||
const makeIndexedArt = (meta: any): LoadedMissileArt => ({
|
||||
meta,
|
||||
handle: 1 as unknown as AtlasHandle,
|
||||
frames: [
|
||||
meta.groups[0].map(([x, y, w, h, ax, ay]: number[], idx: number) => ({
|
||||
x,
|
||||
y,
|
||||
width: w,
|
||||
height: h,
|
||||
anchorX: ax,
|
||||
anchorY: ay,
|
||||
frameIdx: idx,
|
||||
})),
|
||||
],
|
||||
})
|
||||
|
||||
const centerArt = makeIndexedArt(METEORCENTER_META)
|
||||
const fireArt = makeIndexedArt(METEORFIRE_META)
|
||||
|
||||
// 1. meteorcenter starts at frame 0 and advances forward
|
||||
for (let elapsed = 0; elapsed < 17; elapsed++) {
|
||||
drawnFrames.length = 0
|
||||
const shot: Projectile = {
|
||||
skillId: '56',
|
||||
missileType: 'meteorcenter',
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 60 - elapsed,
|
||||
ageTicks: elapsed,
|
||||
fromPlayer: true,
|
||||
}
|
||||
drawMissileProjectile(mockRenderer, shot, centerArt)
|
||||
expect(drawnFrames[0]).toBe(elapsed)
|
||||
}
|
||||
|
||||
// 2. meteorfire plays emergence once (frames 0..11) then loops 12..36 indefinitely
|
||||
// Emergence phase
|
||||
for (let elapsed = 0; elapsed < 12; elapsed++) {
|
||||
drawnFrames.length = 0
|
||||
const shot: Projectile = {
|
||||
skillId: '56',
|
||||
missileType: 'meteorfire',
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 10,
|
||||
ttl: 315 - elapsed,
|
||||
ageTicks: elapsed,
|
||||
fromPlayer: true,
|
||||
fireDuration: 315,
|
||||
}
|
||||
drawMissileProjectile(mockRenderer, shot, fireArt)
|
||||
expect(drawnFrames[0]).toBe(elapsed)
|
||||
}
|
||||
|
||||
// Looping phase (frames 12..36, 25-frame cycle)
|
||||
for (let elapsed = 12; elapsed <= 70; elapsed++) {
|
||||
drawnFrames.length = 0
|
||||
const shot: Projectile = {
|
||||
skillId: '56',
|
||||
missileType: 'meteorfire',
|
||||
x: 0,
|
||||
y: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 10,
|
||||
ttl: 315 - elapsed,
|
||||
ageTicks: elapsed,
|
||||
fromPlayer: true,
|
||||
fireDuration: 315,
|
||||
}
|
||||
drawMissileProjectile(mockRenderer, shot, fireArt)
|
||||
const expectedFrame = 12 + ((elapsed - 12) % 25)
|
||||
expect(drawnFrames[0]).toBe(expectedFrame)
|
||||
expect(drawnFrames[0]!).toBeGreaterThanOrEqual(12)
|
||||
expect(drawnFrames[0]!).toBeLessThanOrEqual(36)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||