@vibemancer/core 0.1.0
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 -0
- package/dist/chunk-L7Z7OFXD.js +9140 -0
- package/dist/chunk-L7Z7OFXD.js.map +1 -0
- package/dist/index-browser.d.ts +2602 -0
- package/dist/index-browser.js +407 -0
- package/dist/index-browser.js.map +1 -0
- package/dist/index.d.ts +150 -0
- package/dist/index.js +750 -0
- package/dist/index.js.map +1 -0
- package/package.json +79 -0
- package/src/bots/berserker/01_Stormchaser.ts +457 -0
- package/src/bots/berserker/02_Stormcaller.ts +417 -0
- package/src/bots/berserker/03_Stormforger.ts +481 -0
- package/src/bots/caster/01_Flamecaller.ts +286 -0
- package/src/bots/caster/02_Pyromancer.ts +350 -0
- package/src/bots/caster/03_Infernalist.ts +492 -0
- package/src/bots/defensive/01_Turtle.ts +151 -0
- package/src/bots/defensive/02_Sentinel.ts +134 -0
- package/src/bots/defensive/03_Golem.ts +357 -0
- package/src/bots/duelist/01_Battlemage.ts +433 -0
- package/src/bots/duelist/02_Warmage.ts +438 -0
- package/src/bots/duelist/03_Archmage.ts +588 -0
- package/src/bots/homing/01_Bonemancer.ts +67 -0
- package/src/bots/homing/02_Lich.ts +356 -0
- package/src/bots/homing/03_Archlich.ts +220 -0
- package/src/bots/index.ts +30 -0
- package/src/bots/kiter/01_Spellspinner.ts +398 -0
- package/src/bots/kiter/02_Spellweaver.ts +378 -0
- package/src/bots/kiter/03_Spellbinder.ts +448 -0
- package/src/bots/melee/01_Shadowblade.ts +270 -0
- package/src/bots/melee/02_Nightblade.ts +437 -0
- package/src/bots/melee/03_Voidblade.ts +582 -0
- package/src/bots/registry.ts +207 -0
- package/src/bots/shared.ts +472 -0
- package/src/bots/sniper/01_Spellshot.ts +385 -0
- package/src/bots/sniper/02_Spelltracer.ts +441 -0
- package/src/bots/sniper/03_Spellseeker.ts +546 -0
- package/src/bots/standalone/Critter.ts +89 -0
- package/src/bots/standalone/Doombringer.ts +91 -0
- package/src/bots/standalone/Hogger.ts +228 -0
- package/src/bots/standalone/Rookie.ts +50 -0
- package/src/bots/standalone/TargetDummy.ts +21 -0
- package/src/bots/test/cheater.ts +405 -0
- package/src/bots/test/crasher.ts +81 -0
- package/src/engine/hooks-runtime.ts +394 -0
- package/src/engine/manual-match.ts +289 -0
- package/src/engine/missile-templates.ts +155 -0
- package/src/engine/optimizer.ts +220 -0
- package/src/engine/params-runtime.ts +189 -0
- package/src/engine/physics.ts +143 -0
- package/src/engine/sandbox-browser.ts +671 -0
- package/src/engine/sandbox-compile.ts +197 -0
- package/src/engine/sandbox-harness.ts +367 -0
- package/src/engine/sandbox.ts +332 -0
- package/src/engine/simulation.ts +828 -0
- package/src/engine/spells.ts +128 -0
- package/src/engine-version.ts +11 -0
- package/src/hooks/action-builders.ts +210 -0
- package/src/hooks/bot-wrapper.ts +84 -0
- package/src/hooks/index.ts +75 -0
- package/src/hooks/state-hooks.ts +354 -0
- package/src/hooks/threat-analysis.ts +365 -0
- package/src/hooks/types.ts +142 -0
- package/src/index-browser.ts +30 -0
- package/src/index.ts +24 -0
- package/src/rules.ts +254 -0
- package/src/stats.ts +262 -0
- package/src/testing.ts +207 -0
- package/src/trace.ts +430 -0
- package/src/types.ts +193 -0
- package/src/utils/angles.ts +47 -0
- package/src/utils/combat.ts +279 -0
- package/src/utils/distance.ts +21 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/movement.ts +108 -0
- package/src/utils/random.ts +65 -0
- package/src/utils/spatial.ts +63 -0
- package/src/utils/targeting.ts +45 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER - STATE HOOKS
|
|
3
|
+
*
|
|
4
|
+
* React-style hooks for reading game state.
|
|
5
|
+
* These handle the "subconscious" perception that humans do instinctively.
|
|
6
|
+
*
|
|
7
|
+
* UNITS REFERENCE (100 ticks = 1 second):
|
|
8
|
+
* Position: absolute world coordinates, 0-800 on each axis (800×800 arena)
|
|
9
|
+
* Velocity: units per tick on each axis (player max speed = 1 u/t)
|
|
10
|
+
* Health: hit points (max 60)
|
|
11
|
+
* Ticks: game ticks (10ms each, 100/sec). Divide by 100 for seconds.
|
|
12
|
+
* Shield: block multiplier 0.0-0.9 (0.9 = blocks 90% damage)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {Position, Velocity, ProjectileState} from '../types.js';
|
|
16
|
+
import {getBotContext, useMemo} from '../engine/hooks-runtime.js';
|
|
17
|
+
import {SHIELD_MAX_BLOCK, SHIELD_DECAY_PER_SECOND, SHIELD_MIN_BLOCK, TICKS_PER_SECOND} from '../rules.js';
|
|
18
|
+
import type {EnemyState, AnalyzedThreat} from './types.js';
|
|
19
|
+
import {analyzeThreats} from './threat-analysis.js';
|
|
20
|
+
|
|
21
|
+
// ============================================================
|
|
22
|
+
// SELF STATE HOOKS
|
|
23
|
+
// ============================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get your current health (0-60). Wizard dies at 0.
|
|
27
|
+
*/
|
|
28
|
+
export function useHealth(): number
|
|
29
|
+
{
|
|
30
|
+
return getBotContext().health;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Get your current position as {x, y} in world coordinates (0-800).
|
|
35
|
+
* Position is clamped to [5, 795] (arena bounds minus wizard radius).
|
|
36
|
+
*/
|
|
37
|
+
export function usePosition(): Position
|
|
38
|
+
{
|
|
39
|
+
const ctx = getBotContext();
|
|
40
|
+
return {x: ctx.position.x, y: ctx.position.y};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get your current velocity as {x, y} in units/tick.
|
|
45
|
+
* Max magnitude is 1 u/t when idle, 0.5 u/t when casting, 0 when channeling shield.
|
|
46
|
+
*/
|
|
47
|
+
export function useVelocity(): Velocity
|
|
48
|
+
{
|
|
49
|
+
const ctx = getBotContext();
|
|
50
|
+
return {x: ctx.velocity.x, y: ctx.velocity.y};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get your current status:
|
|
55
|
+
* - 'idle': free to act
|
|
56
|
+
* - 'casting': casting a spell (missile or blink). Can move at 50% speed.
|
|
57
|
+
* - 'channeling': channeling shield. Cannot move. Cancel anytime with cancel().
|
|
58
|
+
* - 'gcd_locked': global cooldown after spell. Can move at full speed, but cannot cast.
|
|
59
|
+
*/
|
|
60
|
+
export function useStatus(): 'idle' | 'casting' | 'channeling' | 'gcd_locked'
|
|
61
|
+
{
|
|
62
|
+
return getBotContext().state;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get ticks until you can start a new spell.
|
|
67
|
+
*
|
|
68
|
+
* Returns 0 when idle or channeling (shield can be canceled immediately).
|
|
69
|
+
* During casting: remaining cast ticks. During GCD: remaining GCD ticks.
|
|
70
|
+
*
|
|
71
|
+
* Note: 100 ticks = 1 second.
|
|
72
|
+
*/
|
|
73
|
+
export function useTicksUntilReady(): number
|
|
74
|
+
{
|
|
75
|
+
const ctx = getBotContext();
|
|
76
|
+
|
|
77
|
+
switch (ctx.state)
|
|
78
|
+
{
|
|
79
|
+
case 'idle':
|
|
80
|
+
return 0;
|
|
81
|
+
case 'casting':
|
|
82
|
+
// Remaining cast time
|
|
83
|
+
if (ctx.castDuration !== undefined && ctx.castProgress !== undefined)
|
|
84
|
+
{
|
|
85
|
+
return ctx.castDuration - ctx.castProgress;
|
|
86
|
+
}
|
|
87
|
+
return 0;
|
|
88
|
+
case 'channeling':
|
|
89
|
+
// Can cancel anytime, so 0
|
|
90
|
+
return 0;
|
|
91
|
+
case 'gcd_locked':
|
|
92
|
+
return ctx.gcdRemaining ?? 0;
|
|
93
|
+
default:
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Get current shield block multiplier.
|
|
100
|
+
*
|
|
101
|
+
* Returns 0 if not channeling shield.
|
|
102
|
+
* Returns 0.3-0.9 if channeling: starts at 0.9 (blocks 90%), decays by 0.2/sec,
|
|
103
|
+
* minimum 0.3 (blocks 30%). The remaining damage gets through:
|
|
104
|
+
* actualDamage = incomingDamage × (1 - shieldStrength).
|
|
105
|
+
*/
|
|
106
|
+
export function useShieldStrength(): number
|
|
107
|
+
{
|
|
108
|
+
const ctx = getBotContext();
|
|
109
|
+
|
|
110
|
+
if (ctx.state !== 'channeling' || ctx.channelingSpell !== 'shield')
|
|
111
|
+
{
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Calculate shield strength based on channel duration
|
|
116
|
+
const channelDuration = ctx.channelDuration ?? 0;
|
|
117
|
+
const decayPerTick = SHIELD_DECAY_PER_SECOND / TICKS_PER_SECOND;
|
|
118
|
+
const strength = SHIELD_MAX_BLOCK - (channelDuration * decayPerTick);
|
|
119
|
+
return Math.max(SHIELD_MIN_BLOCK, strength);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Get blink cooldown remaining in ticks. Returns 0 if ready to blink.
|
|
124
|
+
*
|
|
125
|
+
* Cooldown scales with distance used:
|
|
126
|
+
* - 100 units → ~100 ticks (1s)
|
|
127
|
+
* - 300 units (max range) → 2000 ticks (20s)
|
|
128
|
+
*
|
|
129
|
+
* Note: 100 ticks = 1 second.
|
|
130
|
+
*/
|
|
131
|
+
export function useBlinkCooldown(): number
|
|
132
|
+
{
|
|
133
|
+
return getBotContext().blinkCooldown;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get currently casting spell, or null if not casting.
|
|
138
|
+
* Returns 'missile', 'shield', or 'blink'.
|
|
139
|
+
*/
|
|
140
|
+
export function useCastingSpell(): 'missile' | 'shield' | 'blink' | null
|
|
141
|
+
{
|
|
142
|
+
const ctx = getBotContext();
|
|
143
|
+
if (ctx.state !== 'casting')
|
|
144
|
+
{
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return ctx.castingSpell ?? null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Get cast progress as {current, total} in ticks, or null if not casting.
|
|
152
|
+
*
|
|
153
|
+
* current = ticks elapsed, total = ticks needed. When current >= total, spell fires.
|
|
154
|
+
* Note: 100 ticks = 1 second.
|
|
155
|
+
*/
|
|
156
|
+
export function useCastProgress(): {current: number; total: number} | null
|
|
157
|
+
{
|
|
158
|
+
const ctx = getBotContext();
|
|
159
|
+
if (ctx.state !== 'casting')
|
|
160
|
+
{
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
if (ctx.castProgress === undefined || ctx.castDuration === undefined)
|
|
164
|
+
{
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return {current: ctx.castProgress, total: ctx.castDuration};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================================
|
|
171
|
+
// ENEMY STATE HOOK
|
|
172
|
+
// ============================================================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get enemy wizard state.
|
|
176
|
+
*
|
|
177
|
+
* Returns position, velocity, health, status, casting spell, and shield strength.
|
|
178
|
+
* Note: you cannot see the enemy's missile configs or exact cooldown timers —
|
|
179
|
+
* only their status and what's visible on the field.
|
|
180
|
+
*/
|
|
181
|
+
export function useEnemy(): EnemyState
|
|
182
|
+
{
|
|
183
|
+
const ctx = getBotContext();
|
|
184
|
+
const enemy = ctx.enemies[0];
|
|
185
|
+
|
|
186
|
+
if (!enemy)
|
|
187
|
+
{
|
|
188
|
+
// No enemy - return default state
|
|
189
|
+
return {
|
|
190
|
+
position: {x: 0, y: 0},
|
|
191
|
+
velocity: {x: 0, y: 0},
|
|
192
|
+
health: 0,
|
|
193
|
+
status: 'idle',
|
|
194
|
+
castingSpell: null,
|
|
195
|
+
shieldStrength: 0,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Calculate enemy shield strength
|
|
200
|
+
let shieldStrength = 0;
|
|
201
|
+
if (enemy.state === 'channeling' && enemy.channelingSpell === 'shield')
|
|
202
|
+
{
|
|
203
|
+
const channelDuration = enemy.channelDuration ?? 0;
|
|
204
|
+
const decayPerTick = SHIELD_DECAY_PER_SECOND / TICKS_PER_SECOND;
|
|
205
|
+
shieldStrength = Math.max(SHIELD_MIN_BLOCK, SHIELD_MAX_BLOCK - (channelDuration * decayPerTick));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
position: {x: enemy.position.x, y: enemy.position.y},
|
|
210
|
+
velocity: {x: enemy.velocity.x, y: enemy.velocity.y},
|
|
211
|
+
health: enemy.health,
|
|
212
|
+
status: enemy.state,
|
|
213
|
+
castingSpell: enemy.state === 'casting' ? (enemy.castingSpell ?? null) : null,
|
|
214
|
+
shieldStrength,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ============================================================
|
|
219
|
+
// PROJECTILE HOOKS
|
|
220
|
+
// ============================================================
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get all your active (in-flight) projectiles.
|
|
224
|
+
* Each has position, rotation (degrees), speed (u/t), turnRate, remainingTicks.
|
|
225
|
+
*/
|
|
226
|
+
export function useMyProjectiles(): ProjectileState[]
|
|
227
|
+
{
|
|
228
|
+
return getBotContext().myProjectiles;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ============================================================
|
|
232
|
+
// COMBAT TRACKING HOOKS
|
|
233
|
+
// ============================================================
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Get total damage you've dealt this match.
|
|
237
|
+
*/
|
|
238
|
+
export function useDamageDealt(): number
|
|
239
|
+
{
|
|
240
|
+
return getBotContext().damageDealt;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get total damage you've taken this match.
|
|
245
|
+
*/
|
|
246
|
+
export function useDamageTaken(): number
|
|
247
|
+
{
|
|
248
|
+
return getBotContext().damageTaken;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get the tick number when you last took damage. Returns 0 if never hit.
|
|
253
|
+
* Compare with useTick() to get ticks since last hit.
|
|
254
|
+
*/
|
|
255
|
+
export function useLastHitTick(): number
|
|
256
|
+
{
|
|
257
|
+
return getBotContext().lastHitTick;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ============================================================
|
|
261
|
+
// ARENA HOOKS
|
|
262
|
+
// ============================================================
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Get arena dimensions. Default: {width: 800, height: 800}.
|
|
266
|
+
* Wizards are clamped to [5, 795] on each axis (radius = 5).
|
|
267
|
+
*/
|
|
268
|
+
export function useArenaSize(): {width: number; height: number}
|
|
269
|
+
{
|
|
270
|
+
const ctx = getBotContext();
|
|
271
|
+
return {width: ctx.arenaWidth, height: ctx.arenaHeight};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get current game tick (starts at 0, increments each tick).
|
|
276
|
+
* 100 ticks = 1 second. Match ends at 30,000 ticks (5 minutes).
|
|
277
|
+
*/
|
|
278
|
+
export function useTick(): number
|
|
279
|
+
{
|
|
280
|
+
return getBotContext().tick;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ============================================================
|
|
284
|
+
// THREAT ANALYSIS HOOKS
|
|
285
|
+
// ============================================================
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Get analyzed threats from all incoming enemy projectiles.
|
|
289
|
+
* Sorted by ticksToImpact (soonest first). Only includes missiles within 500 units
|
|
290
|
+
* or that are predicted to hit.
|
|
291
|
+
*
|
|
292
|
+
* Each threat includes:
|
|
293
|
+
* - ticksToImpact: ticks until hit (Infinity if will miss)
|
|
294
|
+
* - willHit: true if missile hits your current position
|
|
295
|
+
* - canDodgeLeft/Right: whether strafing perpendicular to missile heading works
|
|
296
|
+
* - canOutrun: whether moving away from missile escapes it
|
|
297
|
+
* - bestDodgeDirection: {x, y} unit vector to dodge optimally, or null if undodgeable
|
|
298
|
+
* - canBlockInTime: whether you can raise shield before impact
|
|
299
|
+
* - ticksToStartShield: when to START channeling shield to block in time
|
|
300
|
+
*/
|
|
301
|
+
export function useThreats(): AnalyzedThreat[]
|
|
302
|
+
{
|
|
303
|
+
const ctx = getBotContext();
|
|
304
|
+
const ticksUntilReady = useTicksUntilReady();
|
|
305
|
+
|
|
306
|
+
// Memoize to avoid recalculating every call
|
|
307
|
+
return useMemo(() =>
|
|
308
|
+
{
|
|
309
|
+
return analyzeThreats(
|
|
310
|
+
ctx.position,
|
|
311
|
+
ctx.projectiles,
|
|
312
|
+
ctx.myProjectiles,
|
|
313
|
+
ticksUntilReady,
|
|
314
|
+
);
|
|
315
|
+
}, [ctx.projectiles, ctx.myProjectiles, ctx.position.x, ctx.position.y, ticksUntilReady]);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Get the most imminent threat, or null if no threats.
|
|
320
|
+
* Shorthand for useThreats()[0].
|
|
321
|
+
*/
|
|
322
|
+
export function useClosestThreat(): AnalyzedThreat | null
|
|
323
|
+
{
|
|
324
|
+
const threats = useThreats();
|
|
325
|
+
return threats[0] ?? null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Get your missiles analyzed from the enemy's perspective.
|
|
330
|
+
* Useful to predict when enemy will shield/dodge your attacks.
|
|
331
|
+
*/
|
|
332
|
+
export function useMyThreatsToEnemy(): AnalyzedThreat[]
|
|
333
|
+
{
|
|
334
|
+
const ctx = getBotContext();
|
|
335
|
+
const enemy = ctx.enemies[0];
|
|
336
|
+
|
|
337
|
+
if (!enemy)
|
|
338
|
+
{
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Memoize to avoid recalculating every call
|
|
343
|
+
return useMemo(() =>
|
|
344
|
+
{
|
|
345
|
+
// Analyze from enemy's perspective
|
|
346
|
+
// Pass empty array for "enemy's own projectiles" since we're analyzing our missiles as threats TO them
|
|
347
|
+
return analyzeThreats(
|
|
348
|
+
enemy.position,
|
|
349
|
+
ctx.myProjectiles, // Our projectiles are their threats
|
|
350
|
+
[], // Enemy has no "own projectiles" in this context
|
|
351
|
+
0, // We don't know enemy's ticksUntilReady
|
|
352
|
+
);
|
|
353
|
+
}, [ctx.myProjectiles, enemy.position.x, enemy.position.y]);
|
|
354
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VIBEMANCER - THREAT ANALYSIS
|
|
3
|
+
*
|
|
4
|
+
* Pre-computes threat information for incoming projectiles.
|
|
5
|
+
* This handles the "subconscious" perception of missile trajectories.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {Position, ProjectileState} from '../types.js';
|
|
9
|
+
import {
|
|
10
|
+
COLLISION_RADIUS,
|
|
11
|
+
MISSILE_BASE_RADIUS,
|
|
12
|
+
MISSILE_RADIUS_PER_DAMAGE,
|
|
13
|
+
MOVE_SPEED,
|
|
14
|
+
SHIELD_CAST_TIME,
|
|
15
|
+
ARENA_WIDTH,
|
|
16
|
+
ARENA_HEIGHT,
|
|
17
|
+
ARENA_WATER_BUFFER,
|
|
18
|
+
} from '../rules.js';
|
|
19
|
+
import {distanceTo} from '../utils/distance.js';
|
|
20
|
+
import {angleTo, normalizeAngle, angleDiff} from '../utils/angles.js';
|
|
21
|
+
import type {AnalyzedThreat} from './types.js';
|
|
22
|
+
|
|
23
|
+
// Maximum distance at which a missile is still tracked as a potential threat
|
|
24
|
+
// (even if simulation says it will miss — homing missiles can change course)
|
|
25
|
+
const THREAT_RELEVANCE_DISTANCE = 500;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Analyze all threats from enemy projectiles.
|
|
29
|
+
*
|
|
30
|
+
* @param myPos - Current position of the wizard
|
|
31
|
+
* @param projectiles - All projectiles in the game
|
|
32
|
+
* @param myProjectiles - Only the bot's own projectiles (used for filtering)
|
|
33
|
+
* @param ticksUntilReady - Ticks until wizard can start a new action
|
|
34
|
+
* @returns Array of analyzed threats sorted by ticksToImpact (soonest first)
|
|
35
|
+
*/
|
|
36
|
+
export function analyzeThreats(
|
|
37
|
+
myPos: Position,
|
|
38
|
+
projectiles: ProjectileState[],
|
|
39
|
+
myProjectiles: ProjectileState[],
|
|
40
|
+
ticksUntilReady: number,
|
|
41
|
+
): AnalyzedThreat[]
|
|
42
|
+
{
|
|
43
|
+
// Filter to enemy projectiles by excluding our own
|
|
44
|
+
const myProjectileIds = new Set(myProjectiles.map((p) => p.id));
|
|
45
|
+
const enemyProjectiles = projectiles.filter((p) => !myProjectileIds.has(p.id));
|
|
46
|
+
|
|
47
|
+
// Analyze each projectile
|
|
48
|
+
const threats: AnalyzedThreat[] = [];
|
|
49
|
+
|
|
50
|
+
for (const projectile of enemyProjectiles)
|
|
51
|
+
{
|
|
52
|
+
const analysis = analyzeOneThreat(myPos, projectile, ticksUntilReady);
|
|
53
|
+
// Only include if it will hit or missile is within reasonable distance
|
|
54
|
+
const missileDistance = Math.sqrt(
|
|
55
|
+
(projectile.position.x - myPos.x) ** 2 +
|
|
56
|
+
(projectile.position.y - myPos.y) ** 2,
|
|
57
|
+
);
|
|
58
|
+
if (analysis.willHit || missileDistance < THREAT_RELEVANCE_DISTANCE)
|
|
59
|
+
{
|
|
60
|
+
threats.push(analysis);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Sort by ticksToImpact (soonest first)
|
|
65
|
+
threats.sort((a, b) => a.ticksToImpact - b.ticksToImpact);
|
|
66
|
+
|
|
67
|
+
return threats;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Analyze a single projectile threat.
|
|
72
|
+
*/
|
|
73
|
+
function analyzeOneThreat(
|
|
74
|
+
targetPos: Position,
|
|
75
|
+
projectile: ProjectileState,
|
|
76
|
+
ticksUntilReady: number,
|
|
77
|
+
): AnalyzedThreat
|
|
78
|
+
{
|
|
79
|
+
// Calculate collision radius
|
|
80
|
+
const missileRadius = MISSILE_BASE_RADIUS + projectile.damage * MISSILE_RADIUS_PER_DAMAGE;
|
|
81
|
+
const collisionDist = COLLISION_RADIUS + missileRadius;
|
|
82
|
+
|
|
83
|
+
// Dodge simulation uses a larger collision distance to account for swept circle collision.
|
|
84
|
+
// The real game checks the entire missile path each tick (swept), but the simulation only
|
|
85
|
+
// checks endpoints. Adding half the missile speed compensates for this gap.
|
|
86
|
+
const dodgeCollisionDist = collisionDist + projectile.speed * 0.5;
|
|
87
|
+
|
|
88
|
+
// Simulate missile trajectory toward stationary target
|
|
89
|
+
const {willHit, ticksToImpact} = simulateMissileToTarget(
|
|
90
|
+
projectile,
|
|
91
|
+
targetPos,
|
|
92
|
+
collisionDist,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
// Only simulate dodges for missiles that will actually hit (saves ~75% simulation work)
|
|
96
|
+
let canDodgeLeft = true;
|
|
97
|
+
let canDodgeRight = true;
|
|
98
|
+
let canOutrun = true;
|
|
99
|
+
let bestDodgeDirection: Position | null = null;
|
|
100
|
+
|
|
101
|
+
if (willHit)
|
|
102
|
+
{
|
|
103
|
+
canDodgeLeft = simulateDodge(projectile, targetPos, 'left', dodgeCollisionDist);
|
|
104
|
+
canDodgeRight = simulateDodge(projectile, targetPos, 'right', dodgeCollisionDist);
|
|
105
|
+
canOutrun = simulateDodge(projectile, targetPos, 'away', dodgeCollisionDist);
|
|
106
|
+
|
|
107
|
+
bestDodgeDirection = calculateBestDodgeDirection(
|
|
108
|
+
projectile,
|
|
109
|
+
targetPos,
|
|
110
|
+
canDodgeLeft,
|
|
111
|
+
canDodgeRight,
|
|
112
|
+
canOutrun,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Calculate shield timing
|
|
117
|
+
// Can block if: ticksUntilReady + shield cast time < ticksToImpact
|
|
118
|
+
const canBlockInTime = ticksToImpact > ticksUntilReady + SHIELD_CAST_TIME + 1;
|
|
119
|
+
|
|
120
|
+
// When to start casting shield (leave 1 tick buffer)
|
|
121
|
+
const ticksToStartShield = Math.max(0, ticksToImpact - SHIELD_CAST_TIME - 1);
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
id: projectile.id,
|
|
125
|
+
projectile,
|
|
126
|
+
ticksToImpact,
|
|
127
|
+
willHit,
|
|
128
|
+
canDodgeLeft,
|
|
129
|
+
canDodgeRight,
|
|
130
|
+
canOutrun,
|
|
131
|
+
bestDodgeDirection,
|
|
132
|
+
canBlockInTime,
|
|
133
|
+
ticksToStartShield,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Simulate a missile moving toward a stationary target.
|
|
139
|
+
* Accounts for homing (turn rate) and expiration.
|
|
140
|
+
*/
|
|
141
|
+
function simulateMissileToTarget(
|
|
142
|
+
projectile: ProjectileState,
|
|
143
|
+
targetPos: Position,
|
|
144
|
+
collisionDist: number,
|
|
145
|
+
): {willHit: boolean; ticksToImpact: number}
|
|
146
|
+
{
|
|
147
|
+
let pos = {...projectile.position};
|
|
148
|
+
let rotation = projectile.rotation;
|
|
149
|
+
const maxTicks = projectile.remainingTicks;
|
|
150
|
+
const speed = projectile.speed;
|
|
151
|
+
const turnRate = projectile.turnRate;
|
|
152
|
+
|
|
153
|
+
for (let tick = 1; tick <= maxTicks; tick++)
|
|
154
|
+
{
|
|
155
|
+
// Apply homing (turn toward target)
|
|
156
|
+
if (turnRate > 0)
|
|
157
|
+
{
|
|
158
|
+
const desiredAngle = angleTo(pos, targetPos);
|
|
159
|
+
const diff = angleDiff(rotation, desiredAngle);
|
|
160
|
+
const maxTurn = turnRate;
|
|
161
|
+
|
|
162
|
+
if (Math.abs(diff) <= maxTurn)
|
|
163
|
+
{
|
|
164
|
+
rotation = desiredAngle;
|
|
165
|
+
}
|
|
166
|
+
else
|
|
167
|
+
{
|
|
168
|
+
rotation = normalizeAngle(rotation + Math.sign(diff) * maxTurn);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Move forward
|
|
173
|
+
const rad = rotation * (Math.PI / 180);
|
|
174
|
+
pos = {
|
|
175
|
+
x: pos.x + Math.cos(rad) * speed,
|
|
176
|
+
y: pos.y + Math.sin(rad) * speed,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
// Check if missile is out of bounds (with water buffer)
|
|
180
|
+
if (isOutOfBounds(pos))
|
|
181
|
+
{
|
|
182
|
+
return {willHit: false, ticksToImpact: Infinity};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check collision
|
|
186
|
+
const dist = distanceTo(pos, targetPos);
|
|
187
|
+
if (dist <= collisionDist)
|
|
188
|
+
{
|
|
189
|
+
return {willHit: true, ticksToImpact: tick};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Missile expired without hitting
|
|
194
|
+
return {willHit: false, ticksToImpact: Infinity};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Simulate a dodge attempt.
|
|
199
|
+
* Returns true if the dodge avoids the missile.
|
|
200
|
+
*/
|
|
201
|
+
function simulateDodge(
|
|
202
|
+
projectile: ProjectileState,
|
|
203
|
+
startPos: Position,
|
|
204
|
+
direction: 'left' | 'right' | 'away',
|
|
205
|
+
collisionDist: number,
|
|
206
|
+
): boolean
|
|
207
|
+
{
|
|
208
|
+
// Calculate dodge direction vector
|
|
209
|
+
const dodgeDir = getDodgeDirection(projectile, startPos, direction);
|
|
210
|
+
|
|
211
|
+
// If we can't determine a dodge direction, fail
|
|
212
|
+
if (dodgeDir.x === 0 && dodgeDir.y === 0)
|
|
213
|
+
{
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
let targetPos = {...startPos};
|
|
218
|
+
let missilePos = {...projectile.position};
|
|
219
|
+
let missileRotation = projectile.rotation;
|
|
220
|
+
const maxTicks = projectile.remainingTicks;
|
|
221
|
+
const speed = projectile.speed;
|
|
222
|
+
const turnRate = projectile.turnRate;
|
|
223
|
+
|
|
224
|
+
for (let tick = 1; tick <= maxTicks; tick++)
|
|
225
|
+
{
|
|
226
|
+
// Move target
|
|
227
|
+
const newX = targetPos.x + dodgeDir.x * MOVE_SPEED;
|
|
228
|
+
const newY = targetPos.y + dodgeDir.y * MOVE_SPEED;
|
|
229
|
+
|
|
230
|
+
// Clamp to arena bounds
|
|
231
|
+
targetPos = {
|
|
232
|
+
x: Math.max(COLLISION_RADIUS, Math.min(ARENA_WIDTH - COLLISION_RADIUS, newX)),
|
|
233
|
+
y: Math.max(COLLISION_RADIUS, Math.min(ARENA_HEIGHT - COLLISION_RADIUS, newY)),
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Apply missile homing toward new target position
|
|
237
|
+
if (turnRate > 0)
|
|
238
|
+
{
|
|
239
|
+
const desiredAngle = angleTo(missilePos, targetPos);
|
|
240
|
+
const diff = angleDiff(missileRotation, desiredAngle);
|
|
241
|
+
const maxTurn = turnRate;
|
|
242
|
+
|
|
243
|
+
if (Math.abs(diff) <= maxTurn)
|
|
244
|
+
{
|
|
245
|
+
missileRotation = desiredAngle;
|
|
246
|
+
}
|
|
247
|
+
else
|
|
248
|
+
{
|
|
249
|
+
missileRotation = normalizeAngle(missileRotation + Math.sign(diff) * maxTurn);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Move missile
|
|
254
|
+
const rad = missileRotation * (Math.PI / 180);
|
|
255
|
+
missilePos = {
|
|
256
|
+
x: missilePos.x + Math.cos(rad) * speed,
|
|
257
|
+
y: missilePos.y + Math.sin(rad) * speed,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// Check if missile is out of bounds
|
|
261
|
+
if (isOutOfBounds(missilePos))
|
|
262
|
+
{
|
|
263
|
+
return true; // Dodged - missile left arena
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Check collision
|
|
267
|
+
const dist = distanceTo(missilePos, targetPos);
|
|
268
|
+
if (dist <= collisionDist)
|
|
269
|
+
{
|
|
270
|
+
return false; // Got hit while dodging
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Missile expired without hitting
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get the dodge direction vector based on direction type.
|
|
280
|
+
*/
|
|
281
|
+
function getDodgeDirection(
|
|
282
|
+
projectile: ProjectileState,
|
|
283
|
+
targetPos: Position,
|
|
284
|
+
direction: 'left' | 'right' | 'away',
|
|
285
|
+
): Position
|
|
286
|
+
{
|
|
287
|
+
// Get missile direction vector
|
|
288
|
+
const missileAngleRad = projectile.rotation * (Math.PI / 180);
|
|
289
|
+
const missileDir = {
|
|
290
|
+
x: Math.cos(missileAngleRad),
|
|
291
|
+
y: Math.sin(missileAngleRad),
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
switch (direction)
|
|
295
|
+
{
|
|
296
|
+
case 'left':
|
|
297
|
+
// Perpendicular to missile direction (left = counter-clockwise)
|
|
298
|
+
return {x: missileDir.y, y: -missileDir.x};
|
|
299
|
+
|
|
300
|
+
case 'right':
|
|
301
|
+
// Perpendicular to missile direction (right = clockwise)
|
|
302
|
+
return {x: -missileDir.y, y: missileDir.x};
|
|
303
|
+
|
|
304
|
+
case 'away':
|
|
305
|
+
{
|
|
306
|
+
// Away from missile current position
|
|
307
|
+
const dx = targetPos.x - projectile.position.x;
|
|
308
|
+
const dy = targetPos.y - projectile.position.y;
|
|
309
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
310
|
+
if (len === 0) return {x: 0, y: 0};
|
|
311
|
+
return {x: dx / len, y: dy / len};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
default:
|
|
315
|
+
return {x: 0, y: 0};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Calculate the best dodge direction based on available options.
|
|
321
|
+
*/
|
|
322
|
+
function calculateBestDodgeDirection(
|
|
323
|
+
projectile: ProjectileState,
|
|
324
|
+
targetPos: Position,
|
|
325
|
+
canDodgeLeft: boolean,
|
|
326
|
+
canDodgeRight: boolean,
|
|
327
|
+
canOutrun: boolean,
|
|
328
|
+
): Position | null
|
|
329
|
+
{
|
|
330
|
+
// Prefer sidestep over outrunning (faster result)
|
|
331
|
+
if (canDodgeLeft && canDodgeRight)
|
|
332
|
+
{
|
|
333
|
+
// Both sides work - pick the one closer to where we want to be
|
|
334
|
+
// For simplicity, just return left
|
|
335
|
+
return getDodgeDirection(projectile, targetPos, 'left');
|
|
336
|
+
}
|
|
337
|
+
else if (canDodgeLeft)
|
|
338
|
+
{
|
|
339
|
+
return getDodgeDirection(projectile, targetPos, 'left');
|
|
340
|
+
}
|
|
341
|
+
else if (canDodgeRight)
|
|
342
|
+
{
|
|
343
|
+
return getDodgeDirection(projectile, targetPos, 'right');
|
|
344
|
+
}
|
|
345
|
+
else if (canOutrun)
|
|
346
|
+
{
|
|
347
|
+
return getDodgeDirection(projectile, targetPos, 'away');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// No dodge available
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Check if a position is out of bounds (past the water buffer).
|
|
356
|
+
*/
|
|
357
|
+
function isOutOfBounds(pos: Position): boolean
|
|
358
|
+
{
|
|
359
|
+
return (
|
|
360
|
+
pos.x < -ARENA_WATER_BUFFER ||
|
|
361
|
+
pos.x > ARENA_WIDTH + ARENA_WATER_BUFFER ||
|
|
362
|
+
pos.y < -ARENA_WATER_BUFFER ||
|
|
363
|
+
pos.y > ARENA_HEIGHT + ARENA_WATER_BUFFER
|
|
364
|
+
);
|
|
365
|
+
}
|