@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.
- package/README.md +28 -28
- package/dist/{chunk-L7Z7OFXD.js → chunk-OL7ETV6V.js} +3 -3
- package/dist/chunk-OL7ETV6V.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,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
|
+
}
|
package/src/engine/physics.ts
CHANGED
|
@@ -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
|
+
}
|