@vibemancer/core 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-7VDJHO22.js} +3 -3
- package/dist/chunk-7VDJHO22.js.map +1 -0
- package/dist/index-browser.d.ts +1 -1
- package/dist/index-browser.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +79 -78
- package/src/bots/berserker/01_Stormchaser.ts +457 -457
- package/src/bots/berserker/02_Stormcaller.ts +417 -417
- package/src/bots/berserker/03_Stormforger.ts +481 -481
- package/src/bots/caster/01_Flamecaller.ts +286 -286
- package/src/bots/caster/02_Pyromancer.ts +350 -350
- package/src/bots/caster/03_Infernalist.ts +492 -492
- package/src/bots/defensive/01_Turtle.ts +151 -151
- package/src/bots/defensive/02_Sentinel.ts +134 -134
- package/src/bots/defensive/03_Golem.ts +357 -357
- package/src/bots/duelist/01_Battlemage.ts +433 -433
- package/src/bots/duelist/02_Warmage.ts +438 -438
- package/src/bots/duelist/03_Archmage.ts +588 -588
- package/src/bots/homing/01_Bonemancer.ts +67 -67
- package/src/bots/homing/02_Lich.ts +356 -356
- package/src/bots/homing/03_Archlich.ts +220 -220
- package/src/bots/kiter/01_Spellspinner.ts +398 -398
- package/src/bots/kiter/02_Spellweaver.ts +378 -378
- package/src/bots/kiter/03_Spellbinder.ts +448 -448
- package/src/bots/melee/01_Shadowblade.ts +270 -270
- package/src/bots/melee/02_Nightblade.ts +437 -437
- package/src/bots/melee/03_Voidblade.ts +582 -582
- package/src/bots/sniper/01_Spellshot.ts +385 -385
- package/src/bots/sniper/02_Spelltracer.ts +441 -441
- package/src/bots/sniper/03_Spellseeker.ts +546 -546
- package/src/bots/standalone/Critter.ts +89 -89
- package/src/bots/standalone/Doombringer.ts +91 -91
- package/src/bots/standalone/Hogger.ts +228 -228
- package/src/bots/standalone/Rookie.ts +50 -50
- package/src/bots/standalone/TargetDummy.ts +21 -21
- package/src/bots/test/cheater.ts +405 -405
- package/src/bots/test/crasher.ts +81 -81
- package/src/engine/hooks-runtime.ts +394 -394
- package/src/engine/manual-match.ts +289 -289
- package/src/engine/missile-templates.ts +155 -155
- package/src/engine/physics.ts +143 -143
- package/src/engine/simulation.ts +828 -828
- package/src/engine/spells.ts +128 -128
- package/src/engine-version.ts +1 -1
- package/src/index.ts +23 -23
- package/src/rules.ts +254 -254
- package/src/types.ts +193 -193
- package/src/utils/index.ts +6 -6
- package/dist/chunk-L7Z7OFXD.js.map +0 -1
|
@@ -1,437 +1,437 @@
|
|
|
1
|
-
import {WizardFunction} from '../../types.js';
|
|
2
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
3
|
-
import {angleTo} from '../../utils/angles.js';
|
|
4
|
-
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
5
|
-
import {
|
|
6
|
-
getThreats,
|
|
7
|
-
getMostUrgentThreat,
|
|
8
|
-
getDodgeDirectionForMovement,
|
|
9
|
-
getBlinkToDecreaseDistance,
|
|
10
|
-
shouldForceShield,
|
|
11
|
-
fitMissileToBudget,
|
|
12
|
-
MAX_CAST_BUDGET,
|
|
13
|
-
MIN_USEFUL_DAMAGE,
|
|
14
|
-
} from '../shared.js';
|
|
15
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
16
|
-
const WALL_BUFFER = 50;
|
|
17
|
-
const SHIELD_BUFFER = 3;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Aggressive movement: dodge missiles, close distance, light strafe.
|
|
21
|
-
*/
|
|
22
|
-
function aggressiveMove(
|
|
23
|
-
position: {x: number; y: number},
|
|
24
|
-
enemy: {position: {x: number; y: number}},
|
|
25
|
-
currentDistance: number,
|
|
26
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
27
|
-
tick: number,
|
|
28
|
-
strafeCyclePeriod: number,
|
|
29
|
-
strafeIntensity: number,
|
|
30
|
-
approachSpeed: number,
|
|
31
|
-
): {x: number; y: number}
|
|
32
|
-
{
|
|
33
|
-
const deltaX = enemy.position.x - position.x;
|
|
34
|
-
const deltaY = enemy.position.y - position.y;
|
|
35
|
-
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
36
|
-
|
|
37
|
-
const strafeClockwise = {
|
|
38
|
-
x: -deltaY / normalizedDistance,
|
|
39
|
-
y: deltaX / normalizedDistance,
|
|
40
|
-
};
|
|
41
|
-
const strafeCounterClockwise = {
|
|
42
|
-
x: deltaY / normalizedDistance,
|
|
43
|
-
y: -deltaX / normalizedDistance,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
let strafeDirection: {x: number; y: number};
|
|
47
|
-
|
|
48
|
-
if (dodgeDirection)
|
|
49
|
-
{
|
|
50
|
-
strafeDirection = dodgeDirection;
|
|
51
|
-
}
|
|
52
|
-
else if (
|
|
53
|
-
position.x < WALL_BUFFER ||
|
|
54
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
55
|
-
position.y < WALL_BUFFER ||
|
|
56
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
57
|
-
)
|
|
58
|
-
{
|
|
59
|
-
const futureClockwise = {
|
|
60
|
-
x: position.x + strafeClockwise.x * 100,
|
|
61
|
-
y: position.y + strafeClockwise.y * 100,
|
|
62
|
-
};
|
|
63
|
-
const futureCounterClockwise = {
|
|
64
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
65
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
66
|
-
};
|
|
67
|
-
const scoreClockwise = Math.min(
|
|
68
|
-
futureClockwise.x,
|
|
69
|
-
ARENA_SIZE - futureClockwise.x,
|
|
70
|
-
futureClockwise.y,
|
|
71
|
-
ARENA_SIZE - futureClockwise.y,
|
|
72
|
-
);
|
|
73
|
-
const scoreCounterClockwise = Math.min(
|
|
74
|
-
futureCounterClockwise.x,
|
|
75
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
76
|
-
futureCounterClockwise.y,
|
|
77
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
78
|
-
);
|
|
79
|
-
strafeDirection =
|
|
80
|
-
scoreClockwise > scoreCounterClockwise
|
|
81
|
-
? strafeClockwise
|
|
82
|
-
: strafeCounterClockwise;
|
|
83
|
-
}
|
|
84
|
-
else
|
|
85
|
-
{
|
|
86
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
87
|
-
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// When actively dodging, commit fully
|
|
91
|
-
if (dodgeDirection)
|
|
92
|
-
{
|
|
93
|
-
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Light strafe + aggressive closing
|
|
97
|
-
let moveX = strafeDirection.x * strafeIntensity;
|
|
98
|
-
let moveY = strafeDirection.y * strafeIntensity;
|
|
99
|
-
|
|
100
|
-
// Always close distance aggressively
|
|
101
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
102
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
103
|
-
|
|
104
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
105
|
-
if (magnitude > 100)
|
|
106
|
-
{
|
|
107
|
-
moveX = (moveX / magnitude) * 100;
|
|
108
|
-
moveY = (moveY / magnitude) * 100;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return {x: moveX, y: moveY};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Bot: Nightblade
|
|
116
|
-
*
|
|
117
|
-
* BEHAVIOR: Enhanced melee assassin. Same aggressive engagement as Shadowblade —
|
|
118
|
-
* blinks directly to the enemy and stabs for 15 damage (2-hit kill). The tier 2
|
|
119
|
-
* upgrade is PREEMPTIVE DEFENSE: Nightblade watches the enemy's cast bar and
|
|
120
|
-
* shields before a point-blank missile is even launched. At melee range, missiles
|
|
121
|
-
* arrive almost instantly after launch — too fast to react. Nightblade anticipates
|
|
122
|
-
* the threat. Also has emergency blink and proper channeling management.
|
|
123
|
-
*
|
|
124
|
-
* PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
|
|
125
|
-
* - Shadowblade (tier 1): Offensive blink, melee stabs, basic shield (reactive only)
|
|
126
|
-
* - Nightblade (tier 2): + preemptive shield vs enemy casts, emergency blink
|
|
127
|
-
* - Voidblade (tier 3): Future — perfect assassination timing, inescapable engages
|
|
128
|
-
*
|
|
129
|
-
* TIER: 2 (enhanced Shadowblade)
|
|
130
|
-
*/
|
|
131
|
-
export const Nightblade: WizardFunction = ({state}) =>
|
|
132
|
-
{
|
|
133
|
-
const stabRange = useParam('stabRange', 263, {range: 38, min: 0, steps: 5});
|
|
134
|
-
const stabRangeVsCasting = stabRange + 20;
|
|
135
|
-
const preemptiveShieldRange = useParam('preemptiveShieldRange', 261, {
|
|
136
|
-
range: 85,
|
|
137
|
-
min: 0,
|
|
138
|
-
steps: 7,
|
|
139
|
-
});
|
|
140
|
-
const acceptHitFraction = useParam('acceptHitFraction', 0, {
|
|
141
|
-
range: 0.2,
|
|
142
|
-
min: 0,
|
|
143
|
-
max: 1,
|
|
144
|
-
steps: 5,
|
|
145
|
-
});
|
|
146
|
-
const minTurnRate = useParam('minTurnRate', -11.4, {range: 5.25, steps: 5});
|
|
147
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {
|
|
148
|
-
range: 75,
|
|
149
|
-
min: 80,
|
|
150
|
-
max: 300,
|
|
151
|
-
});
|
|
152
|
-
const strafeIntensity = useParam('strafeIntensity', 22, {
|
|
153
|
-
range: 30,
|
|
154
|
-
min: 10,
|
|
155
|
-
max: 80,
|
|
156
|
-
steps: 7,
|
|
157
|
-
});
|
|
158
|
-
const approachSpeed = useParam('approachSpeed', 92, {
|
|
159
|
-
range: 20,
|
|
160
|
-
min: 50,
|
|
161
|
-
max: 100,
|
|
162
|
-
steps: 7,
|
|
163
|
-
});
|
|
164
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 50, {
|
|
165
|
-
range: 75,
|
|
166
|
-
min: 50,
|
|
167
|
-
max: 300,
|
|
168
|
-
});
|
|
169
|
-
const blinkWindowMax = useParam('blinkWindowMax', 19, {
|
|
170
|
-
range: 30,
|
|
171
|
-
min: 15,
|
|
172
|
-
max: 80,
|
|
173
|
-
steps: 7,
|
|
174
|
-
});
|
|
175
|
-
const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
|
|
176
|
-
range: 4,
|
|
177
|
-
min: 2,
|
|
178
|
-
max: 12,
|
|
179
|
-
steps: 5,
|
|
180
|
-
});
|
|
181
|
-
const enemyFiringSoonThreshold = useParam('enemyFiringSoonThreshold', 50, {
|
|
182
|
-
range: 30,
|
|
183
|
-
min: 10,
|
|
184
|
-
max: 100,
|
|
185
|
-
steps: 7,
|
|
186
|
-
});
|
|
187
|
-
const meleeAbortRange = useParam('meleeAbortRange', 200, {
|
|
188
|
-
range: 50,
|
|
189
|
-
min: 50,
|
|
190
|
-
max: 200,
|
|
191
|
-
steps: 7,
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
const enemy = state.enemies[0];
|
|
195
|
-
if (!enemy)
|
|
196
|
-
{
|
|
197
|
-
return {move: {x: 0, y: 0}};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
201
|
-
|
|
202
|
-
const threats = getThreats(state);
|
|
203
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
204
|
-
|
|
205
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
206
|
-
const move = aggressiveMove(
|
|
207
|
-
state.position,
|
|
208
|
-
enemy,
|
|
209
|
-
distance,
|
|
210
|
-
dodgeDirection,
|
|
211
|
-
state.tick,
|
|
212
|
-
strafeCyclePeriod,
|
|
213
|
-
strafeIntensity,
|
|
214
|
-
approachSpeed,
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
// === DEFENSE: Handle channeling ===
|
|
218
|
-
if (state.state === 'channeling')
|
|
219
|
-
{
|
|
220
|
-
// Keep shield up if enemy is about to fire at close range (preemptive shield scenario)
|
|
221
|
-
const enemyFiringSoon =
|
|
222
|
-
distance < preemptiveShieldRange &&
|
|
223
|
-
enemy.state === 'casting' &&
|
|
224
|
-
enemy.castingSpell === 'missile' &&
|
|
225
|
-
(enemy.castDuration ?? 0) -
|
|
226
|
-
(enemy.castProgress ?? 0) +
|
|
227
|
-
distance / flightTimeDivisor <
|
|
228
|
-
enemyFiringSoonThreshold;
|
|
229
|
-
if (
|
|
230
|
-
(!urgentThreat && !enemyFiringSoon) ||
|
|
231
|
-
(urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
232
|
-
)
|
|
233
|
-
{
|
|
234
|
-
return {move, cancel: true};
|
|
235
|
-
}
|
|
236
|
-
return {move: {x: 0, y: 0}};
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// === DEFENSE: Cancel missile cast for LETHAL incoming damage ===
|
|
240
|
-
// DPS trade: we accepted the hit when we started casting. Only cancel to survive.
|
|
241
|
-
if (
|
|
242
|
-
state.state === 'casting' &&
|
|
243
|
-
state.castingSpell === 'missile' &&
|
|
244
|
-
urgentThreat &&
|
|
245
|
-
urgentThreat.projectile.damage >= state.health
|
|
246
|
-
)
|
|
247
|
-
{
|
|
248
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
249
|
-
if (
|
|
250
|
-
urgentThreat.ticksToImpact <=
|
|
251
|
-
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
|
|
252
|
-
)
|
|
253
|
-
{
|
|
254
|
-
return {move, cancel: true};
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// === CANCEL: Abort melee cast if target escaped (non-homing missile won't reach) ===
|
|
259
|
-
// Melee missiles are non-homing with short range (~63u). If the enemy blinked away,
|
|
260
|
-
// continuing the cast is wasted time while we're vulnerable.
|
|
261
|
-
if (
|
|
262
|
-
state.state === 'casting' &&
|
|
263
|
-
state.castingSpell === 'missile' &&
|
|
264
|
-
distance > meleeAbortRange
|
|
265
|
-
)
|
|
266
|
-
{
|
|
267
|
-
return {move, cancel: true};
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// === BLINK: Dodge missile + close gap simultaneously (first priority) ===
|
|
271
|
-
// Blink both dodges the incoming missile AND closes distance — better than shielding.
|
|
272
|
-
// Only shield when blink is on cooldown.
|
|
273
|
-
if (
|
|
274
|
-
state.state === 'idle' &&
|
|
275
|
-
urgentThreat &&
|
|
276
|
-
state.blinkCooldown === 0 &&
|
|
277
|
-
distance > stabRange &&
|
|
278
|
-
urgentThreat.ticksToImpact >= 10 &&
|
|
279
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
280
|
-
)
|
|
281
|
-
{
|
|
282
|
-
const blinkTarget =
|
|
283
|
-
getBlinkToDecreaseDistance(
|
|
284
|
-
state.position,
|
|
285
|
-
enemy.position,
|
|
286
|
-
state.projectiles,
|
|
287
|
-
) ?? enemy.position;
|
|
288
|
-
return {
|
|
289
|
-
move: {x: 0, y: 0},
|
|
290
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
291
|
-
};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// === DEFENSE: Shield lethal/high-damage undodgeable threats — survival over offense ===
|
|
295
|
-
if (
|
|
296
|
-
state.state === 'idle' &&
|
|
297
|
-
urgentThreat &&
|
|
298
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
299
|
-
urgentThreat.projectile.damage >= state.health &&
|
|
300
|
-
urgentThreat.canBlockInTime
|
|
301
|
-
)
|
|
302
|
-
{
|
|
303
|
-
return {
|
|
304
|
-
move: {x: 0, y: 0},
|
|
305
|
-
startCast: {spell: 'shield'},
|
|
306
|
-
};
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
// === OFFENSE: Budget-based melee stab when close enough ===
|
|
310
|
-
// Tier 2 upgrade: fits the highest-damage stab into the available time window
|
|
311
|
-
// before needing to shield. Also stabs from farther when enemy is casting/channeling
|
|
312
|
-
// (they move at 0.5 or 0 speed, so the cast delay doesn't let them dodge).
|
|
313
|
-
const effectiveStabRange =
|
|
314
|
-
enemy.state === 'casting' || enemy.state === 'channeling'
|
|
315
|
-
? stabRangeVsCasting
|
|
316
|
-
: stabRange;
|
|
317
|
-
// Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
|
|
318
|
-
const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
|
|
319
|
-
const hasNearbyLethalMissile = state.projectiles.some(
|
|
320
|
-
(p) =>
|
|
321
|
-
!myProjectileIds.has(p.id) &&
|
|
322
|
-
p.damage >= state.health &&
|
|
323
|
-
distanceTo(state.position, p.position) / p.speed <
|
|
324
|
-
MAX_CAST_BUDGET + GCD_DURATION,
|
|
325
|
-
);
|
|
326
|
-
if (
|
|
327
|
-
state.state === 'idle' &&
|
|
328
|
-
distance < effectiveStabRange &&
|
|
329
|
-
!hasNearbyLethalMissile
|
|
330
|
-
)
|
|
331
|
-
{
|
|
332
|
-
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
333
|
-
|
|
334
|
-
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
335
|
-
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
336
|
-
{
|
|
337
|
-
const remainingEnemyCast =
|
|
338
|
-
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
339
|
-
const estimatedFlightTime = distance / flightTimeDivisor;
|
|
340
|
-
nextThreatTime = Math.min(
|
|
341
|
-
nextThreatTime,
|
|
342
|
-
remainingEnemyCast + estimatedFlightTime,
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
const safeBudget =
|
|
347
|
-
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
348
|
-
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
349
|
-
const enemyHP = Math.ceil(enemy.health);
|
|
350
|
-
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
351
|
-
let config = fitMissileToBudget(budgetTicks, distance, {
|
|
352
|
-
minTurnRate,
|
|
353
|
-
maxDamage: enemyHP,
|
|
354
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// If budget too small for useful stab, fire bigger and accept the hit
|
|
358
|
-
// Only accept small hits (< 25% HP) — don't trade away large chunks of health
|
|
359
|
-
// Never accept when enemy is actively casting (their missile WILL arrive during our long cast)
|
|
360
|
-
const enemyCasting =
|
|
361
|
-
enemy.state === 'casting' && enemy.castingSpell === 'missile';
|
|
362
|
-
if (
|
|
363
|
-
(!config || config.damage < minDmg) &&
|
|
364
|
-
!enemyCasting &&
|
|
365
|
-
(!urgentThreat ||
|
|
366
|
-
urgentThreat.projectile.damage < state.health * acceptHitFraction)
|
|
367
|
-
)
|
|
368
|
-
{
|
|
369
|
-
config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
|
|
370
|
-
minTurnRate,
|
|
371
|
-
maxDamage: enemyHP,
|
|
372
|
-
lastMissileConfig: state.lastMissileConfig,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
if (config)
|
|
377
|
-
{
|
|
378
|
-
return {
|
|
379
|
-
move,
|
|
380
|
-
startCast: {
|
|
381
|
-
spell: 'missile',
|
|
382
|
-
config,
|
|
383
|
-
missileAI: () => ({}),
|
|
384
|
-
direction: angleTo(state.position, enemy.position),
|
|
385
|
-
},
|
|
386
|
-
};
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
// === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
|
|
391
|
-
if (
|
|
392
|
-
state.state === 'idle' &&
|
|
393
|
-
urgentThreat &&
|
|
394
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
395
|
-
)
|
|
396
|
-
{
|
|
397
|
-
if (
|
|
398
|
-
urgentThreat.canBlockInTime &&
|
|
399
|
-
(state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
|
|
400
|
-
)
|
|
401
|
-
{
|
|
402
|
-
return {
|
|
403
|
-
move: {x: 0, y: 0},
|
|
404
|
-
startCast: {spell: 'shield'},
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// === DEFENSE: Preemptive shield when enemy is casting a missile at close range ===
|
|
410
|
-
// At melee range, missiles arrive almost instantly after launch — too fast to react.
|
|
411
|
-
// Shield BEFORE the missile launches. Trigger when shield will finish BEFORE missile hits.
|
|
412
|
-
if (
|
|
413
|
-
state.state === 'idle' &&
|
|
414
|
-
distance < preemptiveShieldRange &&
|
|
415
|
-
enemy.state === 'casting' &&
|
|
416
|
-
enemy.castingSpell === 'missile'
|
|
417
|
-
)
|
|
418
|
-
{
|
|
419
|
-
const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
420
|
-
const estimatedFlightTicks = distance / flightTimeDivisor;
|
|
421
|
-
const ticksUntilHit = remainingCast + estimatedFlightTicks;
|
|
422
|
-
// Shield when hit is close enough that we MUST start now, but far enough that
|
|
423
|
-
// the shield finishes in time. The window: SHIELD_CAST_TIME <= ticksUntilHit < SHIELD_CAST_TIME+15
|
|
424
|
-
if (
|
|
425
|
-
ticksUntilHit >= SHIELD_CAST_TIME &&
|
|
426
|
-
ticksUntilHit < SHIELD_CAST_TIME + 15
|
|
427
|
-
)
|
|
428
|
-
{
|
|
429
|
-
return {
|
|
430
|
-
move: {x: 0, y: 0},
|
|
431
|
-
startCast: {spell: 'shield'},
|
|
432
|
-
};
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
return {move};
|
|
437
|
-
};
|
|
1
|
+
import {WizardFunction} from '../../types.js';
|
|
2
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
3
|
+
import {angleTo} from '../../utils/angles.js';
|
|
4
|
+
import {ARENA_SIZE, GCD_DURATION, SHIELD_CAST_TIME} from '../../rules.js';
|
|
5
|
+
import {
|
|
6
|
+
getThreats,
|
|
7
|
+
getMostUrgentThreat,
|
|
8
|
+
getDodgeDirectionForMovement,
|
|
9
|
+
getBlinkToDecreaseDistance,
|
|
10
|
+
shouldForceShield,
|
|
11
|
+
fitMissileToBudget,
|
|
12
|
+
MAX_CAST_BUDGET,
|
|
13
|
+
MIN_USEFUL_DAMAGE,
|
|
14
|
+
} from '../shared.js';
|
|
15
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
16
|
+
const WALL_BUFFER = 50;
|
|
17
|
+
const SHIELD_BUFFER = 3;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Aggressive movement: dodge missiles, close distance, light strafe.
|
|
21
|
+
*/
|
|
22
|
+
function aggressiveMove(
|
|
23
|
+
position: {x: number; y: number},
|
|
24
|
+
enemy: {position: {x: number; y: number}},
|
|
25
|
+
currentDistance: number,
|
|
26
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
27
|
+
tick: number,
|
|
28
|
+
strafeCyclePeriod: number,
|
|
29
|
+
strafeIntensity: number,
|
|
30
|
+
approachSpeed: number,
|
|
31
|
+
): {x: number; y: number}
|
|
32
|
+
{
|
|
33
|
+
const deltaX = enemy.position.x - position.x;
|
|
34
|
+
const deltaY = enemy.position.y - position.y;
|
|
35
|
+
const normalizedDistance = currentDistance > 0 ? currentDistance : 1;
|
|
36
|
+
|
|
37
|
+
const strafeClockwise = {
|
|
38
|
+
x: -deltaY / normalizedDistance,
|
|
39
|
+
y: deltaX / normalizedDistance,
|
|
40
|
+
};
|
|
41
|
+
const strafeCounterClockwise = {
|
|
42
|
+
x: deltaY / normalizedDistance,
|
|
43
|
+
y: -deltaX / normalizedDistance,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let strafeDirection: {x: number; y: number};
|
|
47
|
+
|
|
48
|
+
if (dodgeDirection)
|
|
49
|
+
{
|
|
50
|
+
strafeDirection = dodgeDirection;
|
|
51
|
+
}
|
|
52
|
+
else if (
|
|
53
|
+
position.x < WALL_BUFFER ||
|
|
54
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
55
|
+
position.y < WALL_BUFFER ||
|
|
56
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
57
|
+
)
|
|
58
|
+
{
|
|
59
|
+
const futureClockwise = {
|
|
60
|
+
x: position.x + strafeClockwise.x * 100,
|
|
61
|
+
y: position.y + strafeClockwise.y * 100,
|
|
62
|
+
};
|
|
63
|
+
const futureCounterClockwise = {
|
|
64
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
65
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
66
|
+
};
|
|
67
|
+
const scoreClockwise = Math.min(
|
|
68
|
+
futureClockwise.x,
|
|
69
|
+
ARENA_SIZE - futureClockwise.x,
|
|
70
|
+
futureClockwise.y,
|
|
71
|
+
ARENA_SIZE - futureClockwise.y,
|
|
72
|
+
);
|
|
73
|
+
const scoreCounterClockwise = Math.min(
|
|
74
|
+
futureCounterClockwise.x,
|
|
75
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
76
|
+
futureCounterClockwise.y,
|
|
77
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
78
|
+
);
|
|
79
|
+
strafeDirection =
|
|
80
|
+
scoreClockwise > scoreCounterClockwise
|
|
81
|
+
? strafeClockwise
|
|
82
|
+
: strafeCounterClockwise;
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
{
|
|
86
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
87
|
+
strafeDirection = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// When actively dodging, commit fully
|
|
91
|
+
if (dodgeDirection)
|
|
92
|
+
{
|
|
93
|
+
return {x: strafeDirection.x * 100, y: strafeDirection.y * 100};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Light strafe + aggressive closing
|
|
97
|
+
let moveX = strafeDirection.x * strafeIntensity;
|
|
98
|
+
let moveY = strafeDirection.y * strafeIntensity;
|
|
99
|
+
|
|
100
|
+
// Always close distance aggressively
|
|
101
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
102
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
103
|
+
|
|
104
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
105
|
+
if (magnitude > 100)
|
|
106
|
+
{
|
|
107
|
+
moveX = (moveX / magnitude) * 100;
|
|
108
|
+
moveY = (moveY / magnitude) * 100;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {x: moveX, y: moveY};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Bot: Nightblade
|
|
116
|
+
*
|
|
117
|
+
* BEHAVIOR: Enhanced melee assassin. Same aggressive engagement as Shadowblade —
|
|
118
|
+
* blinks directly to the enemy and stabs for 15 damage (2-hit kill). The tier 2
|
|
119
|
+
* upgrade is PREEMPTIVE DEFENSE: Nightblade watches the enemy's cast bar and
|
|
120
|
+
* shields before a point-blank missile is even launched. At melee range, missiles
|
|
121
|
+
* arrive almost instantly after launch — too fast to react. Nightblade anticipates
|
|
122
|
+
* the threat. Also has emergency blink and proper channeling management.
|
|
123
|
+
*
|
|
124
|
+
* PROGRESSION LINE: Shadowblade → Nightblade → Voidblade
|
|
125
|
+
* - Shadowblade (tier 1): Offensive blink, melee stabs, basic shield (reactive only)
|
|
126
|
+
* - Nightblade (tier 2): + preemptive shield vs enemy casts, emergency blink
|
|
127
|
+
* - Voidblade (tier 3): Future — perfect assassination timing, inescapable engages
|
|
128
|
+
*
|
|
129
|
+
* TIER: 2 (enhanced Shadowblade)
|
|
130
|
+
*/
|
|
131
|
+
export const Nightblade: WizardFunction = ({state}) =>
|
|
132
|
+
{
|
|
133
|
+
const stabRange = useParam('stabRange', 263, {range: 38, min: 0, steps: 5});
|
|
134
|
+
const stabRangeVsCasting = stabRange + 20;
|
|
135
|
+
const preemptiveShieldRange = useParam('preemptiveShieldRange', 261, {
|
|
136
|
+
range: 85,
|
|
137
|
+
min: 0,
|
|
138
|
+
steps: 7,
|
|
139
|
+
});
|
|
140
|
+
const acceptHitFraction = useParam('acceptHitFraction', 0, {
|
|
141
|
+
range: 0.2,
|
|
142
|
+
min: 0,
|
|
143
|
+
max: 1,
|
|
144
|
+
steps: 5,
|
|
145
|
+
});
|
|
146
|
+
const minTurnRate = useParam('minTurnRate', -11.4, {range: 5.25, steps: 5});
|
|
147
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 300, {
|
|
148
|
+
range: 75,
|
|
149
|
+
min: 80,
|
|
150
|
+
max: 300,
|
|
151
|
+
});
|
|
152
|
+
const strafeIntensity = useParam('strafeIntensity', 22, {
|
|
153
|
+
range: 30,
|
|
154
|
+
min: 10,
|
|
155
|
+
max: 80,
|
|
156
|
+
steps: 7,
|
|
157
|
+
});
|
|
158
|
+
const approachSpeed = useParam('approachSpeed', 92, {
|
|
159
|
+
range: 20,
|
|
160
|
+
min: 50,
|
|
161
|
+
max: 100,
|
|
162
|
+
steps: 7,
|
|
163
|
+
});
|
|
164
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 50, {
|
|
165
|
+
range: 75,
|
|
166
|
+
min: 50,
|
|
167
|
+
max: 300,
|
|
168
|
+
});
|
|
169
|
+
const blinkWindowMax = useParam('blinkWindowMax', 19, {
|
|
170
|
+
range: 30,
|
|
171
|
+
min: 15,
|
|
172
|
+
max: 80,
|
|
173
|
+
steps: 7,
|
|
174
|
+
});
|
|
175
|
+
const flightTimeDivisor = useParam('flightTimeDivisor', 12, {
|
|
176
|
+
range: 4,
|
|
177
|
+
min: 2,
|
|
178
|
+
max: 12,
|
|
179
|
+
steps: 5,
|
|
180
|
+
});
|
|
181
|
+
const enemyFiringSoonThreshold = useParam('enemyFiringSoonThreshold', 50, {
|
|
182
|
+
range: 30,
|
|
183
|
+
min: 10,
|
|
184
|
+
max: 100,
|
|
185
|
+
steps: 7,
|
|
186
|
+
});
|
|
187
|
+
const meleeAbortRange = useParam('meleeAbortRange', 200, {
|
|
188
|
+
range: 50,
|
|
189
|
+
min: 50,
|
|
190
|
+
max: 200,
|
|
191
|
+
steps: 7,
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const enemy = state.enemies[0];
|
|
195
|
+
if (!enemy)
|
|
196
|
+
{
|
|
197
|
+
return {move: {x: 0, y: 0}};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
201
|
+
|
|
202
|
+
const threats = getThreats(state);
|
|
203
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
204
|
+
|
|
205
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
206
|
+
const move = aggressiveMove(
|
|
207
|
+
state.position,
|
|
208
|
+
enemy,
|
|
209
|
+
distance,
|
|
210
|
+
dodgeDirection,
|
|
211
|
+
state.tick,
|
|
212
|
+
strafeCyclePeriod,
|
|
213
|
+
strafeIntensity,
|
|
214
|
+
approachSpeed,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// === DEFENSE: Handle channeling ===
|
|
218
|
+
if (state.state === 'channeling')
|
|
219
|
+
{
|
|
220
|
+
// Keep shield up if enemy is about to fire at close range (preemptive shield scenario)
|
|
221
|
+
const enemyFiringSoon =
|
|
222
|
+
distance < preemptiveShieldRange &&
|
|
223
|
+
enemy.state === 'casting' &&
|
|
224
|
+
enemy.castingSpell === 'missile' &&
|
|
225
|
+
(enemy.castDuration ?? 0) -
|
|
226
|
+
(enemy.castProgress ?? 0) +
|
|
227
|
+
distance / flightTimeDivisor <
|
|
228
|
+
enemyFiringSoonThreshold;
|
|
229
|
+
if (
|
|
230
|
+
(!urgentThreat && !enemyFiringSoon) ||
|
|
231
|
+
(urgentThreat && urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
232
|
+
)
|
|
233
|
+
{
|
|
234
|
+
return {move, cancel: true};
|
|
235
|
+
}
|
|
236
|
+
return {move: {x: 0, y: 0}};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// === DEFENSE: Cancel missile cast for LETHAL incoming damage ===
|
|
240
|
+
// DPS trade: we accepted the hit when we started casting. Only cancel to survive.
|
|
241
|
+
if (
|
|
242
|
+
state.state === 'casting' &&
|
|
243
|
+
state.castingSpell === 'missile' &&
|
|
244
|
+
urgentThreat &&
|
|
245
|
+
urgentThreat.projectile.damage >= state.health
|
|
246
|
+
)
|
|
247
|
+
{
|
|
248
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
249
|
+
if (
|
|
250
|
+
urgentThreat.ticksToImpact <=
|
|
251
|
+
remainingCast + GCD_DURATION + SHIELD_CAST_TIME + SHIELD_BUFFER
|
|
252
|
+
)
|
|
253
|
+
{
|
|
254
|
+
return {move, cancel: true};
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// === CANCEL: Abort melee cast if target escaped (non-homing missile won't reach) ===
|
|
259
|
+
// Melee missiles are non-homing with short range (~63u). If the enemy blinked away,
|
|
260
|
+
// continuing the cast is wasted time while we're vulnerable.
|
|
261
|
+
if (
|
|
262
|
+
state.state === 'casting' &&
|
|
263
|
+
state.castingSpell === 'missile' &&
|
|
264
|
+
distance > meleeAbortRange
|
|
265
|
+
)
|
|
266
|
+
{
|
|
267
|
+
return {move, cancel: true};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// === BLINK: Dodge missile + close gap simultaneously (first priority) ===
|
|
271
|
+
// Blink both dodges the incoming missile AND closes distance — better than shielding.
|
|
272
|
+
// Only shield when blink is on cooldown.
|
|
273
|
+
if (
|
|
274
|
+
state.state === 'idle' &&
|
|
275
|
+
urgentThreat &&
|
|
276
|
+
state.blinkCooldown === 0 &&
|
|
277
|
+
distance > stabRange &&
|
|
278
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
279
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
280
|
+
)
|
|
281
|
+
{
|
|
282
|
+
const blinkTarget =
|
|
283
|
+
getBlinkToDecreaseDistance(
|
|
284
|
+
state.position,
|
|
285
|
+
enemy.position,
|
|
286
|
+
state.projectiles,
|
|
287
|
+
) ?? enemy.position;
|
|
288
|
+
return {
|
|
289
|
+
move: {x: 0, y: 0},
|
|
290
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// === DEFENSE: Shield lethal/high-damage undodgeable threats — survival over offense ===
|
|
295
|
+
if (
|
|
296
|
+
state.state === 'idle' &&
|
|
297
|
+
urgentThreat &&
|
|
298
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat)) &&
|
|
299
|
+
urgentThreat.projectile.damage >= state.health &&
|
|
300
|
+
urgentThreat.canBlockInTime
|
|
301
|
+
)
|
|
302
|
+
{
|
|
303
|
+
return {
|
|
304
|
+
move: {x: 0, y: 0},
|
|
305
|
+
startCast: {spell: 'shield'},
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// === OFFENSE: Budget-based melee stab when close enough ===
|
|
310
|
+
// Tier 2 upgrade: fits the highest-damage stab into the available time window
|
|
311
|
+
// before needing to shield. Also stabs from farther when enemy is casting/channeling
|
|
312
|
+
// (they move at 0.5 or 0 speed, so the cast delay doesn't let them dodge).
|
|
313
|
+
const effectiveStabRange =
|
|
314
|
+
enemy.state === 'casting' || enemy.state === 'channeling'
|
|
315
|
+
? stabRangeVsCasting
|
|
316
|
+
: stabRange;
|
|
317
|
+
// Skip offense when a lethal missile could reach us during cast+GCD — dodge at full speed instead
|
|
318
|
+
const myProjectileIds = new Set(state.myProjectiles.map((p) => p.id));
|
|
319
|
+
const hasNearbyLethalMissile = state.projectiles.some(
|
|
320
|
+
(p) =>
|
|
321
|
+
!myProjectileIds.has(p.id) &&
|
|
322
|
+
p.damage >= state.health &&
|
|
323
|
+
distanceTo(state.position, p.position) / p.speed <
|
|
324
|
+
MAX_CAST_BUDGET + GCD_DURATION,
|
|
325
|
+
);
|
|
326
|
+
if (
|
|
327
|
+
state.state === 'idle' &&
|
|
328
|
+
distance < effectiveStabRange &&
|
|
329
|
+
!hasNearbyLethalMissile
|
|
330
|
+
)
|
|
331
|
+
{
|
|
332
|
+
let nextThreatTime = urgentThreat?.ticksToImpact ?? Infinity;
|
|
333
|
+
|
|
334
|
+
// Account for enemy's active cast — their missile will arrive after cast completes + flight time
|
|
335
|
+
if (enemy.state === 'casting' && enemy.castingSpell === 'missile')
|
|
336
|
+
{
|
|
337
|
+
const remainingEnemyCast =
|
|
338
|
+
(enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
339
|
+
const estimatedFlightTime = distance / flightTimeDivisor;
|
|
340
|
+
nextThreatTime = Math.min(
|
|
341
|
+
nextThreatTime,
|
|
342
|
+
remainingEnemyCast + estimatedFlightTime,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const safeBudget =
|
|
347
|
+
nextThreatTime - GCD_DURATION - SHIELD_CAST_TIME - SHIELD_BUFFER;
|
|
348
|
+
const budgetTicks = Math.min(safeBudget, MAX_CAST_BUDGET);
|
|
349
|
+
const enemyHP = Math.ceil(enemy.health);
|
|
350
|
+
const minDmg = Math.min(MIN_USEFUL_DAMAGE, enemyHP);
|
|
351
|
+
let config = fitMissileToBudget(budgetTicks, distance, {
|
|
352
|
+
minTurnRate,
|
|
353
|
+
maxDamage: enemyHP,
|
|
354
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// If budget too small for useful stab, fire bigger and accept the hit
|
|
358
|
+
// Only accept small hits (< 25% HP) — don't trade away large chunks of health
|
|
359
|
+
// Never accept when enemy is actively casting (their missile WILL arrive during our long cast)
|
|
360
|
+
const enemyCasting =
|
|
361
|
+
enemy.state === 'casting' && enemy.castingSpell === 'missile';
|
|
362
|
+
if (
|
|
363
|
+
(!config || config.damage < minDmg) &&
|
|
364
|
+
!enemyCasting &&
|
|
365
|
+
(!urgentThreat ||
|
|
366
|
+
urgentThreat.projectile.damage < state.health * acceptHitFraction)
|
|
367
|
+
)
|
|
368
|
+
{
|
|
369
|
+
config = fitMissileToBudget(MAX_CAST_BUDGET, distance, {
|
|
370
|
+
minTurnRate,
|
|
371
|
+
maxDamage: enemyHP,
|
|
372
|
+
lastMissileConfig: state.lastMissileConfig,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (config)
|
|
377
|
+
{
|
|
378
|
+
return {
|
|
379
|
+
move,
|
|
380
|
+
startCast: {
|
|
381
|
+
spell: 'missile',
|
|
382
|
+
config,
|
|
383
|
+
missileAI: () => ({}),
|
|
384
|
+
direction: angleTo(state.position, enemy.position),
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// === DEFENSE: Shield undodgeable or high-damage homing threats (only when blink can't handle it) ===
|
|
391
|
+
if (
|
|
392
|
+
state.state === 'idle' &&
|
|
393
|
+
urgentThreat &&
|
|
394
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
395
|
+
)
|
|
396
|
+
{
|
|
397
|
+
if (
|
|
398
|
+
urgentThreat.canBlockInTime &&
|
|
399
|
+
(state.blinkCooldown > 0 || urgentThreat.ticksToImpact < 10)
|
|
400
|
+
)
|
|
401
|
+
{
|
|
402
|
+
return {
|
|
403
|
+
move: {x: 0, y: 0},
|
|
404
|
+
startCast: {spell: 'shield'},
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// === DEFENSE: Preemptive shield when enemy is casting a missile at close range ===
|
|
410
|
+
// At melee range, missiles arrive almost instantly after launch — too fast to react.
|
|
411
|
+
// Shield BEFORE the missile launches. Trigger when shield will finish BEFORE missile hits.
|
|
412
|
+
if (
|
|
413
|
+
state.state === 'idle' &&
|
|
414
|
+
distance < preemptiveShieldRange &&
|
|
415
|
+
enemy.state === 'casting' &&
|
|
416
|
+
enemy.castingSpell === 'missile'
|
|
417
|
+
)
|
|
418
|
+
{
|
|
419
|
+
const remainingCast = (enemy.castDuration ?? 0) - (enemy.castProgress ?? 0);
|
|
420
|
+
const estimatedFlightTicks = distance / flightTimeDivisor;
|
|
421
|
+
const ticksUntilHit = remainingCast + estimatedFlightTicks;
|
|
422
|
+
// Shield when hit is close enough that we MUST start now, but far enough that
|
|
423
|
+
// the shield finishes in time. The window: SHIELD_CAST_TIME <= ticksUntilHit < SHIELD_CAST_TIME+15
|
|
424
|
+
if (
|
|
425
|
+
ticksUntilHit >= SHIELD_CAST_TIME &&
|
|
426
|
+
ticksUntilHit < SHIELD_CAST_TIME + 15
|
|
427
|
+
)
|
|
428
|
+
{
|
|
429
|
+
return {
|
|
430
|
+
move: {x: 0, y: 0},
|
|
431
|
+
startCast: {spell: 'shield'},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
return {move};
|
|
437
|
+
};
|