@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,398 +1,398 @@
|
|
|
1
|
-
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
-
import {angleTo} from '../../utils/angles.js';
|
|
3
|
-
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
-
import {getMissileCastTime} from '../../utils/combat.js';
|
|
5
|
-
import {ARENA_SIZE, GCD_DURATION} from '../../rules.js';
|
|
6
|
-
import {
|
|
7
|
-
getThreats,
|
|
8
|
-
getMostUrgentThreat,
|
|
9
|
-
getDodgeDirectionForMovement,
|
|
10
|
-
isSafeToAttack,
|
|
11
|
-
getBlinkToCenter,
|
|
12
|
-
getBlinkToIncreaseDistance,
|
|
13
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
14
|
-
shouldForceShield,
|
|
15
|
-
} from '../shared.js';
|
|
16
|
-
import {useParam} from '../../engine/params-runtime.js';
|
|
17
|
-
const WALL_BUFFER = 60;
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Standard homing missile: higher damage, good range. s=5 × dur=150 = 750u range.
|
|
21
|
-
*/
|
|
22
|
-
const STANDARD_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Quick homing missile for firing under pressure.
|
|
26
|
-
* Similar speed/turn for warmup compatibility.
|
|
27
|
-
*/
|
|
28
|
-
const QUICK_CONFIG = {damage: 10, speed: 5, turnRate: 1, duration: 80};
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Standard homing missile AI.
|
|
32
|
-
*/
|
|
33
|
-
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
34
|
-
{
|
|
35
|
-
const enemy = worldState.enemies[0];
|
|
36
|
-
return {
|
|
37
|
-
turnToward: enemy?.position,
|
|
38
|
-
};
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Check if near wall.
|
|
43
|
-
*/
|
|
44
|
-
function isNearWall(position: {x: number; y: number}): boolean
|
|
45
|
-
{
|
|
46
|
-
return (
|
|
47
|
-
position.x < WALL_BUFFER ||
|
|
48
|
-
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
49
|
-
position.y < WALL_BUFFER ||
|
|
50
|
-
position.y > ARENA_SIZE - WALL_BUFFER
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Smart strafe with threat-aware dodging and wall avoidance.
|
|
56
|
-
*/
|
|
57
|
-
function smartStrafe(
|
|
58
|
-
position: {x: number; y: number},
|
|
59
|
-
enemy: {position: {x: number; y: number}},
|
|
60
|
-
dodgeDirection: {x: number; y: number} | null,
|
|
61
|
-
tick: number,
|
|
62
|
-
strafeCyclePeriod: number,
|
|
63
|
-
strafeIntensity: number,
|
|
64
|
-
): {x: number; y: number}
|
|
65
|
-
{
|
|
66
|
-
const deltaX = enemy.position.x - position.x;
|
|
67
|
-
const deltaY = enemy.position.y - position.y;
|
|
68
|
-
const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
69
|
-
if (normalizedDistance === 0) return {x: 0, y: 0};
|
|
70
|
-
|
|
71
|
-
// Perpendicular directions
|
|
72
|
-
const strafeClockwise = {
|
|
73
|
-
x: -deltaY / normalizedDistance,
|
|
74
|
-
y: deltaX / normalizedDistance,
|
|
75
|
-
};
|
|
76
|
-
const strafeCounterClockwise = {
|
|
77
|
-
x: deltaY / normalizedDistance,
|
|
78
|
-
y: -deltaX / normalizedDistance,
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
let strafeDir: {x: number; y: number};
|
|
82
|
-
|
|
83
|
-
// Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
|
|
84
|
-
if (dodgeDirection)
|
|
85
|
-
{
|
|
86
|
-
strafeDir = dodgeDirection;
|
|
87
|
-
}
|
|
88
|
-
// Priority 2: Avoid walls
|
|
89
|
-
else if (isNearWall(position))
|
|
90
|
-
{
|
|
91
|
-
const futureClockwise = {
|
|
92
|
-
x: position.x + strafeClockwise.x * 100,
|
|
93
|
-
y: position.y + strafeClockwise.y * 100,
|
|
94
|
-
};
|
|
95
|
-
const futureCounterClockwise = {
|
|
96
|
-
x: position.x + strafeCounterClockwise.x * 100,
|
|
97
|
-
y: position.y + strafeCounterClockwise.y * 100,
|
|
98
|
-
};
|
|
99
|
-
const scoreClockwise = Math.min(
|
|
100
|
-
futureClockwise.x,
|
|
101
|
-
ARENA_SIZE - futureClockwise.x,
|
|
102
|
-
futureClockwise.y,
|
|
103
|
-
ARENA_SIZE - futureClockwise.y,
|
|
104
|
-
);
|
|
105
|
-
const scoreCounterClockwise = Math.min(
|
|
106
|
-
futureCounterClockwise.x,
|
|
107
|
-
ARENA_SIZE - futureCounterClockwise.x,
|
|
108
|
-
futureCounterClockwise.y,
|
|
109
|
-
ARENA_SIZE - futureCounterClockwise.y,
|
|
110
|
-
);
|
|
111
|
-
strafeDir =
|
|
112
|
-
scoreClockwise > scoreCounterClockwise
|
|
113
|
-
? strafeClockwise
|
|
114
|
-
: strafeCounterClockwise;
|
|
115
|
-
}
|
|
116
|
-
// Priority 3: Commit to direction (switch every ~200 ticks)
|
|
117
|
-
else
|
|
118
|
-
{
|
|
119
|
-
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
120
|
-
strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// When actively dodging, commit fully — no approach/retreat dilution
|
|
124
|
-
const intensity = dodgeDirection ? 100 : strafeIntensity;
|
|
125
|
-
return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Bot: Spellspinner
|
|
130
|
-
*
|
|
131
|
-
* BEHAVIOR: Maintains medium range (350 units), strafes constantly to dodge
|
|
132
|
-
* missiles, and fires homing missiles (damage 10, speed 4, turnRate 2). Heavy
|
|
133
|
-
* emphasis on movement — 80% strafe intensity when not dodging, 100% when dodging.
|
|
134
|
-
* Shields only undodgeable threats, emergency blinks when shield isn't available.
|
|
135
|
-
* The constant circular strafing motion traces patterns like thread being spun.
|
|
136
|
-
*
|
|
137
|
-
* NAMING RATIONALE: Like a spider spinning a web of projectiles while circling its
|
|
138
|
-
* prey. The constant strafing movement pattern traces circles — spinning thread
|
|
139
|
-
* around the arena. "Spell" + "spinner" = a wizard who spins spells around the
|
|
140
|
-
* battlefield. The kiting behavior (maintaining distance while attacking) creates
|
|
141
|
-
* a web-like pattern of missiles and movement that traps opponents.
|
|
142
|
-
*
|
|
143
|
-
* PROGRESSION LINE: Spellspinner → Spellweaver → Spellbinder
|
|
144
|
-
* - Spellspinner (tier 1): Fixed homing missiles, constant strafe, basic defense
|
|
145
|
-
* - Spellweaver (tier 2): + adaptive missile fitting, more sophisticated patterns
|
|
146
|
-
* - Spellbinder (tier 3): Future — inescapable web of magic, perfect distance control
|
|
147
|
-
* The progression: spinner (raw thread) → weaver (creates patterns) → binder
|
|
148
|
-
* (constrains and traps). Each tier's projectile web becomes harder to escape.
|
|
149
|
-
*
|
|
150
|
-
* TIER: 1 (base)
|
|
151
|
-
*/
|
|
152
|
-
export const Spellspinner: WizardFunction = ({state}) =>
|
|
153
|
-
{
|
|
154
|
-
const targetDistance = useParam('targetDistance', 338, {
|
|
155
|
-
range: 150,
|
|
156
|
-
min: 150,
|
|
157
|
-
max: 450,
|
|
158
|
-
});
|
|
159
|
-
const dangerDistance = useParam('dangerDistance', 242, {
|
|
160
|
-
range: 100,
|
|
161
|
-
min: 100,
|
|
162
|
-
});
|
|
163
|
-
const strafeCyclePeriod = useParam('strafeCyclePeriod', 210, {
|
|
164
|
-
range: 75,
|
|
165
|
-
min: 80,
|
|
166
|
-
max: 300,
|
|
167
|
-
});
|
|
168
|
-
const strafeIntensity = useParam('strafeIntensity', 31, {
|
|
169
|
-
range: 40,
|
|
170
|
-
min: 30,
|
|
171
|
-
max: 100,
|
|
172
|
-
steps: 7,
|
|
173
|
-
});
|
|
174
|
-
const approachSpeed = useParam('approachSpeed', 25, {
|
|
175
|
-
range: 30,
|
|
176
|
-
min: 10,
|
|
177
|
-
max: 70,
|
|
178
|
-
steps: 7,
|
|
179
|
-
});
|
|
180
|
-
const distanceDeadZone = useParam('distanceDeadZone', 40, {
|
|
181
|
-
range: 30,
|
|
182
|
-
min: 5,
|
|
183
|
-
max: 80,
|
|
184
|
-
steps: 7,
|
|
185
|
-
});
|
|
186
|
-
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
187
|
-
range: 75,
|
|
188
|
-
min: 50,
|
|
189
|
-
max: 300,
|
|
190
|
-
});
|
|
191
|
-
const blinkWindowMax = useParam('blinkWindowMax', 68, {
|
|
192
|
-
range: 30,
|
|
193
|
-
min: 15,
|
|
194
|
-
max: 80,
|
|
195
|
-
steps: 7,
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
const enemy = state.enemies[0];
|
|
199
|
-
if (!enemy)
|
|
200
|
-
{
|
|
201
|
-
return {move: {x: 0, y: 0}};
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const distance = distanceTo(state.position, enemy.position);
|
|
205
|
-
|
|
206
|
-
// Analyze threats using simulation
|
|
207
|
-
const threats = getThreats(state);
|
|
208
|
-
const urgentThreat = getMostUrgentThreat(threats);
|
|
209
|
-
|
|
210
|
-
// Smart strafe (dodge direction includes close homing missile avoidance)
|
|
211
|
-
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
212
|
-
const strafe = smartStrafe(
|
|
213
|
-
state.position,
|
|
214
|
-
enemy,
|
|
215
|
-
dodgeDirection,
|
|
216
|
-
state.tick,
|
|
217
|
-
strafeCyclePeriod,
|
|
218
|
-
strafeIntensity,
|
|
219
|
-
);
|
|
220
|
-
|
|
221
|
-
// Distance management (only when not dodging)
|
|
222
|
-
let moveX = strafe.x;
|
|
223
|
-
let moveY = strafe.y;
|
|
224
|
-
|
|
225
|
-
if (!dodgeDirection)
|
|
226
|
-
{
|
|
227
|
-
const deltaX = enemy.position.x - state.position.x;
|
|
228
|
-
const deltaY = enemy.position.y - state.position.y;
|
|
229
|
-
const normalizedDistance = distance > 0 ? distance : 1;
|
|
230
|
-
|
|
231
|
-
if (distance > targetDistance + distanceDeadZone)
|
|
232
|
-
{
|
|
233
|
-
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
234
|
-
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
235
|
-
}
|
|
236
|
-
else if (distance < targetDistance - distanceDeadZone)
|
|
237
|
-
{
|
|
238
|
-
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
239
|
-
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
240
|
-
if (state.position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
241
|
-
if (state.position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0)
|
|
242
|
-
retreatX = 0;
|
|
243
|
-
if (state.position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
244
|
-
if (state.position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0)
|
|
245
|
-
retreatY = 0;
|
|
246
|
-
moveX += retreatX;
|
|
247
|
-
moveY += retreatY;
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// Normalize
|
|
252
|
-
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
253
|
-
if (magnitude > 100)
|
|
254
|
-
{
|
|
255
|
-
moveX = (moveX / magnitude) * 100;
|
|
256
|
-
moveY = (moveY / magnitude) * 100;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const move = {x: moveX, y: moveY};
|
|
260
|
-
|
|
261
|
-
// === DEFENSE: Handle channeling ===
|
|
262
|
-
if (state.state === 'channeling')
|
|
263
|
-
{
|
|
264
|
-
// Cancel shield if no imminent threats
|
|
265
|
-
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
266
|
-
{
|
|
267
|
-
return {move, cancel: true};
|
|
268
|
-
}
|
|
269
|
-
return {move: {x: 0, y: 0}}; // Hold shield if still threatened
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
// === DEFENSE: Cancel missile cast if lethal threat arrives during GCD ===
|
|
273
|
-
if (
|
|
274
|
-
state.state === 'casting' &&
|
|
275
|
-
state.castingSpell === 'missile' &&
|
|
276
|
-
urgentThreat
|
|
277
|
-
)
|
|
278
|
-
{
|
|
279
|
-
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
280
|
-
const arrivesInGCD =
|
|
281
|
-
urgentThreat.ticksToImpact > remainingCast &&
|
|
282
|
-
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION;
|
|
283
|
-
|
|
284
|
-
if (
|
|
285
|
-
arrivesInGCD &&
|
|
286
|
-
state.health <= urgentThreat.projectile.damage + 1 &&
|
|
287
|
-
remainingCast > 10
|
|
288
|
-
)
|
|
289
|
-
{
|
|
290
|
-
return {move, cancel: true};
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// === BLINK-DODGE: For undodgeable threats OR to create attack window ===
|
|
295
|
-
// Blink to center (stays in combat range) and dodges the current missile.
|
|
296
|
-
// Also blink when persistent threats block all offense (creates safe firing window).
|
|
297
|
-
if (
|
|
298
|
-
state.state === 'idle' &&
|
|
299
|
-
urgentThreat &&
|
|
300
|
-
state.blinkCooldown === 0 &&
|
|
301
|
-
urgentThreat.ticksToImpact >= 10 &&
|
|
302
|
-
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
303
|
-
)
|
|
304
|
-
{
|
|
305
|
-
const undodgeableOrForced =
|
|
306
|
-
!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat);
|
|
307
|
-
const cannotAttack = !isSafeToAttack(
|
|
308
|
-
threats,
|
|
309
|
-
getMissileCastTime(QUICK_CONFIG),
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
if (undodgeableOrForced || cannotAttack)
|
|
313
|
-
{
|
|
314
|
-
return {
|
|
315
|
-
move: {x: 0, y: 0},
|
|
316
|
-
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
// === DEFENSE: Shield undodgeable/high-damage threats (when blink on cooldown) ===
|
|
322
|
-
if (
|
|
323
|
-
state.state === 'idle' &&
|
|
324
|
-
urgentThreat &&
|
|
325
|
-
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
326
|
-
)
|
|
327
|
-
{
|
|
328
|
-
if (urgentThreat.canBlockInTime)
|
|
329
|
-
{
|
|
330
|
-
if (urgentThreat.ticksToStartShield <= 3)
|
|
331
|
-
{
|
|
332
|
-
return {
|
|
333
|
-
move: {x: 0, y: 0},
|
|
334
|
-
startCast: {spell: 'shield'},
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
// === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
|
|
341
|
-
if (
|
|
342
|
-
state.state === 'idle' &&
|
|
343
|
-
distance < dangerDistance &&
|
|
344
|
-
state.blinkCooldown === 0 &&
|
|
345
|
-
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
346
|
-
)
|
|
347
|
-
{
|
|
348
|
-
const blinkTarget = getBlinkToIncreaseDistance(
|
|
349
|
-
state.position,
|
|
350
|
-
enemy.position,
|
|
351
|
-
state.projectiles,
|
|
352
|
-
MIN_BLINK_ESCAPE_DISTANCE,
|
|
353
|
-
);
|
|
354
|
-
if (blinkTarget)
|
|
355
|
-
{
|
|
356
|
-
return {
|
|
357
|
-
move: {x: 0, y: 0},
|
|
358
|
-
startCast: {spell: 'blink', target: blinkTarget},
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// === OFFENSE: Attack when safe ===
|
|
364
|
-
if (state.state === 'idle' && distance < 500)
|
|
365
|
-
{
|
|
366
|
-
// Primary: standard homing missile
|
|
367
|
-
const standardCastTime = getMissileCastTime(STANDARD_CONFIG);
|
|
368
|
-
if (isSafeToAttack(threats, standardCastTime))
|
|
369
|
-
{
|
|
370
|
-
return {
|
|
371
|
-
move,
|
|
372
|
-
startCast: {
|
|
373
|
-
spell: 'missile',
|
|
374
|
-
config: STANDARD_CONFIG,
|
|
375
|
-
missileAI: HomingAI,
|
|
376
|
-
direction: angleTo(state.position, enemy.position),
|
|
377
|
-
},
|
|
378
|
-
};
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Fallback: quick missile under pressure
|
|
382
|
-
const quickCastTime = getMissileCastTime(QUICK_CONFIG);
|
|
383
|
-
if (isSafeToAttack(threats, quickCastTime))
|
|
384
|
-
{
|
|
385
|
-
return {
|
|
386
|
-
move,
|
|
387
|
-
startCast: {
|
|
388
|
-
spell: 'missile',
|
|
389
|
-
config: QUICK_CONFIG,
|
|
390
|
-
missileAI: HomingAI,
|
|
391
|
-
direction: angleTo(state.position, enemy.position),
|
|
392
|
-
},
|
|
393
|
-
};
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return {move};
|
|
398
|
-
};
|
|
1
|
+
import {WizardFunction, MissileAIFunction} from '../../types.js';
|
|
2
|
+
import {angleTo} from '../../utils/angles.js';
|
|
3
|
+
import {distanceTo} from '../../utils/distance.js';
|
|
4
|
+
import {getMissileCastTime} from '../../utils/combat.js';
|
|
5
|
+
import {ARENA_SIZE, GCD_DURATION} from '../../rules.js';
|
|
6
|
+
import {
|
|
7
|
+
getThreats,
|
|
8
|
+
getMostUrgentThreat,
|
|
9
|
+
getDodgeDirectionForMovement,
|
|
10
|
+
isSafeToAttack,
|
|
11
|
+
getBlinkToCenter,
|
|
12
|
+
getBlinkToIncreaseDistance,
|
|
13
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
14
|
+
shouldForceShield,
|
|
15
|
+
} from '../shared.js';
|
|
16
|
+
import {useParam} from '../../engine/params-runtime.js';
|
|
17
|
+
const WALL_BUFFER = 60;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Standard homing missile: higher damage, good range. s=5 × dur=150 = 750u range.
|
|
21
|
+
*/
|
|
22
|
+
const STANDARD_CONFIG = {damage: 15, speed: 5, turnRate: 1, duration: 150};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Quick homing missile for firing under pressure.
|
|
26
|
+
* Similar speed/turn for warmup compatibility.
|
|
27
|
+
*/
|
|
28
|
+
const QUICK_CONFIG = {damage: 10, speed: 5, turnRate: 1, duration: 80};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Standard homing missile AI.
|
|
32
|
+
*/
|
|
33
|
+
const HomingAI: MissileAIFunction = ({worldState}) =>
|
|
34
|
+
{
|
|
35
|
+
const enemy = worldState.enemies[0];
|
|
36
|
+
return {
|
|
37
|
+
turnToward: enemy?.position,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if near wall.
|
|
43
|
+
*/
|
|
44
|
+
function isNearWall(position: {x: number; y: number}): boolean
|
|
45
|
+
{
|
|
46
|
+
return (
|
|
47
|
+
position.x < WALL_BUFFER ||
|
|
48
|
+
position.x > ARENA_SIZE - WALL_BUFFER ||
|
|
49
|
+
position.y < WALL_BUFFER ||
|
|
50
|
+
position.y > ARENA_SIZE - WALL_BUFFER
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Smart strafe with threat-aware dodging and wall avoidance.
|
|
56
|
+
*/
|
|
57
|
+
function smartStrafe(
|
|
58
|
+
position: {x: number; y: number},
|
|
59
|
+
enemy: {position: {x: number; y: number}},
|
|
60
|
+
dodgeDirection: {x: number; y: number} | null,
|
|
61
|
+
tick: number,
|
|
62
|
+
strafeCyclePeriod: number,
|
|
63
|
+
strafeIntensity: number,
|
|
64
|
+
): {x: number; y: number}
|
|
65
|
+
{
|
|
66
|
+
const deltaX = enemy.position.x - position.x;
|
|
67
|
+
const deltaY = enemy.position.y - position.y;
|
|
68
|
+
const normalizedDistance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
|
69
|
+
if (normalizedDistance === 0) return {x: 0, y: 0};
|
|
70
|
+
|
|
71
|
+
// Perpendicular directions
|
|
72
|
+
const strafeClockwise = {
|
|
73
|
+
x: -deltaY / normalizedDistance,
|
|
74
|
+
y: deltaX / normalizedDistance,
|
|
75
|
+
};
|
|
76
|
+
const strafeCounterClockwise = {
|
|
77
|
+
x: deltaY / normalizedDistance,
|
|
78
|
+
y: -deltaX / normalizedDistance,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let strafeDir: {x: number; y: number};
|
|
82
|
+
|
|
83
|
+
// Priority 1: Use dodge direction (from threat analysis or close missile avoidance)
|
|
84
|
+
if (dodgeDirection)
|
|
85
|
+
{
|
|
86
|
+
strafeDir = dodgeDirection;
|
|
87
|
+
}
|
|
88
|
+
// Priority 2: Avoid walls
|
|
89
|
+
else if (isNearWall(position))
|
|
90
|
+
{
|
|
91
|
+
const futureClockwise = {
|
|
92
|
+
x: position.x + strafeClockwise.x * 100,
|
|
93
|
+
y: position.y + strafeClockwise.y * 100,
|
|
94
|
+
};
|
|
95
|
+
const futureCounterClockwise = {
|
|
96
|
+
x: position.x + strafeCounterClockwise.x * 100,
|
|
97
|
+
y: position.y + strafeCounterClockwise.y * 100,
|
|
98
|
+
};
|
|
99
|
+
const scoreClockwise = Math.min(
|
|
100
|
+
futureClockwise.x,
|
|
101
|
+
ARENA_SIZE - futureClockwise.x,
|
|
102
|
+
futureClockwise.y,
|
|
103
|
+
ARENA_SIZE - futureClockwise.y,
|
|
104
|
+
);
|
|
105
|
+
const scoreCounterClockwise = Math.min(
|
|
106
|
+
futureCounterClockwise.x,
|
|
107
|
+
ARENA_SIZE - futureCounterClockwise.x,
|
|
108
|
+
futureCounterClockwise.y,
|
|
109
|
+
ARENA_SIZE - futureCounterClockwise.y,
|
|
110
|
+
);
|
|
111
|
+
strafeDir =
|
|
112
|
+
scoreClockwise > scoreCounterClockwise
|
|
113
|
+
? strafeClockwise
|
|
114
|
+
: strafeCounterClockwise;
|
|
115
|
+
}
|
|
116
|
+
// Priority 3: Commit to direction (switch every ~200 ticks)
|
|
117
|
+
else
|
|
118
|
+
{
|
|
119
|
+
const cycle = Math.floor(tick / strafeCyclePeriod) % 2;
|
|
120
|
+
strafeDir = cycle === 0 ? strafeClockwise : strafeCounterClockwise;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// When actively dodging, commit fully — no approach/retreat dilution
|
|
124
|
+
const intensity = dodgeDirection ? 100 : strafeIntensity;
|
|
125
|
+
return {x: strafeDir.x * intensity, y: strafeDir.y * intensity};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Bot: Spellspinner
|
|
130
|
+
*
|
|
131
|
+
* BEHAVIOR: Maintains medium range (350 units), strafes constantly to dodge
|
|
132
|
+
* missiles, and fires homing missiles (damage 10, speed 4, turnRate 2). Heavy
|
|
133
|
+
* emphasis on movement — 80% strafe intensity when not dodging, 100% when dodging.
|
|
134
|
+
* Shields only undodgeable threats, emergency blinks when shield isn't available.
|
|
135
|
+
* The constant circular strafing motion traces patterns like thread being spun.
|
|
136
|
+
*
|
|
137
|
+
* NAMING RATIONALE: Like a spider spinning a web of projectiles while circling its
|
|
138
|
+
* prey. The constant strafing movement pattern traces circles — spinning thread
|
|
139
|
+
* around the arena. "Spell" + "spinner" = a wizard who spins spells around the
|
|
140
|
+
* battlefield. The kiting behavior (maintaining distance while attacking) creates
|
|
141
|
+
* a web-like pattern of missiles and movement that traps opponents.
|
|
142
|
+
*
|
|
143
|
+
* PROGRESSION LINE: Spellspinner → Spellweaver → Spellbinder
|
|
144
|
+
* - Spellspinner (tier 1): Fixed homing missiles, constant strafe, basic defense
|
|
145
|
+
* - Spellweaver (tier 2): + adaptive missile fitting, more sophisticated patterns
|
|
146
|
+
* - Spellbinder (tier 3): Future — inescapable web of magic, perfect distance control
|
|
147
|
+
* The progression: spinner (raw thread) → weaver (creates patterns) → binder
|
|
148
|
+
* (constrains and traps). Each tier's projectile web becomes harder to escape.
|
|
149
|
+
*
|
|
150
|
+
* TIER: 1 (base)
|
|
151
|
+
*/
|
|
152
|
+
export const Spellspinner: WizardFunction = ({state}) =>
|
|
153
|
+
{
|
|
154
|
+
const targetDistance = useParam('targetDistance', 338, {
|
|
155
|
+
range: 150,
|
|
156
|
+
min: 150,
|
|
157
|
+
max: 450,
|
|
158
|
+
});
|
|
159
|
+
const dangerDistance = useParam('dangerDistance', 242, {
|
|
160
|
+
range: 100,
|
|
161
|
+
min: 100,
|
|
162
|
+
});
|
|
163
|
+
const strafeCyclePeriod = useParam('strafeCyclePeriod', 210, {
|
|
164
|
+
range: 75,
|
|
165
|
+
min: 80,
|
|
166
|
+
max: 300,
|
|
167
|
+
});
|
|
168
|
+
const strafeIntensity = useParam('strafeIntensity', 31, {
|
|
169
|
+
range: 40,
|
|
170
|
+
min: 30,
|
|
171
|
+
max: 100,
|
|
172
|
+
steps: 7,
|
|
173
|
+
});
|
|
174
|
+
const approachSpeed = useParam('approachSpeed', 25, {
|
|
175
|
+
range: 30,
|
|
176
|
+
min: 10,
|
|
177
|
+
max: 70,
|
|
178
|
+
steps: 7,
|
|
179
|
+
});
|
|
180
|
+
const distanceDeadZone = useParam('distanceDeadZone', 40, {
|
|
181
|
+
range: 30,
|
|
182
|
+
min: 5,
|
|
183
|
+
max: 80,
|
|
184
|
+
steps: 7,
|
|
185
|
+
});
|
|
186
|
+
const shieldCancelThreshold = useParam('shieldCancelThreshold', 150, {
|
|
187
|
+
range: 75,
|
|
188
|
+
min: 50,
|
|
189
|
+
max: 300,
|
|
190
|
+
});
|
|
191
|
+
const blinkWindowMax = useParam('blinkWindowMax', 68, {
|
|
192
|
+
range: 30,
|
|
193
|
+
min: 15,
|
|
194
|
+
max: 80,
|
|
195
|
+
steps: 7,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const enemy = state.enemies[0];
|
|
199
|
+
if (!enemy)
|
|
200
|
+
{
|
|
201
|
+
return {move: {x: 0, y: 0}};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const distance = distanceTo(state.position, enemy.position);
|
|
205
|
+
|
|
206
|
+
// Analyze threats using simulation
|
|
207
|
+
const threats = getThreats(state);
|
|
208
|
+
const urgentThreat = getMostUrgentThreat(threats);
|
|
209
|
+
|
|
210
|
+
// Smart strafe (dodge direction includes close homing missile avoidance)
|
|
211
|
+
const dodgeDirection = getDodgeDirectionForMovement(threats, state.position);
|
|
212
|
+
const strafe = smartStrafe(
|
|
213
|
+
state.position,
|
|
214
|
+
enemy,
|
|
215
|
+
dodgeDirection,
|
|
216
|
+
state.tick,
|
|
217
|
+
strafeCyclePeriod,
|
|
218
|
+
strafeIntensity,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// Distance management (only when not dodging)
|
|
222
|
+
let moveX = strafe.x;
|
|
223
|
+
let moveY = strafe.y;
|
|
224
|
+
|
|
225
|
+
if (!dodgeDirection)
|
|
226
|
+
{
|
|
227
|
+
const deltaX = enemy.position.x - state.position.x;
|
|
228
|
+
const deltaY = enemy.position.y - state.position.y;
|
|
229
|
+
const normalizedDistance = distance > 0 ? distance : 1;
|
|
230
|
+
|
|
231
|
+
if (distance > targetDistance + distanceDeadZone)
|
|
232
|
+
{
|
|
233
|
+
moveX += (deltaX / normalizedDistance) * approachSpeed;
|
|
234
|
+
moveY += (deltaY / normalizedDistance) * approachSpeed;
|
|
235
|
+
}
|
|
236
|
+
else if (distance < targetDistance - distanceDeadZone)
|
|
237
|
+
{
|
|
238
|
+
let retreatX = -(deltaX / normalizedDistance) * approachSpeed;
|
|
239
|
+
let retreatY = -(deltaY / normalizedDistance) * approachSpeed;
|
|
240
|
+
if (state.position.x < WALL_BUFFER && retreatX < 0) retreatX = 0;
|
|
241
|
+
if (state.position.x > ARENA_SIZE - WALL_BUFFER && retreatX > 0)
|
|
242
|
+
retreatX = 0;
|
|
243
|
+
if (state.position.y < WALL_BUFFER && retreatY < 0) retreatY = 0;
|
|
244
|
+
if (state.position.y > ARENA_SIZE - WALL_BUFFER && retreatY > 0)
|
|
245
|
+
retreatY = 0;
|
|
246
|
+
moveX += retreatX;
|
|
247
|
+
moveY += retreatY;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Normalize
|
|
252
|
+
const magnitude = Math.sqrt(moveX * moveX + moveY * moveY);
|
|
253
|
+
if (magnitude > 100)
|
|
254
|
+
{
|
|
255
|
+
moveX = (moveX / magnitude) * 100;
|
|
256
|
+
moveY = (moveY / magnitude) * 100;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const move = {x: moveX, y: moveY};
|
|
260
|
+
|
|
261
|
+
// === DEFENSE: Handle channeling ===
|
|
262
|
+
if (state.state === 'channeling')
|
|
263
|
+
{
|
|
264
|
+
// Cancel shield if no imminent threats
|
|
265
|
+
if (!urgentThreat || urgentThreat.ticksToImpact > shieldCancelThreshold)
|
|
266
|
+
{
|
|
267
|
+
return {move, cancel: true};
|
|
268
|
+
}
|
|
269
|
+
return {move: {x: 0, y: 0}}; // Hold shield if still threatened
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// === DEFENSE: Cancel missile cast if lethal threat arrives during GCD ===
|
|
273
|
+
if (
|
|
274
|
+
state.state === 'casting' &&
|
|
275
|
+
state.castingSpell === 'missile' &&
|
|
276
|
+
urgentThreat
|
|
277
|
+
)
|
|
278
|
+
{
|
|
279
|
+
const remainingCast = (state.castDuration ?? 0) - (state.castProgress ?? 0);
|
|
280
|
+
const arrivesInGCD =
|
|
281
|
+
urgentThreat.ticksToImpact > remainingCast &&
|
|
282
|
+
urgentThreat.ticksToImpact <= remainingCast + GCD_DURATION;
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
arrivesInGCD &&
|
|
286
|
+
state.health <= urgentThreat.projectile.damage + 1 &&
|
|
287
|
+
remainingCast > 10
|
|
288
|
+
)
|
|
289
|
+
{
|
|
290
|
+
return {move, cancel: true};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// === BLINK-DODGE: For undodgeable threats OR to create attack window ===
|
|
295
|
+
// Blink to center (stays in combat range) and dodges the current missile.
|
|
296
|
+
// Also blink when persistent threats block all offense (creates safe firing window).
|
|
297
|
+
if (
|
|
298
|
+
state.state === 'idle' &&
|
|
299
|
+
urgentThreat &&
|
|
300
|
+
state.blinkCooldown === 0 &&
|
|
301
|
+
urgentThreat.ticksToImpact >= 10 &&
|
|
302
|
+
urgentThreat.ticksToImpact <= blinkWindowMax
|
|
303
|
+
)
|
|
304
|
+
{
|
|
305
|
+
const undodgeableOrForced =
|
|
306
|
+
!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat);
|
|
307
|
+
const cannotAttack = !isSafeToAttack(
|
|
308
|
+
threats,
|
|
309
|
+
getMissileCastTime(QUICK_CONFIG),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
if (undodgeableOrForced || cannotAttack)
|
|
313
|
+
{
|
|
314
|
+
return {
|
|
315
|
+
move: {x: 0, y: 0},
|
|
316
|
+
startCast: {spell: 'blink', target: getBlinkToCenter(state.position)},
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// === DEFENSE: Shield undodgeable/high-damage threats (when blink on cooldown) ===
|
|
322
|
+
if (
|
|
323
|
+
state.state === 'idle' &&
|
|
324
|
+
urgentThreat &&
|
|
325
|
+
(!urgentThreat.bestDodgeDirection || shouldForceShield(urgentThreat))
|
|
326
|
+
)
|
|
327
|
+
{
|
|
328
|
+
if (urgentThreat.canBlockInTime)
|
|
329
|
+
{
|
|
330
|
+
if (urgentThreat.ticksToStartShield <= 3)
|
|
331
|
+
{
|
|
332
|
+
return {
|
|
333
|
+
move: {x: 0, y: 0},
|
|
334
|
+
startCast: {spell: 'shield'},
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// === DISTANCE BLINK: enemy too close, blink to reestablish kiting range ===
|
|
341
|
+
if (
|
|
342
|
+
state.state === 'idle' &&
|
|
343
|
+
distance < dangerDistance &&
|
|
344
|
+
state.blinkCooldown === 0 &&
|
|
345
|
+
(!urgentThreat || urgentThreat.bestDodgeDirection !== null)
|
|
346
|
+
)
|
|
347
|
+
{
|
|
348
|
+
const blinkTarget = getBlinkToIncreaseDistance(
|
|
349
|
+
state.position,
|
|
350
|
+
enemy.position,
|
|
351
|
+
state.projectiles,
|
|
352
|
+
MIN_BLINK_ESCAPE_DISTANCE,
|
|
353
|
+
);
|
|
354
|
+
if (blinkTarget)
|
|
355
|
+
{
|
|
356
|
+
return {
|
|
357
|
+
move: {x: 0, y: 0},
|
|
358
|
+
startCast: {spell: 'blink', target: blinkTarget},
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// === OFFENSE: Attack when safe ===
|
|
364
|
+
if (state.state === 'idle' && distance < 500)
|
|
365
|
+
{
|
|
366
|
+
// Primary: standard homing missile
|
|
367
|
+
const standardCastTime = getMissileCastTime(STANDARD_CONFIG);
|
|
368
|
+
if (isSafeToAttack(threats, standardCastTime))
|
|
369
|
+
{
|
|
370
|
+
return {
|
|
371
|
+
move,
|
|
372
|
+
startCast: {
|
|
373
|
+
spell: 'missile',
|
|
374
|
+
config: STANDARD_CONFIG,
|
|
375
|
+
missileAI: HomingAI,
|
|
376
|
+
direction: angleTo(state.position, enemy.position),
|
|
377
|
+
},
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Fallback: quick missile under pressure
|
|
382
|
+
const quickCastTime = getMissileCastTime(QUICK_CONFIG);
|
|
383
|
+
if (isSafeToAttack(threats, quickCastTime))
|
|
384
|
+
{
|
|
385
|
+
return {
|
|
386
|
+
move,
|
|
387
|
+
startCast: {
|
|
388
|
+
spell: 'missile',
|
|
389
|
+
config: QUICK_CONFIG,
|
|
390
|
+
missileAI: HomingAI,
|
|
391
|
+
direction: angleTo(state.position, enemy.position),
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return {move};
|
|
398
|
+
};
|