@vibemancer/core 0.1.0 → 0.1.2

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 (50) hide show
  1. package/README.md +28 -28
  2. package/dist/{chunk-L7Z7OFXD.js → chunk-OL7ETV6V.js} +3 -3
  3. package/dist/chunk-OL7ETV6V.js.map +1 -0
  4. package/dist/index-browser.d.ts +1 -1
  5. package/dist/index-browser.js +1 -1
  6. package/dist/index.js +1 -1
  7. package/package.json +79 -78
  8. package/src/bots/berserker/01_Stormchaser.ts +457 -457
  9. package/src/bots/berserker/02_Stormcaller.ts +417 -417
  10. package/src/bots/berserker/03_Stormforger.ts +481 -481
  11. package/src/bots/caster/01_Flamecaller.ts +286 -286
  12. package/src/bots/caster/02_Pyromancer.ts +350 -350
  13. package/src/bots/caster/03_Infernalist.ts +492 -492
  14. package/src/bots/defensive/01_Turtle.ts +151 -151
  15. package/src/bots/defensive/02_Sentinel.ts +134 -134
  16. package/src/bots/defensive/03_Golem.ts +357 -357
  17. package/src/bots/duelist/01_Battlemage.ts +433 -433
  18. package/src/bots/duelist/02_Warmage.ts +438 -438
  19. package/src/bots/duelist/03_Archmage.ts +588 -588
  20. package/src/bots/homing/01_Bonemancer.ts +67 -67
  21. package/src/bots/homing/02_Lich.ts +356 -356
  22. package/src/bots/homing/03_Archlich.ts +220 -220
  23. package/src/bots/kiter/01_Spellspinner.ts +398 -398
  24. package/src/bots/kiter/02_Spellweaver.ts +378 -378
  25. package/src/bots/kiter/03_Spellbinder.ts +448 -448
  26. package/src/bots/melee/01_Shadowblade.ts +270 -270
  27. package/src/bots/melee/02_Nightblade.ts +437 -437
  28. package/src/bots/melee/03_Voidblade.ts +582 -582
  29. package/src/bots/sniper/01_Spellshot.ts +385 -385
  30. package/src/bots/sniper/02_Spelltracer.ts +441 -441
  31. package/src/bots/sniper/03_Spellseeker.ts +546 -546
  32. package/src/bots/standalone/Critter.ts +89 -89
  33. package/src/bots/standalone/Doombringer.ts +91 -91
  34. package/src/bots/standalone/Hogger.ts +228 -228
  35. package/src/bots/standalone/Rookie.ts +50 -50
  36. package/src/bots/standalone/TargetDummy.ts +21 -21
  37. package/src/bots/test/cheater.ts +405 -405
  38. package/src/bots/test/crasher.ts +81 -81
  39. package/src/engine/hooks-runtime.ts +394 -394
  40. package/src/engine/manual-match.ts +289 -289
  41. package/src/engine/missile-templates.ts +155 -155
  42. package/src/engine/physics.ts +143 -143
  43. package/src/engine/simulation.ts +828 -828
  44. package/src/engine/spells.ts +128 -128
  45. package/src/engine-version.ts +1 -1
  46. package/src/index.ts +23 -23
  47. package/src/rules.ts +254 -254
  48. package/src/types.ts +193 -193
  49. package/src/utils/index.ts +6 -6
  50. package/dist/chunk-L7Z7OFXD.js.map +0 -1
@@ -1,155 +1,155 @@
1
- /**
2
- * VIBEMANCER - MISSILE TEMPLATES
3
- *
4
- * Reusable missile config + AI factories for manual play and bot examples.
5
- * Each factory clamps its inputs to rules.ts minimums and returns a pure
6
- * AI function that's safe to register with the engine.
7
- */
8
-
9
- import {
10
- MISSILE_MIN_DAMAGE,
11
- MISSILE_MIN_SPEED,
12
- MISSILE_MIN_DURATION,
13
- } from '../rules.js';
14
- import type {MissileAIFunction, MissileConfig} from '../types.js';
15
-
16
- interface BaseParams
17
- {
18
- damage: number;
19
- speed: number;
20
- duration: number;
21
- }
22
-
23
- interface TurningParams extends BaseParams
24
- {
25
- turnRate: number;
26
- }
27
-
28
- export type StraightParams = BaseParams;
29
-
30
- export type HomingParams = TurningParams;
31
-
32
- export type AntiHomingParams = TurningParams;
33
-
34
- export interface SpiralParams extends BaseParams
35
- {
36
- spiralRadius: number;
37
- spiralFreq: number;
38
- }
39
-
40
- export interface SeekerParams extends TurningParams
41
- {
42
- minLockDistance: number;
43
- }
44
-
45
- export interface MissileTemplate
46
- {
47
- config: MissileConfig;
48
- ai: MissileAIFunction;
49
- }
50
-
51
- function clampBase(p: BaseParams): {damage: number; speed: number; duration: number}
52
- {
53
- return {
54
- damage: Math.max(MISSILE_MIN_DAMAGE, p.damage),
55
- speed: Math.max(MISSILE_MIN_SPEED, p.speed),
56
- duration: Math.max(MISSILE_MIN_DURATION, Math.floor(p.duration)),
57
- };
58
- }
59
-
60
- /**
61
- * Fire-and-forget missile. No steering — flies in a straight line.
62
- */
63
- export function straightMissile(p: StraightParams): MissileTemplate
64
- {
65
- const base = clampBase(p);
66
- const config: MissileConfig = {...base, turnRate: 0};
67
- const ai: MissileAIFunction = () => ({});
68
- return {config, ai};
69
- }
70
-
71
- /**
72
- * Homing missile. Steers toward the enemy each tick.
73
- */
74
- export function homingMissile(p: HomingParams): MissileTemplate
75
- {
76
- const base = clampBase(p);
77
- const config: MissileConfig = {...base, turnRate: p.turnRate};
78
- const ai: MissileAIFunction = ({worldState}) =>
79
- {
80
- const enemy = worldState.enemies[0];
81
- if (!enemy) return {};
82
- return {turnToward: enemy.position};
83
- };
84
- return {config, ai};
85
- }
86
-
87
- /**
88
- * Anti-homing missile. Steers AWAY from the enemy — useful as a feint or
89
- * area-denial pattern.
90
- */
91
- export function antiHomingMissile(p: AntiHomingParams): MissileTemplate
92
- {
93
- const base = clampBase(p);
94
- const config: MissileConfig = {...base, turnRate: p.turnRate};
95
- const ai: MissileAIFunction = ({missileState, worldState}) =>
96
- {
97
- const enemy = worldState.enemies[0];
98
- if (!enemy) return {};
99
- // Reflect the enemy position through the missile to get an "away" target.
100
- const target = {
101
- x: missileState.position.x * 2 - enemy.position.x,
102
- y: missileState.position.y * 2 - enemy.position.y,
103
- };
104
- return {turnToward: target};
105
- };
106
- return {config, ai};
107
- }
108
-
109
- /**
110
- * Spiral missile. Continuously orbits its current heading while advancing.
111
- * Uses missileState.remainingTicks as a deterministic phase counter so the
112
- * AI is fully pure (no closure state).
113
- */
114
- export function spiralMissile(p: SpiralParams): MissileTemplate
115
- {
116
- const base = clampBase(p);
117
- // turnRate must be high enough for the spiral to manifest visibly
118
- const config: MissileConfig = {...base, turnRate: 12};
119
- const radius = Math.max(1, p.spiralRadius);
120
- const freq = p.spiralFreq;
121
- const ai: MissileAIFunction = ({missileState}) =>
122
- {
123
- const phase = -missileState.remainingTicks * freq;
124
- const target = {
125
- x: missileState.position.x + Math.cos(phase) * radius,
126
- y: missileState.position.y + Math.sin(phase) * radius,
127
- };
128
- return {turnToward: target};
129
- };
130
- return {config, ai};
131
- }
132
-
133
- /**
134
- * Seeker missile. Homes toward the enemy, but only when farther away than
135
- * minLockDistance — closer than that, the missile coasts straight (so it
136
- * doesn't whirl around a target it's about to hit).
137
- */
138
- export function seekerMissile(p: SeekerParams): MissileTemplate
139
- {
140
- const base = clampBase(p);
141
- const config: MissileConfig = {...base, turnRate: p.turnRate};
142
- const minDist = Math.max(0, p.minLockDistance);
143
- const minDistSq = minDist * minDist;
144
- const ai: MissileAIFunction = ({missileState, worldState}) =>
145
- {
146
- const enemy = worldState.enemies[0];
147
- if (!enemy) return {};
148
- const dx = enemy.position.x - missileState.position.x;
149
- const dy = enemy.position.y - missileState.position.y;
150
- const distSq = dx * dx + dy * dy;
151
- if (distSq < minDistSq) return {};
152
- return {turnToward: enemy.position};
153
- };
154
- return {config, ai};
155
- }
1
+ /**
2
+ * VIBEMANCER - MISSILE TEMPLATES
3
+ *
4
+ * Reusable missile config + AI factories for manual play and bot examples.
5
+ * Each factory clamps its inputs to rules.ts minimums and returns a pure
6
+ * AI function that's safe to register with the engine.
7
+ */
8
+
9
+ import {
10
+ MISSILE_MIN_DAMAGE,
11
+ MISSILE_MIN_SPEED,
12
+ MISSILE_MIN_DURATION,
13
+ } from '../rules.js';
14
+ import type {MissileAIFunction, MissileConfig} from '../types.js';
15
+
16
+ interface BaseParams
17
+ {
18
+ damage: number;
19
+ speed: number;
20
+ duration: number;
21
+ }
22
+
23
+ interface TurningParams extends BaseParams
24
+ {
25
+ turnRate: number;
26
+ }
27
+
28
+ export type StraightParams = BaseParams;
29
+
30
+ export type HomingParams = TurningParams;
31
+
32
+ export type AntiHomingParams = TurningParams;
33
+
34
+ export interface SpiralParams extends BaseParams
35
+ {
36
+ spiralRadius: number;
37
+ spiralFreq: number;
38
+ }
39
+
40
+ export interface SeekerParams extends TurningParams
41
+ {
42
+ minLockDistance: number;
43
+ }
44
+
45
+ export interface MissileTemplate
46
+ {
47
+ config: MissileConfig;
48
+ ai: MissileAIFunction;
49
+ }
50
+
51
+ function clampBase(p: BaseParams): {damage: number; speed: number; duration: number}
52
+ {
53
+ return {
54
+ damage: Math.max(MISSILE_MIN_DAMAGE, p.damage),
55
+ speed: Math.max(MISSILE_MIN_SPEED, p.speed),
56
+ duration: Math.max(MISSILE_MIN_DURATION, Math.floor(p.duration)),
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Fire-and-forget missile. No steering — flies in a straight line.
62
+ */
63
+ export function straightMissile(p: StraightParams): MissileTemplate
64
+ {
65
+ const base = clampBase(p);
66
+ const config: MissileConfig = {...base, turnRate: 0};
67
+ const ai: MissileAIFunction = () => ({});
68
+ return {config, ai};
69
+ }
70
+
71
+ /**
72
+ * Homing missile. Steers toward the enemy each tick.
73
+ */
74
+ export function homingMissile(p: HomingParams): MissileTemplate
75
+ {
76
+ const base = clampBase(p);
77
+ const config: MissileConfig = {...base, turnRate: p.turnRate};
78
+ const ai: MissileAIFunction = ({worldState}) =>
79
+ {
80
+ const enemy = worldState.enemies[0];
81
+ if (!enemy) return {};
82
+ return {turnToward: enemy.position};
83
+ };
84
+ return {config, ai};
85
+ }
86
+
87
+ /**
88
+ * Anti-homing missile. Steers AWAY from the enemy — useful as a feint or
89
+ * area-denial pattern.
90
+ */
91
+ export function antiHomingMissile(p: AntiHomingParams): MissileTemplate
92
+ {
93
+ const base = clampBase(p);
94
+ const config: MissileConfig = {...base, turnRate: p.turnRate};
95
+ const ai: MissileAIFunction = ({missileState, worldState}) =>
96
+ {
97
+ const enemy = worldState.enemies[0];
98
+ if (!enemy) return {};
99
+ // Reflect the enemy position through the missile to get an "away" target.
100
+ const target = {
101
+ x: missileState.position.x * 2 - enemy.position.x,
102
+ y: missileState.position.y * 2 - enemy.position.y,
103
+ };
104
+ return {turnToward: target};
105
+ };
106
+ return {config, ai};
107
+ }
108
+
109
+ /**
110
+ * Spiral missile. Continuously orbits its current heading while advancing.
111
+ * Uses missileState.remainingTicks as a deterministic phase counter so the
112
+ * AI is fully pure (no closure state).
113
+ */
114
+ export function spiralMissile(p: SpiralParams): MissileTemplate
115
+ {
116
+ const base = clampBase(p);
117
+ // turnRate must be high enough for the spiral to manifest visibly
118
+ const config: MissileConfig = {...base, turnRate: 12};
119
+ const radius = Math.max(1, p.spiralRadius);
120
+ const freq = p.spiralFreq;
121
+ const ai: MissileAIFunction = ({missileState}) =>
122
+ {
123
+ const phase = -missileState.remainingTicks * freq;
124
+ const target = {
125
+ x: missileState.position.x + Math.cos(phase) * radius,
126
+ y: missileState.position.y + Math.sin(phase) * radius,
127
+ };
128
+ return {turnToward: target};
129
+ };
130
+ return {config, ai};
131
+ }
132
+
133
+ /**
134
+ * Seeker missile. Homes toward the enemy, but only when farther away than
135
+ * minLockDistance — closer than that, the missile coasts straight (so it
136
+ * doesn't whirl around a target it's about to hit).
137
+ */
138
+ export function seekerMissile(p: SeekerParams): MissileTemplate
139
+ {
140
+ const base = clampBase(p);
141
+ const config: MissileConfig = {...base, turnRate: p.turnRate};
142
+ const minDist = Math.max(0, p.minLockDistance);
143
+ const minDistSq = minDist * minDist;
144
+ const ai: MissileAIFunction = ({missileState, worldState}) =>
145
+ {
146
+ const enemy = worldState.enemies[0];
147
+ if (!enemy) return {};
148
+ const dx = enemy.position.x - missileState.position.x;
149
+ const dy = enemy.position.y - missileState.position.y;
150
+ const distSq = dx * dx + dy * dy;
151
+ if (distSq < minDistSq) return {};
152
+ return {turnToward: enemy.position};
153
+ };
154
+ return {config, ai};
155
+ }
@@ -1,143 +1,143 @@
1
- import {Position, WizardState, ProjectileState} from '../types.js';
2
- import {ARENA_WIDTH, ARENA_HEIGHT, MOVEMENT_SPEED, CASTING_MOVEMENT_MULT, WIZARD_RADIUS} from '../rules.js';
3
-
4
- /**
5
- * Move a wizard based on world-space input direction.
6
- *
7
- * Movement model (from design doc):
8
- * - x: +100 = right, -100 = left
9
- * - y: +100 = down, -100 = up
10
- * - Values are clamped to [-100, 100]
11
- * - Diagonal movement is normalized (magnitude capped at 100)
12
- * - No rotation tracking - just output (x, y) direction
13
- */
14
- export function moveWizard(wizard: WizardState, move: {x: number; y: number}, deltaTicks: number): Position
15
- {
16
- // Clamp input values to [-100, 100]
17
- let dx = Math.max(-100, Math.min(100, move.x || 0));
18
- let dy = Math.max(-100, Math.min(100, move.y || 0));
19
-
20
- // Normalize diagonal movement (cap magnitude at 100)
21
- const magnitude = Math.sqrt(dx * dx + dy * dy);
22
- if (magnitude > 100)
23
- {
24
- dx = dx * 100 / magnitude;
25
- dy = dy * 100 / magnitude;
26
- }
27
-
28
- // Convert from [-100, 100] to [-1, 1] direction
29
- dx = dx / 100;
30
- dy = dy / 100;
31
-
32
- let speed = MOVEMENT_SPEED;
33
- if (wizard.state === 'casting')
34
- {
35
- speed *= CASTING_MOVEMENT_MULT;
36
- }
37
- else if (wizard.state === 'channeling')
38
- {
39
- // Movement keys ignored during channeling (design doc line 197, 1120)
40
- speed = 0;
41
- }
42
-
43
- const newPos = {
44
- x: wizard.position.x + dx * speed * deltaTicks,
45
- y: wizard.position.y + dy * speed * deltaTicks,
46
- };
47
-
48
- return clampToArena(newPos, WIZARD_RADIUS);
49
- }
50
-
51
- /**
52
- * Move a projectile in its current rotation direction.
53
- */
54
- export function moveProjectile(projectile: ProjectileState, deltaTicks: number): Position
55
- {
56
- const radians = projectile.rotation * (Math.PI / 180);
57
- return {
58
- x: projectile.position.x + Math.cos(radians) * projectile.speed * deltaTicks,
59
- y: projectile.position.y + Math.sin(radians) * projectile.speed * deltaTicks,
60
- };
61
- }
62
-
63
- /**
64
- * Clamp a position to the arena boundaries.
65
- */
66
- export function clampToArena(position: Position, radius: number): Position
67
- {
68
- return {
69
- x: Math.max(radius, Math.min(ARENA_WIDTH - radius, position.x)),
70
- y: Math.max(radius, Math.min(ARENA_HEIGHT - radius, position.y)),
71
- };
72
- }
73
-
74
- /**
75
- * Resolve body collision between two wizards.
76
- * Pushes both apart equally so they don't overlap. Neither is blocked — they just can't stack.
77
- * Iterates until stable: wizard push → wall clamp → re-check overlap → repeat.
78
- */
79
- export function resolveWizardCollision(wizard1: WizardState, wizard2: WizardState): void
80
- {
81
- const minDist = WIZARD_RADIUS * 2;
82
- const maxIterations = 5; // Prevents infinite loops in edge cases
83
-
84
- for (let i = 0; i < maxIterations; i++)
85
- {
86
- const dx = wizard2.position.x - wizard1.position.x;
87
- const dy = wizard2.position.y - wizard1.position.y;
88
- const dist = Math.sqrt(dx * dx + dy * dy);
89
-
90
- if (dist >= minDist) return; // No overlap — done
91
-
92
- if (dist === 0)
93
- {
94
- // Exactly overlapping — push apart along arbitrary axis
95
- wizard1.position = {x: wizard1.position.x - WIZARD_RADIUS, y: wizard1.position.y};
96
- wizard2.position = {x: wizard2.position.x + WIZARD_RADIUS, y: wizard2.position.y};
97
- }
98
- else
99
- {
100
- const overlap = minDist - dist;
101
- const pushX = (dx / dist) * (overlap / 2);
102
- const pushY = (dy / dist) * (overlap / 2);
103
-
104
- wizard1.position = {x: wizard1.position.x - pushX, y: wizard1.position.y - pushY};
105
- wizard2.position = {x: wizard2.position.x + pushX, y: wizard2.position.y + pushY};
106
- }
107
-
108
- // Clamp both to arena — this may re-introduce overlap if one is pushed into a wall
109
- wizard1.position = clampToArena(wizard1.position, WIZARD_RADIUS);
110
- wizard2.position = clampToArena(wizard2.position, WIZARD_RADIUS);
111
- }
112
- }
113
-
114
- /**
115
- * Perform swept circle collision detection between a moving point (projectile) and a stationary circle (wizard).
116
- * Returns true if a collision occurred during the movement from oldPos to newPos.
117
- */
118
- export function sweptCircleCollision(oldPos: Position, newPos: Position, radius: number, targetPos: Position, targetRadius: number): boolean
119
- {
120
- // Distance from point to line segment
121
- const totalRadius = radius + targetRadius;
122
-
123
- // Vector from oldPos to newPos
124
- const dx = newPos.x - oldPos.x;
125
- const dy = newPos.y - oldPos.y;
126
- const d2 = dx * dx + dy * dy;
127
-
128
- if (d2 === 0)
129
- {
130
- const dist2 = (oldPos.x - targetPos.x) ** 2 + (oldPos.y - targetPos.y) ** 2;
131
- return dist2 <= totalRadius * totalRadius;
132
- }
133
-
134
- // Projection of targetPos onto the line segment (oldPos, newPos)
135
- let t = ((targetPos.x - oldPos.x) * dx + (targetPos.y - oldPos.y) * dy) / d2;
136
- t = Math.max(0, Math.min(1, t));
137
-
138
- const closestX = oldPos.x + t * dx;
139
- const closestY = oldPos.y + t * dy;
140
-
141
- const dist2 = (targetPos.x - closestX) ** 2 + (targetPos.y - closestY) ** 2;
142
- return dist2 <= totalRadius * totalRadius;
143
- }
1
+ import {Position, WizardState, ProjectileState} from '../types.js';
2
+ import {ARENA_WIDTH, ARENA_HEIGHT, MOVEMENT_SPEED, CASTING_MOVEMENT_MULT, WIZARD_RADIUS} from '../rules.js';
3
+
4
+ /**
5
+ * Move a wizard based on world-space input direction.
6
+ *
7
+ * Movement model (from design doc):
8
+ * - x: +100 = right, -100 = left
9
+ * - y: +100 = down, -100 = up
10
+ * - Values are clamped to [-100, 100]
11
+ * - Diagonal movement is normalized (magnitude capped at 100)
12
+ * - No rotation tracking - just output (x, y) direction
13
+ */
14
+ export function moveWizard(wizard: WizardState, move: {x: number; y: number}, deltaTicks: number): Position
15
+ {
16
+ // Clamp input values to [-100, 100]
17
+ let dx = Math.max(-100, Math.min(100, move.x || 0));
18
+ let dy = Math.max(-100, Math.min(100, move.y || 0));
19
+
20
+ // Normalize diagonal movement (cap magnitude at 100)
21
+ const magnitude = Math.sqrt(dx * dx + dy * dy);
22
+ if (magnitude > 100)
23
+ {
24
+ dx = dx * 100 / magnitude;
25
+ dy = dy * 100 / magnitude;
26
+ }
27
+
28
+ // Convert from [-100, 100] to [-1, 1] direction
29
+ dx = dx / 100;
30
+ dy = dy / 100;
31
+
32
+ let speed = MOVEMENT_SPEED;
33
+ if (wizard.state === 'casting')
34
+ {
35
+ speed *= CASTING_MOVEMENT_MULT;
36
+ }
37
+ else if (wizard.state === 'channeling')
38
+ {
39
+ // Movement keys ignored during channeling (design doc line 197, 1120)
40
+ speed = 0;
41
+ }
42
+
43
+ const newPos = {
44
+ x: wizard.position.x + dx * speed * deltaTicks,
45
+ y: wizard.position.y + dy * speed * deltaTicks,
46
+ };
47
+
48
+ return clampToArena(newPos, WIZARD_RADIUS);
49
+ }
50
+
51
+ /**
52
+ * Move a projectile in its current rotation direction.
53
+ */
54
+ export function moveProjectile(projectile: ProjectileState, deltaTicks: number): Position
55
+ {
56
+ const radians = projectile.rotation * (Math.PI / 180);
57
+ return {
58
+ x: projectile.position.x + Math.cos(radians) * projectile.speed * deltaTicks,
59
+ y: projectile.position.y + Math.sin(radians) * projectile.speed * deltaTicks,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Clamp a position to the arena boundaries.
65
+ */
66
+ export function clampToArena(position: Position, radius: number): Position
67
+ {
68
+ return {
69
+ x: Math.max(radius, Math.min(ARENA_WIDTH - radius, position.x)),
70
+ y: Math.max(radius, Math.min(ARENA_HEIGHT - radius, position.y)),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Resolve body collision between two wizards.
76
+ * Pushes both apart equally so they don't overlap. Neither is blocked — they just can't stack.
77
+ * Iterates until stable: wizard push → wall clamp → re-check overlap → repeat.
78
+ */
79
+ export function resolveWizardCollision(wizard1: WizardState, wizard2: WizardState): void
80
+ {
81
+ const minDist = WIZARD_RADIUS * 2;
82
+ const maxIterations = 5; // Prevents infinite loops in edge cases
83
+
84
+ for (let i = 0; i < maxIterations; i++)
85
+ {
86
+ const dx = wizard2.position.x - wizard1.position.x;
87
+ const dy = wizard2.position.y - wizard1.position.y;
88
+ const dist = Math.sqrt(dx * dx + dy * dy);
89
+
90
+ if (dist >= minDist) return; // No overlap — done
91
+
92
+ if (dist === 0)
93
+ {
94
+ // Exactly overlapping — push apart along arbitrary axis
95
+ wizard1.position = {x: wizard1.position.x - WIZARD_RADIUS, y: wizard1.position.y};
96
+ wizard2.position = {x: wizard2.position.x + WIZARD_RADIUS, y: wizard2.position.y};
97
+ }
98
+ else
99
+ {
100
+ const overlap = minDist - dist;
101
+ const pushX = (dx / dist) * (overlap / 2);
102
+ const pushY = (dy / dist) * (overlap / 2);
103
+
104
+ wizard1.position = {x: wizard1.position.x - pushX, y: wizard1.position.y - pushY};
105
+ wizard2.position = {x: wizard2.position.x + pushX, y: wizard2.position.y + pushY};
106
+ }
107
+
108
+ // Clamp both to arena — this may re-introduce overlap if one is pushed into a wall
109
+ wizard1.position = clampToArena(wizard1.position, WIZARD_RADIUS);
110
+ wizard2.position = clampToArena(wizard2.position, WIZARD_RADIUS);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Perform swept circle collision detection between a moving point (projectile) and a stationary circle (wizard).
116
+ * Returns true if a collision occurred during the movement from oldPos to newPos.
117
+ */
118
+ export function sweptCircleCollision(oldPos: Position, newPos: Position, radius: number, targetPos: Position, targetRadius: number): boolean
119
+ {
120
+ // Distance from point to line segment
121
+ const totalRadius = radius + targetRadius;
122
+
123
+ // Vector from oldPos to newPos
124
+ const dx = newPos.x - oldPos.x;
125
+ const dy = newPos.y - oldPos.y;
126
+ const d2 = dx * dx + dy * dy;
127
+
128
+ if (d2 === 0)
129
+ {
130
+ const dist2 = (oldPos.x - targetPos.x) ** 2 + (oldPos.y - targetPos.y) ** 2;
131
+ return dist2 <= totalRadius * totalRadius;
132
+ }
133
+
134
+ // Projection of targetPos onto the line segment (oldPos, newPos)
135
+ let t = ((targetPos.x - oldPos.x) * dx + (targetPos.y - oldPos.y) * dy) / d2;
136
+ t = Math.max(0, Math.min(1, t));
137
+
138
+ const closestX = oldPos.x + t * dx;
139
+ const closestY = oldPos.y + t * dy;
140
+
141
+ const dist2 = (targetPos.x - closestX) ** 2 + (targetPos.y - closestY) ** 2;
142
+ return dist2 <= totalRadius * totalRadius;
143
+ }