|
|
@ -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,
|
||||
})
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -251,7 +253,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
|
||||
|
|
@ -454,6 +457,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 +473,24 @@ 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' ||
|
||||
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 +597,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 +628,75 @@ 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,
|
||||
}
|
||||
for (const off of METEOR_FIRE_SUBTILE_OFFSETS) {
|
||||
const fx = m.x + off.x * 20
|
||||
const fy = m.y + off.y * 10
|
||||
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,
|
||||
|
|
@ -3542,6 +3656,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
|
||||
}
|
||||
|
|
@ -3738,6 +3923,10 @@ export function tickProjectiles(
|
|||
}
|
||||
|
||||
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,7 +3937,7 @@ export function tickProjectiles(
|
|||
subMissileIdx,
|
||||
altitude,
|
||||
hitTargets,
|
||||
ageTicks: (projectile.ageTicks ?? 0) + 1,
|
||||
ageTicks: currentAge,
|
||||
ttl: projectile.ttl - 1,
|
||||
}
|
||||
|
||||
|
|
@ -3813,12 +4002,17 @@ export function tickProjectiles(
|
|||
const isHarmlessEmitter =
|
||||
projectile.missileType === 'frozenorb' ||
|
||||
projectile.missileType === 'blizzardcenter' ||
|
||||
projectile.missileType === 'meteorcenter' ||
|
||||
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 +4124,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 +4206,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 +4225,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 +4252,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 +4403,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
|
||||
|
|
|
|||
|
|
@ -1,14 +1,49 @@
|
|||
/**
|
||||
* 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 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'
|
||||
|
||||
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 +141,341 @@ 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 60, 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)
|
||||
if (tick < 60) {
|
||||
expect(outcome.alive.length).toBe(1)
|
||||
center = outcome.alive[0]!
|
||||
expect(center.missileType).toBe('meteorcenter')
|
||||
expect(center.ttl).toBe(60 - tick)
|
||||
} else {
|
||||
// At tick 60, center.ttl reaches 0
|
||||
center = outcome.alive[0]!
|
||||
expect(center.ttl).toBe(0)
|
||||
}
|
||||
}
|
||||
|
||||
// On tick 61, center with ttl 0 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
|
||||
})
|
||||
})
|
||||
|
|
|
|||