@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.
Files changed (78) hide show
  1. package/README.md +28 -0
  2. package/dist/chunk-L7Z7OFXD.js +9140 -0
  3. package/dist/chunk-L7Z7OFXD.js.map +1 -0
  4. package/dist/index-browser.d.ts +2602 -0
  5. package/dist/index-browser.js +407 -0
  6. package/dist/index-browser.js.map +1 -0
  7. package/dist/index.d.ts +150 -0
  8. package/dist/index.js +750 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +79 -0
  11. package/src/bots/berserker/01_Stormchaser.ts +457 -0
  12. package/src/bots/berserker/02_Stormcaller.ts +417 -0
  13. package/src/bots/berserker/03_Stormforger.ts +481 -0
  14. package/src/bots/caster/01_Flamecaller.ts +286 -0
  15. package/src/bots/caster/02_Pyromancer.ts +350 -0
  16. package/src/bots/caster/03_Infernalist.ts +492 -0
  17. package/src/bots/defensive/01_Turtle.ts +151 -0
  18. package/src/bots/defensive/02_Sentinel.ts +134 -0
  19. package/src/bots/defensive/03_Golem.ts +357 -0
  20. package/src/bots/duelist/01_Battlemage.ts +433 -0
  21. package/src/bots/duelist/02_Warmage.ts +438 -0
  22. package/src/bots/duelist/03_Archmage.ts +588 -0
  23. package/src/bots/homing/01_Bonemancer.ts +67 -0
  24. package/src/bots/homing/02_Lich.ts +356 -0
  25. package/src/bots/homing/03_Archlich.ts +220 -0
  26. package/src/bots/index.ts +30 -0
  27. package/src/bots/kiter/01_Spellspinner.ts +398 -0
  28. package/src/bots/kiter/02_Spellweaver.ts +378 -0
  29. package/src/bots/kiter/03_Spellbinder.ts +448 -0
  30. package/src/bots/melee/01_Shadowblade.ts +270 -0
  31. package/src/bots/melee/02_Nightblade.ts +437 -0
  32. package/src/bots/melee/03_Voidblade.ts +582 -0
  33. package/src/bots/registry.ts +207 -0
  34. package/src/bots/shared.ts +472 -0
  35. package/src/bots/sniper/01_Spellshot.ts +385 -0
  36. package/src/bots/sniper/02_Spelltracer.ts +441 -0
  37. package/src/bots/sniper/03_Spellseeker.ts +546 -0
  38. package/src/bots/standalone/Critter.ts +89 -0
  39. package/src/bots/standalone/Doombringer.ts +91 -0
  40. package/src/bots/standalone/Hogger.ts +228 -0
  41. package/src/bots/standalone/Rookie.ts +50 -0
  42. package/src/bots/standalone/TargetDummy.ts +21 -0
  43. package/src/bots/test/cheater.ts +405 -0
  44. package/src/bots/test/crasher.ts +81 -0
  45. package/src/engine/hooks-runtime.ts +394 -0
  46. package/src/engine/manual-match.ts +289 -0
  47. package/src/engine/missile-templates.ts +155 -0
  48. package/src/engine/optimizer.ts +220 -0
  49. package/src/engine/params-runtime.ts +189 -0
  50. package/src/engine/physics.ts +143 -0
  51. package/src/engine/sandbox-browser.ts +671 -0
  52. package/src/engine/sandbox-compile.ts +197 -0
  53. package/src/engine/sandbox-harness.ts +367 -0
  54. package/src/engine/sandbox.ts +332 -0
  55. package/src/engine/simulation.ts +828 -0
  56. package/src/engine/spells.ts +128 -0
  57. package/src/engine-version.ts +11 -0
  58. package/src/hooks/action-builders.ts +210 -0
  59. package/src/hooks/bot-wrapper.ts +84 -0
  60. package/src/hooks/index.ts +75 -0
  61. package/src/hooks/state-hooks.ts +354 -0
  62. package/src/hooks/threat-analysis.ts +365 -0
  63. package/src/hooks/types.ts +142 -0
  64. package/src/index-browser.ts +30 -0
  65. package/src/index.ts +24 -0
  66. package/src/rules.ts +254 -0
  67. package/src/stats.ts +262 -0
  68. package/src/testing.ts +207 -0
  69. package/src/trace.ts +430 -0
  70. package/src/types.ts +193 -0
  71. package/src/utils/angles.ts +47 -0
  72. package/src/utils/combat.ts +279 -0
  73. package/src/utils/distance.ts +21 -0
  74. package/src/utils/index.ts +7 -0
  75. package/src/utils/movement.ts +108 -0
  76. package/src/utils/random.ts +65 -0
  77. package/src/utils/spatial.ts +63 -0
  78. 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
+ }