@rpgjs/action-battle 5.0.0-beta.10 → 5.0.0-beta.12

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 (111) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/client/ai.server.d.ts +45 -8
  3. package/dist/client/attack-input.d.ts +3 -0
  4. package/dist/client/core/action-use.d.ts +18 -0
  5. package/dist/client/core/ai-behavior-tree.d.ts +99 -0
  6. package/dist/client/core/attack-runtime.d.ts +2 -0
  7. package/dist/client/core/defaults.d.ts +2 -1
  8. package/dist/client/core/equipment.d.ts +1 -0
  9. package/dist/client/core/targets.d.ts +15 -0
  10. package/dist/client/enemies/factory.d.ts +2 -0
  11. package/dist/client/index.d.ts +12 -7
  12. package/dist/client/index.js +16 -11
  13. package/dist/client/index10.js +32 -56
  14. package/dist/client/index11.js +99 -52
  15. package/dist/client/index12.js +76 -103
  16. package/dist/client/index13.js +72 -135
  17. package/dist/client/index14.js +67 -23
  18. package/dist/client/index15.js +197 -63
  19. package/dist/client/index16.js +112 -1337
  20. package/dist/client/index17.js +193 -7
  21. package/dist/client/index18.js +32 -58
  22. package/dist/client/index19.js +70 -8
  23. package/dist/client/index20.js +57 -501
  24. package/dist/client/index21.js +69 -0
  25. package/dist/client/index22.js +225 -0
  26. package/dist/client/index23.js +16 -0
  27. package/dist/client/index24.js +25 -0
  28. package/dist/client/index25.js +107 -0
  29. package/dist/client/index26.js +1707 -0
  30. package/dist/client/index27.js +12 -0
  31. package/dist/client/index28.js +589 -0
  32. package/dist/client/index4.js +79 -38
  33. package/dist/client/index6.js +65 -306
  34. package/dist/client/index7.js +33 -33
  35. package/dist/client/index8.js +24 -100
  36. package/dist/client/index9.js +293 -61
  37. package/dist/client/locomotion.d.ts +16 -0
  38. package/dist/client/movement.d.ts +14 -0
  39. package/dist/client/server.d.ts +7 -3
  40. package/dist/client/ui.d.ts +22 -0
  41. package/dist/client/visual.d.ts +15 -0
  42. package/dist/server/ai.server.d.ts +45 -8
  43. package/dist/server/attack-input.d.ts +3 -0
  44. package/dist/server/core/action-use.d.ts +18 -0
  45. package/dist/server/core/ai-behavior-tree.d.ts +99 -0
  46. package/dist/server/core/attack-runtime.d.ts +2 -0
  47. package/dist/server/core/defaults.d.ts +2 -1
  48. package/dist/server/core/equipment.d.ts +1 -0
  49. package/dist/server/core/targets.d.ts +15 -0
  50. package/dist/server/enemies/factory.d.ts +2 -0
  51. package/dist/server/index.d.ts +12 -7
  52. package/dist/server/index.js +14 -9
  53. package/dist/server/index10.js +64 -1336
  54. package/dist/server/index11.js +33 -33
  55. package/dist/server/index13.js +66 -11
  56. package/dist/server/index14.js +206 -484
  57. package/dist/server/index15.js +15 -9
  58. package/dist/server/index16.js +26 -0
  59. package/dist/server/index17.js +25 -0
  60. package/dist/server/index18.js +107 -0
  61. package/dist/server/index19.js +1707 -0
  62. package/dist/server/index2.js +10 -2
  63. package/dist/server/index20.js +37 -0
  64. package/dist/server/index21.js +588 -0
  65. package/dist/server/index22.js +78 -0
  66. package/dist/server/index23.js +12 -0
  67. package/dist/server/index5.js +79 -38
  68. package/dist/server/index6.js +192 -129
  69. package/dist/server/index7.js +198 -24
  70. package/dist/server/index8.js +28 -66
  71. package/dist/server/index9.js +68 -51
  72. package/dist/server/locomotion.d.ts +16 -0
  73. package/dist/server/movement.d.ts +14 -0
  74. package/dist/server/server.d.ts +7 -3
  75. package/dist/server/ui.d.ts +22 -0
  76. package/dist/server/visual.d.ts +15 -0
  77. package/package.json +10 -10
  78. package/src/ai.server.spec.ts +233 -0
  79. package/src/ai.server.ts +627 -108
  80. package/src/animations.spec.ts +40 -0
  81. package/src/animations.ts +31 -9
  82. package/src/attack-input.spec.ts +51 -0
  83. package/src/attack-input.ts +59 -0
  84. package/src/client.ts +75 -62
  85. package/src/components/action-bar.ce +2 -2
  86. package/src/config.ts +84 -37
  87. package/src/core/action-use.spec.ts +317 -0
  88. package/src/core/action-use.ts +386 -0
  89. package/src/core/ai-behavior-tree.spec.ts +116 -0
  90. package/src/core/ai-behavior-tree.ts +272 -0
  91. package/src/core/attack-profile.spec.ts +46 -0
  92. package/src/core/attack-runtime.spec.ts +35 -0
  93. package/src/core/attack-runtime.ts +32 -0
  94. package/src/core/context.ts +9 -0
  95. package/src/core/contracts.ts +146 -1
  96. package/src/core/defaults.ts +56 -0
  97. package/src/core/equipment.ts +9 -5
  98. package/src/core/targets.spec.ts +112 -0
  99. package/src/core/targets.ts +147 -0
  100. package/src/enemies/factory.ts +8 -0
  101. package/src/index.ts +111 -2
  102. package/src/locomotion.spec.ts +51 -0
  103. package/src/locomotion.ts +48 -0
  104. package/src/movement.spec.ts +78 -0
  105. package/src/movement.ts +46 -0
  106. package/src/server.ts +242 -66
  107. package/src/types.ts +105 -35
  108. package/src/ui.ts +113 -0
  109. package/src/visual.spec.ts +166 -0
  110. package/src/visual.ts +285 -0
  111. package/README.md +0 -1242
@@ -0,0 +1,1707 @@
1
+ import { getActionBattleAnimationRemovalDelay, resolveActionBattleAnimation } from "./index2.js";
2
+ import { isActionBattleEntityInvincible, setActionBattleInvincibility } from "./index3.js";
3
+ import { getActionBattleOptions } from "./index5.js";
4
+ import { emitActionBattleClientVisual } from "./index6.js";
5
+ import { getActionBattleSystems } from "./index8.js";
6
+ import { normalizeActionBattleEnemyAttackProfiles } from "./index9.js";
7
+ import { ActionBattleHitTracker, runActionBattleActiveHitbox, scheduleActionBattleStartup } from "./index10.js";
8
+ import { applyActionBattleAttackDirection } from "./index11.js";
9
+ import { canActionBattleTarget, isActionBattlePlayer } from "./index13.js";
10
+ import { executeActionBattleUse, getActionBattleActionRange } from "./index14.js";
11
+ import { resolveActionBattleWeapon } from "./index15.js";
12
+ import { withActionBattleAnimationUnlocked } from "./index16.js";
13
+ import { safeActionBattleDash } from "./index17.js";
14
+ import { defineAiBehavior, defineAiTree } from "./index18.js";
15
+ import { MAXHP } from "@rpgjs/server";
16
+ //#region src/ai.server.ts
17
+ var normalizeRewardItem = (item) => {
18
+ if (typeof item === "string") return {
19
+ itemId: item,
20
+ amount: 1,
21
+ chance: 100
22
+ };
23
+ return {
24
+ ...item,
25
+ amount: item.amount ?? 1,
26
+ chance: item.chance ?? 100
27
+ };
28
+ };
29
+ var getRewardItemRef = (item) => item.item ?? item.itemId;
30
+ var getPlayerMap = (player) => {
31
+ return typeof player.getCurrentMap === "function" ? player.getCurrentMap() : void 0;
32
+ };
33
+ var getRewardItemName = (inventoryItem, itemRef) => {
34
+ if (inventoryItem && typeof inventoryItem.name === "function") return inventoryItem.name();
35
+ if (inventoryItem?.name) return inventoryItem.name;
36
+ if (typeof itemRef === "string") return itemRef;
37
+ if (itemRef?.name) return itemRef.name;
38
+ if (itemRef?.id) return itemRef.id;
39
+ return "item";
40
+ };
41
+ var createDefeatReward = (rewards) => {
42
+ let awarded = false;
43
+ return {
44
+ get awarded() {
45
+ return awarded;
46
+ },
47
+ giveTo(player) {
48
+ if (!player || awarded || !rewards) return;
49
+ awarded = true;
50
+ const exp = rewards.exp ?? 0;
51
+ const gold = rewards.gold ?? 0;
52
+ if (exp > 0) player.exp += exp;
53
+ if (gold > 0) player.gold += gold;
54
+ if (rewards.showNotification && (exp > 0 || gold > 0)) player.showNotification(`You won ${exp} experience and ${gold} gold`);
55
+ for (const rawItem of rewards.items ?? []) {
56
+ const item = normalizeRewardItem(rawItem);
57
+ const itemRef = getRewardItemRef(item);
58
+ if (!itemRef) continue;
59
+ if (Math.random() * 100 >= (item.chance ?? 100)) continue;
60
+ const amount = item.amount ?? 1;
61
+ const inventoryItem = player.addItem(itemRef, amount);
62
+ if (rewards.showNotification) {
63
+ const itemData = typeof itemRef === "string" ? getPlayerMap(player)?.database?.()?.[itemRef] : void 0;
64
+ player.showNotification(`You won ${amount} ${getRewardItemName(inventoryItem, itemRef)}`, { icon: itemData?.icon });
65
+ }
66
+ }
67
+ }
68
+ };
69
+ };
70
+ /**
71
+ * AI Debug Logger
72
+ *
73
+ * Conditional logging utility for AI behavior debugging.
74
+ * Enable by setting `AiDebug.enabled = true` or via environment variable `RPGJS_DEBUG_AI=1`
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * // Enable debug logging
79
+ * AiDebug.enabled = true;
80
+ *
81
+ * // Or filter by event ID
82
+ * AiDebug.filterEventId = 'goblin-1';
83
+ * ```
84
+ */
85
+ var AiDebug = {
86
+ /** Enable/disable all AI debug logs */
87
+ enabled: globalThis.process?.env?.RPGJS_DEBUG_AI === "1" || false,
88
+ /** Filter logs to a specific event ID (null = all events) */
89
+ filterEventId: null,
90
+ /** Log categories to enable (empty = all) */
91
+ categories: [],
92
+ /**
93
+ * Log an AI debug message
94
+ *
95
+ * @param category - Log category (e.g., 'state', 'attack', 'movement', 'damage')
96
+ * @param eventId - Event ID for filtering
97
+ * @param message - Log message
98
+ * @param data - Optional additional data
99
+ */
100
+ log(category, eventId, message, data) {
101
+ if (!this.enabled) return;
102
+ if (this.filterEventId && eventId !== this.filterEventId) return;
103
+ if (this.categories.length > 0 && !this.categories.includes(category)) return;
104
+ const prefix = `[AI:${category}]${eventId ? ` [${eventId.substring(0, 8)}]` : ""}`;
105
+ if (data !== void 0) console.log(prefix, message, data);
106
+ else console.log(prefix, message);
107
+ }
108
+ };
109
+ /**
110
+ * AI State enumeration
111
+ *
112
+ * Defines the different states an AI can be in, each with its own behavior.
113
+ */
114
+ var AiState = /* @__PURE__ */ function(AiState) {
115
+ AiState["Idle"] = "idle";
116
+ AiState["Alert"] = "alert";
117
+ AiState["Combat"] = "combat";
118
+ AiState["Flee"] = "flee";
119
+ AiState["Stunned"] = "stunned";
120
+ return AiState;
121
+ }({});
122
+ /**
123
+ * Enemy Type enumeration
124
+ *
125
+ * Defines different enemy archetypes with unique behaviors.
126
+ * Stats (HP, ATK, etc.) should be set on the event itself via onInit.
127
+ */
128
+ var EnemyType = /* @__PURE__ */ function(EnemyType) {
129
+ EnemyType["Aggressive"] = "aggressive";
130
+ EnemyType["Defensive"] = "defensive";
131
+ EnemyType["Ranged"] = "ranged";
132
+ EnemyType["Tank"] = "tank";
133
+ EnemyType["Berserker"] = "berserker";
134
+ return EnemyType;
135
+ }({});
136
+ /**
137
+ * Attack Pattern enumeration
138
+ *
139
+ * Different attack patterns the AI can use.
140
+ */
141
+ var AttackPattern = /* @__PURE__ */ function(AttackPattern) {
142
+ AttackPattern["Melee"] = "melee";
143
+ AttackPattern["Combo"] = "combo";
144
+ AttackPattern["Charged"] = "charged";
145
+ AttackPattern["Zone"] = "zone";
146
+ AttackPattern["DashAttack"] = "dashAttack";
147
+ return AttackPattern;
148
+ }({});
149
+ /**
150
+ * Default knockback configuration
151
+ *
152
+ * Used when no weapon is equipped or weapon doesn't specify knockback.
153
+ */
154
+ var DEFAULT_KNOCKBACK = {
155
+ /** Default knockback force */
156
+ force: 50,
157
+ /** Default knockback duration in milliseconds */
158
+ duration: 300
159
+ };
160
+ var mergeBattleAiPresetOptions = (options, seen = /* @__PURE__ */ new Set()) => {
161
+ if (!options.preset) return options;
162
+ if (typeof options.preset === "string") {
163
+ if (seen.has(options.preset)) throw new Error(`Circular action battle AI preset: ${options.preset}`);
164
+ seen.add(options.preset);
165
+ }
166
+ const preset = typeof options.preset === "string" ? getActionBattleSystems().ai.presets[options.preset] : options.preset;
167
+ if (!preset) throw new Error(`Action battle AI preset not found: ${options.preset}`);
168
+ const resolvedPreset = mergeBattleAiPresetOptions(preset, seen);
169
+ const { preset: _preset, ...overrides } = options;
170
+ return {
171
+ ...resolvedPreset,
172
+ ...overrides,
173
+ behavior: {
174
+ ...resolvedPreset.behavior,
175
+ ...overrides.behavior
176
+ },
177
+ animations: {
178
+ ...resolvedPreset.animations,
179
+ ...overrides.animations
180
+ },
181
+ rewards: {
182
+ ...resolvedPreset.rewards,
183
+ ...overrides.rewards
184
+ }
185
+ };
186
+ };
187
+ /**
188
+ * Advanced Battle AI Controller for events
189
+ *
190
+ * This class provides intelligent combat behavior control for events.
191
+ * It uses the existing RPGJS API for stats, skills, items, etc.
192
+ * The AI only manages behavior - the event's stats should be configured
193
+ * in onInit using standard RPGJS methods.
194
+ *
195
+ * ## Usage with RPGJS API
196
+ *
197
+ * Configure the event stats using standard RPGJS methods:
198
+ * - `this.hp = 100` - Set health
199
+ * - `this.learnSkill(FireBall)` - Learn a skill
200
+ * - `this.addItem(Potion, 3)` - Add items
201
+ * - `this.equip(Sword)` - Equip items
202
+ * - `this.setClass(WarriorClass)` - Set class
203
+ * - `this.param[ATK] = 20` - Set parameters
204
+ *
205
+ * @example
206
+ * ```ts
207
+ * function GoblinEnemy() {
208
+ * return {
209
+ * name: "Goblin",
210
+ * onInit() {
211
+ * this.setGraphic("goblin");
212
+ *
213
+ * // Configure stats using RPGJS API
214
+ * this.hp = 80;
215
+ * this.param[ATK] = 15;
216
+ * this.param[PDEF] = 5;
217
+ * this.learnSkill(Slash);
218
+ *
219
+ * // Apply AI behavior
220
+ * new BattleAi(this, {
221
+ * enemyType: EnemyType.Aggressive,
222
+ * attackSkill: Slash
223
+ * });
224
+ * }
225
+ * };
226
+ * }
227
+ * ```
228
+ */
229
+ var BattleAi = class {
230
+ event;
231
+ target = null;
232
+ lastAttackTime = 0;
233
+ updateInterval;
234
+ /**
235
+ * Log AI debug message for this event
236
+ */
237
+ debugLog(category, message, data) {
238
+ AiDebug.log(category, this.event.id, message, data);
239
+ }
240
+ state = AiState.Idle;
241
+ stateStartTime = 0;
242
+ stunnedUntil = 0;
243
+ enemyType;
244
+ faction;
245
+ targets = "players";
246
+ attackCooldown = 1e3;
247
+ visionRange = 150;
248
+ attackRange = 60;
249
+ dodgeChance = .2;
250
+ dodgeCooldown = 2e3;
251
+ lastDodgeTime = 0;
252
+ fleeThreshold = .2;
253
+ attackSkill;
254
+ attackPatterns;
255
+ attackProfiles;
256
+ animations;
257
+ comboCount = 0;
258
+ comboMax = 3;
259
+ chargingAttack = false;
260
+ groupBehavior;
261
+ nearbyEnemies = [];
262
+ groupUpdateInterval = 0;
263
+ patrolWaypoints = [];
264
+ currentPatrolIndex = 0;
265
+ lastHpCheck = 0;
266
+ recentDamageTaken = 0;
267
+ damageCheckInterval = 2e3;
268
+ isMovingToTarget = false;
269
+ onDefeatedCallback;
270
+ rewards;
271
+ autoAwardRewards = true;
272
+ defeated = false;
273
+ lastFacingDirection = null;
274
+ behaviorScore = 50;
275
+ behaviorMode = "tactical";
276
+ behaviorLastUpdate = 0;
277
+ behaviorUpdateInterval = 400;
278
+ behaviorAssaultThreshold = 65;
279
+ behaviorRetreatThreshold = 35;
280
+ behaviorMinStateDuration = 600;
281
+ behaviorEnabled = false;
282
+ moveToCooldown = 400;
283
+ lastMoveToTime = 0;
284
+ retreatCooldown = 600;
285
+ lastRetreatTime = 0;
286
+ timers = [];
287
+ behaviorKey;
288
+ behaviorTree;
289
+ aiMemory = {};
290
+ poise = 0;
291
+ hitstunMs = 150;
292
+ invincibilityMs = 250;
293
+ visionShape;
294
+ visionSetupRetries = 0;
295
+ maxVisionSetupRetries = 20;
296
+ destroyed = false;
297
+ constructor(event, options = {}) {
298
+ options = mergeBattleAiPresetOptions(options);
299
+ event.battleAi = this;
300
+ this.event = event;
301
+ this.enemyType = options.enemyType || EnemyType.Aggressive;
302
+ this.faction = options.faction;
303
+ this.targets = options.targets ?? "players";
304
+ this.behaviorKey = options.behaviorKey ?? this.enemyType;
305
+ this.applyEnemyTypeBehavior(options);
306
+ this.attackSkill = options.attackSkill || null;
307
+ this.animations = {
308
+ ...getActionBattleOptions().animations,
309
+ ...options.animations
310
+ };
311
+ this.attackPatterns = options.attackPatterns || [
312
+ AttackPattern.Melee,
313
+ AttackPattern.Combo,
314
+ AttackPattern.DashAttack
315
+ ];
316
+ this.attackProfiles = normalizeActionBattleEnemyAttackProfiles(options.attackProfiles);
317
+ this.groupBehavior = options.groupBehavior || false;
318
+ this.patrolWaypoints = options.patrolWaypoints || [];
319
+ this.currentPatrolIndex = 0;
320
+ this.onDefeatedCallback = options.onDefeated;
321
+ this.rewards = options.rewards;
322
+ this.autoAwardRewards = options.autoAwardRewards ?? true;
323
+ if (options.behavior) {
324
+ this.behaviorEnabled = true;
325
+ if (options.behavior.baseScore !== void 0) this.behaviorScore = options.behavior.baseScore;
326
+ if (options.behavior.updateInterval !== void 0) this.behaviorUpdateInterval = options.behavior.updateInterval;
327
+ if (options.behavior.minStateDuration !== void 0) this.behaviorMinStateDuration = options.behavior.minStateDuration;
328
+ if (options.behavior.assaultThreshold !== void 0) this.behaviorAssaultThreshold = options.behavior.assaultThreshold;
329
+ if (options.behavior.retreatThreshold !== void 0) this.behaviorRetreatThreshold = options.behavior.retreatThreshold;
330
+ }
331
+ if (options.moveToCooldown !== void 0) this.moveToCooldown = options.moveToCooldown;
332
+ if (options.retreatCooldown !== void 0) this.retreatCooldown = options.retreatCooldown;
333
+ if (options.poise !== void 0) this.poise = Math.max(0, options.poise);
334
+ if (options.hitstunMs !== void 0) this.hitstunMs = Math.max(0, options.hitstunMs);
335
+ if (options.invincibilityMs !== void 0) this.invincibilityMs = Math.max(0, options.invincibilityMs);
336
+ if (options.tree || options.behaviorTree) this.behaviorTree = defineAiTree(options.tree ?? options.behaviorTree);
337
+ else if (options.simpleBehavior) this.behaviorTree = defineAiBehavior(options.simpleBehavior);
338
+ if (options.attackRange === void 0) {
339
+ const actionRange = this.getCurrentActionRange();
340
+ if (actionRange !== void 0) this.attackRange = actionRange;
341
+ }
342
+ this.scheduleVisionSetup();
343
+ this.startAiBehaviorLoop();
344
+ if (this.patrolWaypoints.length > 0) this.startPatrol();
345
+ this.debugLog("init", `AI created (type=${this.enemyType}, visionRange=${this.visionRange}, attackRange=${this.attackRange})`);
346
+ }
347
+ /**
348
+ * Apply enemy type-specific behavior modifiers
349
+ *
350
+ * This only affects AI behavior (cooldowns, ranges, dodge).
351
+ * Stats should be set on the event itself.
352
+ */
353
+ applyEnemyTypeBehavior(options) {
354
+ switch (this.enemyType) {
355
+ case EnemyType.Aggressive:
356
+ this.attackCooldown = options.attackCooldown ?? 600;
357
+ this.visionRange = options.visionRange ?? 150;
358
+ this.attackRange = options.attackRange ?? 50;
359
+ this.dodgeChance = options.dodgeChance ?? .1;
360
+ this.dodgeCooldown = options.dodgeCooldown ?? 3e3;
361
+ this.fleeThreshold = options.fleeThreshold ?? .15;
362
+ break;
363
+ case EnemyType.Defensive:
364
+ this.attackCooldown = options.attackCooldown ?? 1500;
365
+ this.visionRange = options.visionRange ?? 120;
366
+ this.attackRange = options.attackRange ?? 60;
367
+ this.dodgeChance = options.dodgeChance ?? .5;
368
+ this.dodgeCooldown = options.dodgeCooldown ?? 1500;
369
+ this.fleeThreshold = options.fleeThreshold ?? .3;
370
+ break;
371
+ case EnemyType.Ranged:
372
+ this.attackCooldown = options.attackCooldown ?? 1200;
373
+ this.visionRange = options.visionRange ?? 200;
374
+ this.attackRange = options.attackRange ?? 120;
375
+ this.dodgeChance = options.dodgeChance ?? .4;
376
+ this.dodgeCooldown = options.dodgeCooldown ?? 2e3;
377
+ this.fleeThreshold = options.fleeThreshold ?? .25;
378
+ break;
379
+ case EnemyType.Tank:
380
+ this.attackCooldown = options.attackCooldown ?? 2e3;
381
+ this.visionRange = options.visionRange ?? 100;
382
+ this.attackRange = options.attackRange ?? 50;
383
+ this.dodgeChance = 0;
384
+ this.dodgeCooldown = options.dodgeCooldown ?? 5e3;
385
+ this.fleeThreshold = options.fleeThreshold ?? .1;
386
+ break;
387
+ case EnemyType.Berserker:
388
+ this.attackCooldown = options.attackCooldown ?? 800;
389
+ this.visionRange = options.visionRange ?? 180;
390
+ this.attackRange = options.attackRange ?? 55;
391
+ this.dodgeChance = options.dodgeChance ?? .15;
392
+ this.dodgeCooldown = options.dodgeCooldown ?? 2500;
393
+ this.fleeThreshold = options.fleeThreshold ?? .05;
394
+ break;
395
+ default:
396
+ this.attackCooldown = options.attackCooldown ?? 1e3;
397
+ this.visionRange = options.visionRange ?? 150;
398
+ this.attackRange = options.attackRange ?? 60;
399
+ this.dodgeChance = options.dodgeChance ?? .2;
400
+ this.dodgeCooldown = options.dodgeCooldown ?? 2e3;
401
+ this.fleeThreshold = options.fleeThreshold ?? .2;
402
+ }
403
+ }
404
+ /**
405
+ * Setup vision detection
406
+ */
407
+ setupVision() {
408
+ if (this.visionShape) return true;
409
+ const map = this.event.getCurrentMap?.();
410
+ if (map?.physic?.getEntityByUUID && !map.physic.getEntityByUUID(this.event.id)) return false;
411
+ const diameter = this.visionRange * 2;
412
+ const shape = this.event.attachShape(`vision_${this.event.id}`, {
413
+ radius: this.visionRange,
414
+ width: diameter,
415
+ height: diameter,
416
+ angle: 360
417
+ });
418
+ if (!shape) return false;
419
+ this.visionShape = shape;
420
+ return true;
421
+ }
422
+ scheduleVisionSetup() {
423
+ if (this.destroyed || this.setupVision()) return;
424
+ if (this.visionSetupRetries >= this.maxVisionSetupRetries) return;
425
+ this.visionSetupRetries++;
426
+ this.schedule(() => {
427
+ if (this.destroyed || !this.event.getCurrentMap()) return;
428
+ this.scheduleVisionSetup();
429
+ }, 50);
430
+ }
431
+ /**
432
+ * Start the AI behavior loop
433
+ */
434
+ startAiBehaviorLoop() {
435
+ const updateInterval = setInterval(() => {
436
+ if (!this.event.getCurrentMap()) {
437
+ this.destroy();
438
+ return;
439
+ }
440
+ this.updateAiBehavior();
441
+ }, 100);
442
+ this.updateInterval = updateInterval;
443
+ }
444
+ /**
445
+ * Change AI state with validated transitions
446
+ */
447
+ changeState(newState) {
448
+ if (newState === this.state) return;
449
+ if (!{
450
+ [AiState.Idle]: [AiState.Alert, AiState.Combat],
451
+ [AiState.Alert]: [AiState.Idle, AiState.Combat],
452
+ [AiState.Combat]: [
453
+ AiState.Idle,
454
+ AiState.Flee,
455
+ AiState.Stunned
456
+ ],
457
+ [AiState.Flee]: [AiState.Idle, AiState.Combat],
458
+ [AiState.Stunned]: [AiState.Combat, AiState.Idle]
459
+ }[this.state].includes(newState)) {
460
+ this.debugLog("state", `INVALID transition ${this.state} -> ${newState}`);
461
+ return;
462
+ }
463
+ this.debugLog("state", `STATE change: ${this.state} -> ${newState}`);
464
+ this.state = newState;
465
+ this.stateStartTime = Date.now();
466
+ switch (newState) {
467
+ case AiState.Idle:
468
+ if (this.patrolWaypoints.length > 0) this.startPatrol();
469
+ break;
470
+ case AiState.Alert:
471
+ this.event.stopMoveTo();
472
+ break;
473
+ case AiState.Combat:
474
+ this.comboCount = 0;
475
+ break;
476
+ case AiState.Flee:
477
+ if (this.target) this.fleeFromTarget();
478
+ break;
479
+ case AiState.Stunned:
480
+ this.event.stopMoveTo();
481
+ break;
482
+ }
483
+ }
484
+ /**
485
+ * Main AI behavior update loop
486
+ */
487
+ updateAiBehavior() {
488
+ const currentTime = Date.now();
489
+ if (this.target && this.isTargetDefeated(this.target)) {
490
+ this.debugLog("combat", `Target ${this.target.id} is defeated, returning to idle`);
491
+ this.clearTarget();
492
+ this.changeState(AiState.Idle);
493
+ this.checkDamageTaken();
494
+ return;
495
+ }
496
+ if (this.groupBehavior) this.updateGroupBehavior();
497
+ if (this.state === AiState.Stunned) {
498
+ if (currentTime >= this.stunnedUntil) this.changeState(AiState.Combat);
499
+ return;
500
+ }
501
+ if (this.enemyType === EnemyType.Berserker && this.event.param[MAXHP]) {
502
+ const hpPercent = this.event.hp / this.event.param[MAXHP];
503
+ const berserkerModifier = Math.max(.3, hpPercent);
504
+ this.attackCooldown = 800 * berserkerModifier;
505
+ }
506
+ if (this.behaviorEnabled) this.updateBehavior(currentTime);
507
+ if (!this.target && this.state === AiState.Idle) {
508
+ const target = this.findNearestTarget();
509
+ if (target) this.engageTarget(target);
510
+ }
511
+ if (this.applyCustomBehavior(currentTime)) {
512
+ this.checkDamageTaken();
513
+ return;
514
+ }
515
+ switch (this.state) {
516
+ case AiState.Idle:
517
+ this.updateIdleBehavior();
518
+ break;
519
+ case AiState.Alert:
520
+ this.updateAlertBehavior();
521
+ break;
522
+ case AiState.Combat:
523
+ this.updateCombatBehavior(currentTime);
524
+ break;
525
+ case AiState.Flee:
526
+ this.updateFleeBehavior();
527
+ break;
528
+ }
529
+ this.checkDamageTaken();
530
+ }
531
+ /**
532
+ * Update idle behavior (patrolling)
533
+ */
534
+ updateIdleBehavior() {
535
+ const target = this.findNearestTarget();
536
+ if (target) {
537
+ this.engageTarget(target);
538
+ return;
539
+ }
540
+ if (this.patrolWaypoints.length > 0) {
541
+ const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
542
+ if (this.getDistance(this.event, {
543
+ x: () => waypoint.x,
544
+ y: () => waypoint.y
545
+ }) < 10) {
546
+ this.currentPatrolIndex = (this.currentPatrolIndex + 1) % this.patrolWaypoints.length;
547
+ this.startPatrol();
548
+ }
549
+ }
550
+ }
551
+ /**
552
+ * Update alert behavior
553
+ */
554
+ updateAlertBehavior() {
555
+ if (this.target) {
556
+ this.faceTarget();
557
+ if (this.getDistance(this.event, this.target) <= this.attackRange * 1.5) this.changeState(AiState.Combat);
558
+ } else this.changeState(AiState.Idle);
559
+ }
560
+ /**
561
+ * Update combat behavior
562
+ */
563
+ updateCombatBehavior(currentTime) {
564
+ if (!this.target) {
565
+ this.debugLog("combat", "No target, returning to idle");
566
+ this.changeState(AiState.Idle);
567
+ return;
568
+ }
569
+ const distance = this.getDistance(this.event, this.target);
570
+ if (distance > this.visionRange * 1.5) {
571
+ this.debugLog("combat", `Target out of range (dist=${distance.toFixed(1)}, maxRange=${(this.visionRange * 1.5).toFixed(1)})`);
572
+ this.clearTarget();
573
+ this.changeState(AiState.Idle);
574
+ return;
575
+ }
576
+ if (this.event.param[MAXHP]) {
577
+ const hpPercent = this.event.hp / this.event.param[MAXHP];
578
+ if (hpPercent <= this.fleeThreshold) {
579
+ this.debugLog("combat", `HP low (${(hpPercent * 100).toFixed(0)}%), fleeing`);
580
+ this.isMovingToTarget = false;
581
+ this.changeState(AiState.Flee);
582
+ return;
583
+ }
584
+ }
585
+ if (this.canDodge() && this.shouldDodge()) {
586
+ this.debugLog("combat", "Attempting dodge");
587
+ if (this.tryDodge()) {
588
+ this.isMovingToTarget = false;
589
+ return;
590
+ }
591
+ }
592
+ if (this.behaviorEnabled) {
593
+ if (this.behaviorMode === "tactical") this.handleTacticalMovement(distance);
594
+ else if (this.behaviorMode === "assault") this.handleAssaultMovement(distance);
595
+ else if (this.behaviorMode === "retreat") {
596
+ this.isMovingToTarget = false;
597
+ this.fleeFromTarget();
598
+ return;
599
+ }
600
+ }
601
+ if (this.behaviorEnabled && this.behaviorMode === "assault") {} else if (this.behaviorEnabled && this.behaviorMode === "tactical") {} else if (this.enemyType === EnemyType.Ranged) {
602
+ if (distance < this.attackRange * .6) {
603
+ this.debugLog("movement", `Retreating (dist=${distance.toFixed(1)}, minRange=${(this.attackRange * .6).toFixed(1)})`);
604
+ this.isMovingToTarget = false;
605
+ this.retreatFromTarget();
606
+ } else if (distance > this.attackRange) {
607
+ if (!this.isMovingToTarget) {
608
+ this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
609
+ this.isMovingToTarget = true;
610
+ this.requestMoveTo(this.target);
611
+ }
612
+ } else if (this.isMovingToTarget) {
613
+ this.debugLog("movement", `In range, stopping (dist=${distance.toFixed(1)})`);
614
+ this.isMovingToTarget = false;
615
+ this.event.stopMoveTo();
616
+ }
617
+ } else if (distance > this.attackRange) {
618
+ if (!this.isMovingToTarget) {
619
+ this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
620
+ this.isMovingToTarget = true;
621
+ this.requestMoveTo(this.target);
622
+ }
623
+ } else if (this.isMovingToTarget) {
624
+ this.debugLog("movement", `In range, stopping (dist=${distance.toFixed(1)})`);
625
+ this.isMovingToTarget = false;
626
+ this.event.stopMoveTo();
627
+ }
628
+ if (distance <= this.attackRange && currentTime - this.lastAttackTime >= this.attackCooldown) {
629
+ if (!this.chargingAttack) {
630
+ this.debugLog("attack", `Attacking (dist=${distance.toFixed(1)}, cooldown=${this.attackCooldown}ms)`);
631
+ this.selectAndPerformAttack();
632
+ this.lastAttackTime = currentTime;
633
+ }
634
+ }
635
+ }
636
+ /**
637
+ * Update flee behavior
638
+ */
639
+ updateFleeBehavior() {
640
+ if (!this.target) {
641
+ this.changeState(AiState.Idle);
642
+ return;
643
+ }
644
+ const distance = this.getDistance(this.event, this.target);
645
+ if (this.event.param[MAXHP]) {
646
+ if (this.event.hp / this.event.param[MAXHP] > this.fleeThreshold * 1.5 || distance > this.visionRange * 2) {
647
+ this.changeState(AiState.Combat);
648
+ return;
649
+ }
650
+ }
651
+ this.fleeFromTarget();
652
+ }
653
+ /**
654
+ * Select and perform an attack pattern
655
+ */
656
+ selectAndPerformAttack() {
657
+ if (!this.target) return;
658
+ if (this.comboCount > 0 && this.comboCount < this.comboMax) {
659
+ this.debugLog("attack", `Continuing combo (${this.comboCount}/${this.comboMax})`);
660
+ this.performComboAttack();
661
+ return;
662
+ }
663
+ const pattern = this.selectAttackPattern();
664
+ this.debugLog("attack", `Selected pattern: ${pattern}`);
665
+ this.performAttackPattern(pattern);
666
+ }
667
+ /**
668
+ * Select attack pattern with weighted probability
669
+ */
670
+ selectAttackPattern() {
671
+ const weights = {
672
+ [AttackPattern.Melee]: 40,
673
+ [AttackPattern.Combo]: 25,
674
+ [AttackPattern.Charged]: 15,
675
+ [AttackPattern.Zone]: 10,
676
+ [AttackPattern.DashAttack]: 10
677
+ };
678
+ switch (this.enemyType) {
679
+ case EnemyType.Aggressive:
680
+ weights[AttackPattern.Combo] += 20;
681
+ weights[AttackPattern.DashAttack] += 15;
682
+ break;
683
+ case EnemyType.Defensive:
684
+ weights[AttackPattern.Charged] += 25;
685
+ break;
686
+ case EnemyType.Ranged:
687
+ weights[AttackPattern.Zone] += 20;
688
+ break;
689
+ case EnemyType.Tank:
690
+ weights[AttackPattern.Charged] += 30;
691
+ weights[AttackPattern.Zone] += 15;
692
+ break;
693
+ case EnemyType.Berserker:
694
+ weights[AttackPattern.Combo] += 35;
695
+ break;
696
+ }
697
+ let total = 0;
698
+ const available = [];
699
+ this.attackPatterns.forEach((p) => {
700
+ const weight = weights[p] || 10;
701
+ total += weight;
702
+ available.push({
703
+ pattern: p,
704
+ weight
705
+ });
706
+ });
707
+ let random = Math.random() * total;
708
+ for (const item of available) {
709
+ random -= item.weight;
710
+ if (random <= 0) return item.pattern;
711
+ }
712
+ return this.attackPatterns[0] || AttackPattern.Melee;
713
+ }
714
+ /**
715
+ * Perform attack pattern
716
+ */
717
+ performAttackPattern(pattern) {
718
+ switch (pattern) {
719
+ case AttackPattern.Melee:
720
+ this.performMeleeAttack();
721
+ break;
722
+ case AttackPattern.Combo:
723
+ this.performComboAttack();
724
+ break;
725
+ case AttackPattern.Charged:
726
+ this.performChargedAttack();
727
+ break;
728
+ case AttackPattern.Zone:
729
+ this.performZoneAttack();
730
+ break;
731
+ case AttackPattern.DashAttack:
732
+ this.performDashAttack();
733
+ break;
734
+ }
735
+ }
736
+ /**
737
+ * Perform melee attack
738
+ * Uses skill if configured, otherwise creates hitbox
739
+ */
740
+ performMeleeAttack() {
741
+ if (!this.target) return;
742
+ const profile = this.getAttackProfile(AttackPattern.Melee);
743
+ this.faceTarget({ force: true });
744
+ this.telegraphAttack(profile);
745
+ withActionBattleAnimationUnlocked(this.event, () => {
746
+ emitActionBattleClientVisual({
747
+ moment: "attack",
748
+ entity: this.event,
749
+ target: this.target,
750
+ animations: this.animations
751
+ });
752
+ });
753
+ this.scheduleAttackStartup(profile, () => {
754
+ this.executeMeleeAttack(profile, AttackPattern.Melee);
755
+ });
756
+ }
757
+ executeMeleeAttack(profile, pattern) {
758
+ if (!this.target || this.isTargetDefeated(this.target)) return;
759
+ this.debugLog("attack", `Applying ${pattern} hit`);
760
+ if (this.attackSkill) {
761
+ const resolvedSkill = this.resolveUsable(this.attackSkill);
762
+ try {
763
+ executeActionBattleUse({
764
+ attacker: this.event,
765
+ target: this.target,
766
+ usable: resolvedSkill,
767
+ skill: resolvedSkill,
768
+ pattern,
769
+ profile
770
+ });
771
+ } catch (e) {
772
+ this.performBasicHitbox(profile, pattern);
773
+ }
774
+ return;
775
+ }
776
+ const weapon = resolveActionBattleWeapon(this.event);
777
+ if (weapon && executeActionBattleUse({
778
+ attacker: this.event,
779
+ target: this.target,
780
+ usable: weapon,
781
+ weapon,
782
+ pattern,
783
+ profile
784
+ })) return;
785
+ this.performBasicHitbox(profile, pattern);
786
+ }
787
+ /**
788
+ * Perform basic hitbox attack when no skill is set
789
+ */
790
+ performBasicHitbox(profile = this.getAttackProfile(AttackPattern.Melee), pattern = AttackPattern.Melee) {
791
+ if (!this.target || this.isTargetDefeated(this.target)) return;
792
+ const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
793
+ runActionBattleActiveHitbox({
794
+ ...profile,
795
+ startupMs: 0
796
+ }, () => this.resolveBasicHitboxes(), (hitboxes) => {
797
+ this.processHitboxHits(hitboxes, hitTracker, profile, pattern);
798
+ }, (scheduled, delay) => this.schedule(scheduled, delay));
799
+ }
800
+ resolveBasicHitboxes() {
801
+ if (!this.target || this.isTargetDefeated(this.target)) return [];
802
+ const eventX = this.event.x();
803
+ const eventY = this.event.y();
804
+ const dx = this.target.x() - eventX;
805
+ const dy = this.target.y() - eventY;
806
+ const dist = Math.sqrt(dx * dx + dy * dy);
807
+ if (dist === 0) return [];
808
+ const dirX = dx / dist;
809
+ const dirY = dy / dist;
810
+ return [{
811
+ x: eventX + dirX * 30,
812
+ y: eventY + dirY * 30,
813
+ width: 40,
814
+ height: 40
815
+ }];
816
+ }
817
+ queryHitboxCandidates(hitboxes) {
818
+ const map = this.event.getCurrentMap();
819
+ if (!map || typeof map.queryHitbox !== "function") return [];
820
+ const candidates = /* @__PURE__ */ new Map();
821
+ for (const hitbox of hitboxes) for (const hit of map.queryHitbox(hitbox, {
822
+ excludeIds: [this.event.id],
823
+ kinds: ["players", "events"]
824
+ })) if (hit?.id) candidates.set(hit.id, hit);
825
+ return Array.from(candidates.values());
826
+ }
827
+ processHitboxHits(hitboxes, hitTracker, profile, pattern) {
828
+ for (const hit of this.queryHitboxCandidates(hitboxes)) if (hit !== this.event && this.canTarget(hit) && !this.isTargetDefeated(hit) && hitTracker.tryHit(hit)) this.applyHit(hit, void 0, profile, pattern);
829
+ }
830
+ /**
831
+ * Apply hit to target using RPGJS damage system with knockback
832
+ *
833
+ * Calculates damage using RPGJS formula, applies knockback based on
834
+ * equipped weapon's knockbackForce property, and triggers visual effects.
835
+ * Supports hooks for customizing behavior.
836
+ *
837
+ * @param target - The player or entity being hit
838
+ * @param hooks - Optional hooks for customizing hit behavior
839
+ * @returns The hit result containing damage and knockback info
840
+ *
841
+ * @example
842
+ * ```ts
843
+ * // Basic hit
844
+ * this.applyHit(player);
845
+ *
846
+ * // With custom hooks
847
+ * this.applyHit(player, {
848
+ * onBeforeHit(result) {
849
+ * result.knockbackForce *= 1.5; // Increase knockback
850
+ * return result;
851
+ * },
852
+ * onAfterHit(result) {
853
+ * console.log(`Dealt ${result.damage} damage!`);
854
+ * }
855
+ * });
856
+ * ```
857
+ */
858
+ applyHit(target, hooks, profile = this.getAttackProfile(AttackPattern.Melee), pattern = AttackPattern.Melee) {
859
+ if (this.isTargetDefeated(target)) return {
860
+ damage: 0,
861
+ knockbackForce: 0,
862
+ knockbackDuration: 0,
863
+ defeated: true,
864
+ attacker: this.event,
865
+ target
866
+ };
867
+ if (isActionBattleEntityInvincible(target)) return {
868
+ damage: 0,
869
+ knockbackForce: 0,
870
+ knockbackDuration: 0,
871
+ defeated: false,
872
+ attacker: this.event,
873
+ target
874
+ };
875
+ const { damage } = target.applyDamage(this.event);
876
+ let hitResult = {
877
+ damage,
878
+ knockbackForce: this.getWeaponKnockbackForce(),
879
+ knockbackDuration: DEFAULT_KNOCKBACK.duration,
880
+ defeated: target.hp <= 0,
881
+ attacker: this.event,
882
+ target
883
+ };
884
+ if (hooks?.onBeforeHit) {
885
+ const modified = hooks.onBeforeHit(hitResult);
886
+ if (modified) hitResult = modified;
887
+ }
888
+ withActionBattleAnimationUnlocked(target, () => {
889
+ emitActionBattleClientVisual({
890
+ moment: "hit",
891
+ entity: this.event,
892
+ target,
893
+ damage: hitResult.damage,
894
+ result: hitResult,
895
+ animations: this.animations
896
+ });
897
+ });
898
+ setActionBattleInvincibility(target, profile.reaction.invincibilityMs);
899
+ if (hitResult.knockbackForce > 0) {
900
+ const dx = target.x() - this.event.x();
901
+ const dy = target.y() - this.event.y();
902
+ const distance = Math.sqrt(dx * dx + dy * dy);
903
+ if (distance > 0) {
904
+ const knockbackDirection = {
905
+ x: dx / distance,
906
+ y: dy / distance
907
+ };
908
+ target.knockback(knockbackDirection, hitResult.knockbackForce, hitResult.knockbackDuration);
909
+ }
910
+ }
911
+ if (hooks?.onAfterHit) hooks.onAfterHit(hitResult);
912
+ const targetAi = target.battleAi;
913
+ if (targetAi) targetAi.handleDamage(this.event, {
914
+ damage: hitResult.damage,
915
+ defeated: hitResult.defeated,
916
+ raw: void 0,
917
+ reaction: profile.reaction
918
+ });
919
+ return hitResult;
920
+ }
921
+ /**
922
+ * Get knockback force from equipped weapon
923
+ *
924
+ * Retrieves the knockbackForce property from the event's equipped weapon.
925
+ * Falls back to DEFAULT_KNOCKBACK.force if no weapon or property is set.
926
+ *
927
+ * @returns Knockback force value
928
+ *
929
+ * @example
930
+ * ```ts
931
+ * // Weapon with knockbackForce: 80
932
+ * const force = this.getWeaponKnockbackForce(); // 80
933
+ *
934
+ * // No weapon equipped
935
+ * const force = this.getWeaponKnockbackForce(); // 50 (default)
936
+ * ```
937
+ */
938
+ getWeaponKnockbackForce() {
939
+ try {
940
+ const equipments = this.event.equipments?.() || [];
941
+ for (const item of equipments) {
942
+ const itemData = this.event.databaseById?.(item.id());
943
+ if (itemData?._type === "weapon" && itemData.knockbackForce !== void 0) return itemData.knockbackForce;
944
+ }
945
+ } catch {}
946
+ return DEFAULT_KNOCKBACK.force;
947
+ }
948
+ /**
949
+ * Perform combo attack
950
+ */
951
+ performComboAttack() {
952
+ if (!this.target) return;
953
+ this.comboCount++;
954
+ const profile = this.getAttackProfile(AttackPattern.Combo);
955
+ this.faceTarget({ force: true });
956
+ this.telegraphAttack(profile);
957
+ withActionBattleAnimationUnlocked(this.event, () => {
958
+ emitActionBattleClientVisual({
959
+ moment: "attack",
960
+ entity: this.event,
961
+ target: this.target,
962
+ animations: this.animations
963
+ });
964
+ });
965
+ this.scheduleAttackStartup(profile, () => {
966
+ this.executeMeleeAttack(profile, AttackPattern.Combo);
967
+ });
968
+ if (this.comboCount < this.comboMax) this.schedule(() => {
969
+ if (this.target && this.state === AiState.Combat) this.performComboAttack();
970
+ else this.comboCount = 0;
971
+ }, 300);
972
+ else this.comboCount = 0;
973
+ }
974
+ /**
975
+ * Perform charged attack
976
+ */
977
+ performChargedAttack() {
978
+ if (!this.target) return;
979
+ const profile = this.getAttackProfile(AttackPattern.Charged);
980
+ this.chargingAttack = true;
981
+ this.faceTarget({ force: true });
982
+ this.telegraphAttack(profile);
983
+ withActionBattleAnimationUnlocked(this.event, () => {
984
+ emitActionBattleClientVisual({
985
+ moment: "attack",
986
+ entity: this.event,
987
+ target: this.target,
988
+ animations: this.animations,
989
+ animationDefaults: { repeat: 2 }
990
+ });
991
+ });
992
+ this.scheduleAttackStartup(profile, () => {
993
+ if (!this.target || this.state !== AiState.Combat) {
994
+ this.chargingAttack = false;
995
+ return;
996
+ }
997
+ this.executeMeleeAttack(profile, AttackPattern.Charged);
998
+ });
999
+ this.schedule(() => {
1000
+ this.chargingAttack = false;
1001
+ }, profile.totalDurationMs);
1002
+ }
1003
+ /**
1004
+ * Perform zone attack (360 degrees)
1005
+ */
1006
+ performZoneAttack() {
1007
+ const profile = this.getAttackProfile(AttackPattern.Zone);
1008
+ this.telegraphAttack(profile);
1009
+ withActionBattleAnimationUnlocked(this.event, () => {
1010
+ emitActionBattleClientVisual({
1011
+ moment: "attack",
1012
+ entity: this.event,
1013
+ target: this.target ?? void 0,
1014
+ animations: this.animations
1015
+ });
1016
+ });
1017
+ const eventX = this.event.x();
1018
+ const eventY = this.event.y();
1019
+ const radius = 50;
1020
+ const hitboxes = [];
1021
+ [
1022
+ 0,
1023
+ 90,
1024
+ 180,
1025
+ 270
1026
+ ].forEach((angle) => {
1027
+ const rad = angle * Math.PI / 180;
1028
+ hitboxes.push({
1029
+ x: eventX + Math.cos(rad) * radius,
1030
+ y: eventY + Math.sin(rad) * radius,
1031
+ width: 40,
1032
+ height: 40
1033
+ });
1034
+ });
1035
+ this.scheduleAttackStartup(profile, () => {
1036
+ const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
1037
+ runActionBattleActiveHitbox({
1038
+ ...profile,
1039
+ startupMs: 0
1040
+ }, () => hitboxes, (activeHitboxes) => {
1041
+ this.processHitboxHits(activeHitboxes, hitTracker, profile, AttackPattern.Zone);
1042
+ }, (scheduled, delay) => this.schedule(scheduled, delay));
1043
+ });
1044
+ }
1045
+ /**
1046
+ * Perform dash attack
1047
+ */
1048
+ performDashAttack() {
1049
+ if (!this.target || this.isTargetDefeated(this.target)) return;
1050
+ const profile = this.getAttackProfile(AttackPattern.DashAttack);
1051
+ const dx = this.target.x() - this.event.x();
1052
+ const dy = this.target.y() - this.event.y();
1053
+ const dist = Math.sqrt(dx * dx + dy * dy);
1054
+ if (dist === 0) return;
1055
+ const dirX = dx / dist;
1056
+ const dirY = dy / dist;
1057
+ this.faceTarget({ force: true });
1058
+ this.telegraphAttack(profile);
1059
+ this.scheduleAttackStartup(profile, () => {
1060
+ if (!this.target || this.state !== AiState.Combat) return;
1061
+ safeActionBattleDash(this.event, {
1062
+ x: dirX,
1063
+ y: dirY
1064
+ }, 10, 200);
1065
+ this.schedule(() => {
1066
+ if (!this.target || this.state !== AiState.Combat) return;
1067
+ this.executeMeleeAttack(profile, AttackPattern.DashAttack);
1068
+ }, 200);
1069
+ });
1070
+ }
1071
+ getAttackProfile(pattern) {
1072
+ return this.attackProfiles[pattern] ?? this.attackProfiles.melee;
1073
+ }
1074
+ telegraphAttack(profile) {
1075
+ if (profile.startupMs <= 0) return;
1076
+ this.event.flash({
1077
+ type: "tint",
1078
+ tint: "white",
1079
+ duration: Math.min(profile.startupMs, 300),
1080
+ cycles: 1
1081
+ });
1082
+ }
1083
+ scheduleAttackStartup(profile, callback) {
1084
+ return scheduleActionBattleStartup(profile, callback, (scheduled, delay) => this.schedule(scheduled, delay));
1085
+ }
1086
+ /**
1087
+ * Face the current target with hysteresis to prevent animation flickering
1088
+ *
1089
+ * Uses multiple strategies to prevent flickering:
1090
+ * 1. When very close to target (collision), keep current direction
1091
+ * 2. When near diagonal, require significant difference to change
1092
+ * 3. Only change if direction is clearly wrong (opposite)
1093
+ */
1094
+ faceTarget(options = {}) {
1095
+ if (!this.target) return;
1096
+ const dx = this.target.x() - this.event.x();
1097
+ const dy = this.target.y() - this.event.y();
1098
+ const absX = Math.abs(dx);
1099
+ const absY = Math.abs(dy);
1100
+ const distance = Math.sqrt(dx * dx + dy * dy);
1101
+ if (this.lastFacingDirection && distance < 4) return;
1102
+ let newDirection;
1103
+ if (absX >= absY) newDirection = dx >= 0 ? "right" : "left";
1104
+ else newDirection = dy >= 0 ? "down" : "up";
1105
+ const hysteresisThreshold = .2;
1106
+ const ratio = absX > 0 || absY > 0 ? Math.min(absX, absY) / Math.max(absX, absY) : 0;
1107
+ if (this.lastFacingDirection && ratio > 1 - hysteresisThreshold) {
1108
+ if (!(this.lastFacingDirection === "left" && dx > 20 || this.lastFacingDirection === "right" && dx < -20 || this.lastFacingDirection === "up" && dy > 20 || this.lastFacingDirection === "down" && dy < -20)) return;
1109
+ }
1110
+ this.lastFacingDirection = newDirection;
1111
+ if (options.force) applyActionBattleAttackDirection(this.event, newDirection);
1112
+ else this.event.changeDirection(newDirection);
1113
+ }
1114
+ /**
1115
+ * Try to dodge
1116
+ */
1117
+ tryDodge() {
1118
+ const currentTime = Date.now();
1119
+ if (currentTime - this.lastDodgeTime < this.dodgeCooldown) {
1120
+ this.debugLog("dodge", `Dodge on cooldown (${this.dodgeCooldown - (currentTime - this.lastDodgeTime)}ms remaining)`);
1121
+ return false;
1122
+ }
1123
+ if (Math.random() > this.dodgeChance) {
1124
+ this.debugLog("dodge", `Dodge roll failed (chance=${(this.dodgeChance * 100).toFixed(0)}%)`);
1125
+ return false;
1126
+ }
1127
+ if (!this.target) return false;
1128
+ const dx = this.target.x() - this.event.x();
1129
+ const dy = this.target.y() - this.event.y();
1130
+ const dist = Math.sqrt(dx * dx + dy * dy);
1131
+ if (dist === 0) return false;
1132
+ const dodgeDirX = -dy / dist;
1133
+ const dodgeDirY = dx / dist;
1134
+ const side = Math.random() > .5 ? 1 : -1;
1135
+ this.debugLog("dodge", `Dodging (dir=${side > 0 ? "right" : "left"})`);
1136
+ if (!safeActionBattleDash(this.event, {
1137
+ x: dodgeDirX * side,
1138
+ y: dodgeDirY * side
1139
+ }, 12, 300)) return false;
1140
+ this.lastDodgeTime = currentTime;
1141
+ if (this.enemyType === EnemyType.Defensive && Math.random() < .5) {
1142
+ this.debugLog("dodge", "Counter-attack after dodge");
1143
+ this.schedule(() => {
1144
+ if (this.target && this.state === AiState.Combat) this.selectAndPerformAttack();
1145
+ }, 400);
1146
+ }
1147
+ return true;
1148
+ }
1149
+ canDodge() {
1150
+ if (this.dodgeChance === 0) return false;
1151
+ return Date.now() - this.lastDodgeTime >= this.dodgeCooldown;
1152
+ }
1153
+ shouldDodge() {
1154
+ if (!this.target) return false;
1155
+ return this.getDistance(this.event, this.target) < this.attackRange * .8;
1156
+ }
1157
+ /**
1158
+ * Flee from target
1159
+ */
1160
+ fleeFromTarget() {
1161
+ if (!this.target) return;
1162
+ const dx = this.event.x() - this.target.x();
1163
+ const dy = this.event.y() - this.target.y();
1164
+ const dist = Math.sqrt(dx * dx + dy * dy);
1165
+ if (dist === 0) return;
1166
+ this.requestMoveTo({
1167
+ x: () => this.event.x() + dx / dist * 200,
1168
+ y: () => this.event.y() + dy / dist * 200
1169
+ });
1170
+ }
1171
+ /**
1172
+ * Retreat from target (temporary)
1173
+ */
1174
+ retreatFromTarget() {
1175
+ if (!this.target) return;
1176
+ const currentTime = Date.now();
1177
+ if (currentTime - this.lastRetreatTime < this.retreatCooldown) return;
1178
+ const dx = this.event.x() - this.target.x();
1179
+ const dy = this.event.y() - this.target.y();
1180
+ const dist = Math.sqrt(dx * dx + dy * dy);
1181
+ if (dist === 0) return;
1182
+ if (!safeActionBattleDash(this.event, {
1183
+ x: dx / dist,
1184
+ y: dy / dist
1185
+ }, 8, 200)) return;
1186
+ this.lastRetreatTime = currentTime;
1187
+ }
1188
+ /**
1189
+ * Check damage taken for retreat decision
1190
+ */
1191
+ checkDamageTaken() {
1192
+ const currentTime = Date.now();
1193
+ if (currentTime - this.lastHpCheck >= this.damageCheckInterval) {
1194
+ this.recentDamageTaken = 0;
1195
+ this.lastHpCheck = currentTime;
1196
+ }
1197
+ }
1198
+ /**
1199
+ * Start patrol
1200
+ */
1201
+ startPatrol() {
1202
+ if (this.patrolWaypoints.length === 0) return;
1203
+ const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
1204
+ this.requestMoveTo({
1205
+ x: () => waypoint.x,
1206
+ y: () => waypoint.y
1207
+ });
1208
+ }
1209
+ /**
1210
+ * Update group behavior
1211
+ */
1212
+ updateGroupBehavior() {
1213
+ if (!this.groupBehavior) return;
1214
+ this.groupUpdateInterval++;
1215
+ if (this.groupUpdateInterval >= 20) {
1216
+ this.groupUpdateInterval = 0;
1217
+ this.findNearbyEnemies();
1218
+ }
1219
+ if (this.nearbyEnemies.length > 0 && this.target && this.state === AiState.Combat) this.applyFormation();
1220
+ }
1221
+ /**
1222
+ * Find nearby enemies
1223
+ */
1224
+ findNearbyEnemies() {
1225
+ this.nearbyEnemies = [];
1226
+ const map = this.event.getCurrentMap();
1227
+ if (!map) return;
1228
+ const allEvents = Object.values(map.events());
1229
+ const groupRadius = 150;
1230
+ allEvents.forEach((event) => {
1231
+ if (event === this.event) return;
1232
+ const ai = event.battleAi;
1233
+ if (ai && ai.groupBehavior) {
1234
+ if (this.getDistance(this.event, event) <= groupRadius) this.nearbyEnemies.push(ai);
1235
+ }
1236
+ });
1237
+ }
1238
+ /**
1239
+ * Apply formation around target
1240
+ */
1241
+ applyFormation() {
1242
+ if (!this.target || this.nearbyEnemies.length === 0) return;
1243
+ const totalEnemies = this.nearbyEnemies.length + 1;
1244
+ const angleStep = 2 * Math.PI / totalEnemies;
1245
+ let ourIndex = 0;
1246
+ for (let i = 0; i < this.nearbyEnemies.length; i++) if (this.nearbyEnemies[i].event.id < this.event.id) ourIndex++;
1247
+ const angle = angleStep * ourIndex;
1248
+ const formationRadius = this.attackRange * 1.2;
1249
+ const formationX = this.target.x() + Math.cos(angle) * formationRadius;
1250
+ const formationY = this.target.y() + Math.sin(angle) * formationRadius;
1251
+ if (Math.sqrt(Math.pow(this.event.x() - formationX, 2) + Math.pow(this.event.y() - formationY, 2)) > 20) this.requestMoveTo({
1252
+ x: () => formationX,
1253
+ y: () => formationY
1254
+ });
1255
+ }
1256
+ /**
1257
+ * Handle player entering vision
1258
+ */
1259
+ onDetectInShape(target, shape) {
1260
+ if (!this.canTarget(target) || this.isTargetDefeated(target)) return;
1261
+ this.debugLog("vision", `Target ${target.id} entered vision (state=${this.state})`);
1262
+ this.engageTarget(target);
1263
+ }
1264
+ engageTarget(target) {
1265
+ this.target = target;
1266
+ if (this.state === AiState.Idle) this.changeState(AiState.Alert);
1267
+ else if (this.state === AiState.Alert) this.changeState(AiState.Combat);
1268
+ }
1269
+ /**
1270
+ * Handle player leaving vision
1271
+ */
1272
+ onDetectOutShape(target, shape) {
1273
+ this.debugLog("vision", `Target ${target.id} left vision (wasTarget=${this.target === target})`);
1274
+ if (this.target === target) {
1275
+ this.clearTarget();
1276
+ this.changeState(AiState.Idle);
1277
+ }
1278
+ }
1279
+ /**
1280
+ * Handle taking damage (called from server.ts)
1281
+ *
1282
+ * This triggers state changes like stun and flee check.
1283
+ * The actual damage is applied externally via RPGJS API.
1284
+ */
1285
+ takeDamage(attacker) {
1286
+ if (this.defeated) return true;
1287
+ const raw = this.event.applyDamage(attacker);
1288
+ return this.handleDamage(attacker, {
1289
+ damage: raw.damage ?? 0,
1290
+ defeated: this.event.hp <= 0,
1291
+ raw
1292
+ });
1293
+ }
1294
+ handleDamage(attacker, damageResult) {
1295
+ if (this.defeated) return true;
1296
+ const damage = damageResult.damage;
1297
+ this.debugLog("damage", `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || "?"})`);
1298
+ withActionBattleAnimationUnlocked(this.event, () => {
1299
+ emitActionBattleClientVisual({
1300
+ moment: "hurt",
1301
+ entity: this.event,
1302
+ target: this.event,
1303
+ attacker,
1304
+ damage,
1305
+ defeated: damageResult.defeated,
1306
+ result: damageResult,
1307
+ animations: this.animations
1308
+ });
1309
+ });
1310
+ this.recentDamageTaken += damage;
1311
+ const reaction = damageResult.reaction;
1312
+ const staggerPower = reaction?.staggerPower ?? damage;
1313
+ const hitstunMs = reaction?.hitstunMs ?? this.hitstunMs;
1314
+ const shouldStun = staggerPower >= this.poise && hitstunMs > 0;
1315
+ setActionBattleInvincibility(this.event, reaction?.invincibilityMs ?? this.invincibilityMs);
1316
+ if (shouldStun && this.state !== AiState.Stunned && this.state !== AiState.Flee) {
1317
+ this.debugLog("damage", "Stunned from damage");
1318
+ this.isMovingToTarget = false;
1319
+ this.stunnedUntil = Date.now() + hitstunMs;
1320
+ this.changeState(AiState.Stunned);
1321
+ }
1322
+ if (damageResult.defeated || this.event.hp <= 0) {
1323
+ this.debugLog("damage", "Defeated!");
1324
+ this.kill(attacker);
1325
+ return true;
1326
+ }
1327
+ return false;
1328
+ }
1329
+ /**
1330
+ * Kill this AI
1331
+ *
1332
+ * Stops all movements, cleans up resources, calls the onDefeated hook,
1333
+ * and removes the event from the map.
1334
+ */
1335
+ kill(attacker) {
1336
+ if (this.defeated) return;
1337
+ this.defeated = true;
1338
+ const dieAnimation = resolveActionBattleAnimation("die", this.event, this.animations, { attacker });
1339
+ const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
1340
+ const reward = createDefeatReward(this.rewards);
1341
+ let removed = false;
1342
+ const remove = () => {
1343
+ if (removed) return;
1344
+ removed = true;
1345
+ this.event.remove({
1346
+ reason: "defeated",
1347
+ data: { animation: dieAnimation },
1348
+ transition: dieAnimation ? {
1349
+ animation: dieAnimation.animationName,
1350
+ graphic: dieAnimation.graphic,
1351
+ duration: removeDelay
1352
+ } : void 0,
1353
+ timeoutMs: removeDelay
1354
+ });
1355
+ };
1356
+ if (this.autoAwardRewards && attacker && isActionBattlePlayer(attacker)) reward.giveTo(attacker);
1357
+ const context = {
1358
+ event: this.event,
1359
+ attacker,
1360
+ reward,
1361
+ remove
1362
+ };
1363
+ if (this.onDefeatedCallback) if (this.onDefeatedCallback.length >= 2) this.onDefeatedCallback(this.event, attacker);
1364
+ else this.onDefeatedCallback(context);
1365
+ this.destroy();
1366
+ remove();
1367
+ }
1368
+ /**
1369
+ * Get distance between entities
1370
+ */
1371
+ getDistance(entity1, entity2) {
1372
+ const dx = entity1.x() - entity2.x();
1373
+ const dy = entity1.y() - entity2.y();
1374
+ return Math.sqrt(dx * dx + dy * dy);
1375
+ }
1376
+ resolveUsable(usable) {
1377
+ if (!usable) return usable;
1378
+ const id = typeof usable === "string" ? usable : usable.id;
1379
+ const learned = id ? this.event.getSkill?.(id) : void 0;
1380
+ if (learned) return learned;
1381
+ try {
1382
+ return id ? this.event.databaseById?.(id) ?? usable : usable;
1383
+ } catch {
1384
+ return usable;
1385
+ }
1386
+ }
1387
+ getCurrentActionRange() {
1388
+ const skillRange = this.attackSkill ? getActionBattleActionRange(this.resolveUsable(this.attackSkill)) : void 0;
1389
+ if (skillRange !== void 0) return skillRange;
1390
+ return getActionBattleActionRange(resolveActionBattleWeapon(this.event));
1391
+ }
1392
+ canTarget(target) {
1393
+ return canActionBattleTarget(this.event, target, this.targets, getActionBattleOptions().combat?.targets);
1394
+ }
1395
+ findNearestTarget() {
1396
+ const map = this.event.getCurrentMap();
1397
+ if (!map) return null;
1398
+ const candidates = [];
1399
+ map.getPlayers?.().forEach((player) => candidates.push(player));
1400
+ map.getEvents?.().forEach((event) => candidates.push(event));
1401
+ let nearest = null;
1402
+ let nearestDistance = Number.POSITIVE_INFINITY;
1403
+ for (const candidate of candidates) {
1404
+ if (!this.canTarget(candidate)) continue;
1405
+ const distance = this.getDistance(this.event, candidate);
1406
+ if (distance > this.visionRange) continue;
1407
+ if (distance < nearestDistance) {
1408
+ nearest = candidate;
1409
+ nearestDistance = distance;
1410
+ }
1411
+ }
1412
+ return nearest;
1413
+ }
1414
+ isTargetDefeated(target) {
1415
+ return !target || target.hp <= 0;
1416
+ }
1417
+ clearTarget() {
1418
+ this.target = null;
1419
+ this.isMovingToTarget = false;
1420
+ this.event.stopMoveTo();
1421
+ }
1422
+ updateBehavior(currentTime) {
1423
+ if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) return;
1424
+ this.behaviorLastUpdate = currentTime;
1425
+ let score = this.behaviorScore;
1426
+ const maxHp = this.event.param[MAXHP];
1427
+ if (maxHp) {
1428
+ const hpPercent = this.event.hp / maxHp;
1429
+ score += (hpPercent - .5) * 40;
1430
+ }
1431
+ if (this.recentDamageTaken > 0) score -= Math.min(30, this.recentDamageTaken * .5);
1432
+ if (this.target) {
1433
+ const distance = this.getDistance(this.event, this.target);
1434
+ if (distance <= this.attackRange) score += 10;
1435
+ else if (distance > this.visionRange) score -= 10;
1436
+ }
1437
+ if (this.groupBehavior && this.nearbyEnemies.length > 0) score += Math.min(15, this.nearbyEnemies.length * 5);
1438
+ score = Math.max(0, Math.min(100, score));
1439
+ this.behaviorScore = score;
1440
+ const previousMode = this.behaviorMode;
1441
+ if (score >= this.behaviorAssaultThreshold) this.behaviorMode = "assault";
1442
+ else if (score <= this.behaviorRetreatThreshold) this.behaviorMode = "retreat";
1443
+ else this.behaviorMode = "tactical";
1444
+ if (previousMode !== this.behaviorMode) this.debugLog("state", `Behavior mode: ${previousMode} -> ${this.behaviorMode} (score=${score.toFixed(0)})`);
1445
+ if (this.behaviorMode === "retreat" && this.state === AiState.Combat) {
1446
+ if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) {
1447
+ this.isMovingToTarget = false;
1448
+ this.changeState(AiState.Flee);
1449
+ }
1450
+ } else if (this.behaviorMode === "assault" && this.state === AiState.Flee) {
1451
+ if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) this.changeState(AiState.Combat);
1452
+ }
1453
+ }
1454
+ applyCustomBehavior(currentTime) {
1455
+ let handled = false;
1456
+ if (this.behaviorTree) {
1457
+ const result = this.behaviorTree.tick(this.createAiTreeContext(currentTime));
1458
+ if (result.decision) handled = this.applyAiDecision(result.decision, currentTime) || handled;
1459
+ if (result.intent) handled = this.executeAiIntents(result.intent, currentTime) || handled;
1460
+ if (result.status === "running") handled = true;
1461
+ }
1462
+ if (!this.behaviorKey) return handled;
1463
+ const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
1464
+ if (!behavior) return handled;
1465
+ const maxHp = this.event.param[MAXHP];
1466
+ const decision = behavior({
1467
+ event: this.event,
1468
+ target: this.target,
1469
+ state: this.state,
1470
+ enemyType: this.enemyType,
1471
+ distance: this.target ? this.getDistance(this.event, this.target) : null,
1472
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
1473
+ now: currentTime
1474
+ });
1475
+ if (!decision) return handled;
1476
+ return this.applyAiDecision(decision, currentTime) || handled;
1477
+ }
1478
+ applyAiDecision(decision, currentTime) {
1479
+ if (!decision) return false;
1480
+ let handled = false;
1481
+ if (decision.attackCooldown !== void 0) this.attackCooldown = decision.attackCooldown;
1482
+ if (decision.moveToCooldown !== void 0) this.moveToCooldown = decision.moveToCooldown;
1483
+ if (decision.attackPatterns?.length) this.attackPatterns = decision.attackPatterns;
1484
+ if (decision.mode) {
1485
+ this.behaviorMode = decision.mode;
1486
+ this.behaviorEnabled = true;
1487
+ }
1488
+ if (decision.intent) handled = this.executeAiIntents(decision.intent, currentTime);
1489
+ return handled;
1490
+ }
1491
+ createAiTreeContext(currentTime) {
1492
+ const maxHp = this.event.param[MAXHP];
1493
+ const distance = this.target ? this.getDistance(this.event, this.target) : null;
1494
+ return {
1495
+ event: this.event,
1496
+ target: this.target,
1497
+ state: this.state,
1498
+ enemyType: this.enemyType,
1499
+ distance,
1500
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
1501
+ now: currentTime,
1502
+ self: {
1503
+ event: this.event,
1504
+ state: this.state,
1505
+ enemyType: this.enemyType,
1506
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
1507
+ attackRange: this.attackRange
1508
+ },
1509
+ targetInfo: this.target && distance !== null ? {
1510
+ entity: this.target,
1511
+ distance,
1512
+ inAttackRange: distance <= this.attackRange,
1513
+ visible: true
1514
+ } : null,
1515
+ memory: this.aiMemory
1516
+ };
1517
+ }
1518
+ executeAiIntents(input, currentTime) {
1519
+ const intents = Array.isArray(input) ? input : [input];
1520
+ let handled = false;
1521
+ for (const intent of intents) handled = this.executeAiIntent(intent, currentTime) || handled;
1522
+ return handled;
1523
+ }
1524
+ executeAiIntent(intent, currentTime) {
1525
+ const consumes = intent.consume !== false;
1526
+ switch (intent.type) {
1527
+ case "setMode":
1528
+ this.behaviorMode = intent.mode;
1529
+ this.behaviorEnabled = true;
1530
+ return consumes;
1531
+ case "idle":
1532
+ this.isMovingToTarget = false;
1533
+ this.event.stopMoveTo();
1534
+ return consumes;
1535
+ case "patrol":
1536
+ this.startPatrol();
1537
+ return consumes;
1538
+ case "faceTarget":
1539
+ this.faceTarget();
1540
+ return consumes;
1541
+ case "moveToTarget":
1542
+ if (!this.target) return false;
1543
+ this.isMovingToTarget = true;
1544
+ this.requestMoveTo(this.target);
1545
+ return consumes;
1546
+ case "fleeFromTarget":
1547
+ if (!this.target) return false;
1548
+ this.isMovingToTarget = false;
1549
+ if (this.state === AiState.Combat) this.changeState(AiState.Flee);
1550
+ else this.fleeFromTarget();
1551
+ return consumes;
1552
+ case "keepDistance": return this.executeKeepDistance(intent, consumes);
1553
+ case "useAttack": return this.executeRequestedAttack(intent.pattern, currentTime, consumes);
1554
+ case "useSkill": return this.executeRequestedSkill(intent.skill, currentTime, consumes);
1555
+ }
1556
+ }
1557
+ executeKeepDistance(intent, consumes) {
1558
+ if (!this.target) return false;
1559
+ const tolerance = intent.tolerance ?? Math.max(8, intent.distance * .15);
1560
+ const distance = this.getDistance(this.event, this.target);
1561
+ if (distance < intent.distance - tolerance) {
1562
+ this.isMovingToTarget = false;
1563
+ this.retreatFromTarget();
1564
+ return consumes;
1565
+ }
1566
+ if (distance > intent.distance + tolerance) {
1567
+ this.isMovingToTarget = true;
1568
+ this.requestMoveTo(this.target);
1569
+ return consumes;
1570
+ }
1571
+ if (this.isMovingToTarget) {
1572
+ this.isMovingToTarget = false;
1573
+ this.event.stopMoveTo();
1574
+ }
1575
+ return consumes;
1576
+ }
1577
+ executeRequestedAttack(pattern, currentTime, consumes) {
1578
+ if (!this.target || this.isTargetDefeated(this.target) || this.chargingAttack) return false;
1579
+ if (this.getDistance(this.event, this.target) > this.attackRange) return false;
1580
+ if (currentTime - this.lastAttackTime < this.attackCooldown) return false;
1581
+ if (pattern) this.performAttackPattern(pattern);
1582
+ else this.selectAndPerformAttack();
1583
+ this.lastAttackTime = currentTime;
1584
+ return consumes;
1585
+ }
1586
+ executeRequestedSkill(skill, currentTime, consumes) {
1587
+ if (!this.target || this.isTargetDefeated(this.target) || !skill) return false;
1588
+ const distance = this.getDistance(this.event, this.target);
1589
+ const resolvedSkill = this.resolveUsable(skill);
1590
+ const range = getActionBattleActionRange(resolvedSkill) ?? this.attackRange;
1591
+ const cooldownRemaining = this.attackCooldown - (currentTime - this.lastAttackTime);
1592
+ if (distance > range) return false;
1593
+ if (cooldownRemaining > 0) return false;
1594
+ executeActionBattleUse({
1595
+ attacker: this.event,
1596
+ target: this.target,
1597
+ usable: resolvedSkill,
1598
+ skill: resolvedSkill,
1599
+ profile: this.getAttackProfile(AttackPattern.Melee)
1600
+ });
1601
+ this.lastAttackTime = currentTime;
1602
+ return consumes;
1603
+ }
1604
+ handleTacticalMovement(distance) {
1605
+ if (!this.target) return;
1606
+ const minRange = this.attackRange * .7;
1607
+ const maxRange = this.attackRange * 1.2;
1608
+ if (distance < minRange) {
1609
+ this.debugLog("movement", `Tactical retreat (dist=${distance.toFixed(1)}, minRange=${minRange.toFixed(1)})`);
1610
+ this.isMovingToTarget = false;
1611
+ this.retreatFromTarget();
1612
+ return;
1613
+ }
1614
+ if (distance > maxRange) {
1615
+ if (!this.isMovingToTarget) {
1616
+ this.debugLog("movement", `Tactical approach (dist=${distance.toFixed(1)}, maxRange=${maxRange.toFixed(1)})`);
1617
+ this.isMovingToTarget = true;
1618
+ this.requestMoveTo(this.target);
1619
+ }
1620
+ return;
1621
+ }
1622
+ if (this.isMovingToTarget) {
1623
+ this.debugLog("movement", `Tactical hold (dist=${distance.toFixed(1)})`);
1624
+ this.isMovingToTarget = false;
1625
+ this.event.stopMoveTo();
1626
+ }
1627
+ }
1628
+ handleAssaultMovement(distance) {
1629
+ if (!this.target) return;
1630
+ if (distance > this.attackRange) {
1631
+ if (!this.isMovingToTarget) {
1632
+ this.debugLog("movement", `Assault approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1633
+ this.isMovingToTarget = true;
1634
+ this.requestMoveTo(this.target);
1635
+ }
1636
+ return;
1637
+ }
1638
+ if (this.isMovingToTarget) {
1639
+ this.debugLog("movement", `Assault hold (dist=${distance.toFixed(1)})`);
1640
+ this.isMovingToTarget = false;
1641
+ this.event.stopMoveTo();
1642
+ }
1643
+ }
1644
+ requestMoveTo(target) {
1645
+ const currentTime = Date.now();
1646
+ if (currentTime - this.lastMoveToTime < this.moveToCooldown) return false;
1647
+ this.event.moveTo(target);
1648
+ this.lastMoveToTime = currentTime;
1649
+ return true;
1650
+ }
1651
+ schedule(callback, delay) {
1652
+ const timer = setTimeout(() => {
1653
+ this.timers = this.timers.filter((entry) => entry !== timer);
1654
+ if (this.destroyed) return;
1655
+ callback();
1656
+ }, delay);
1657
+ this.timers.push(timer);
1658
+ return timer;
1659
+ }
1660
+ getHealth() {
1661
+ return this.event.hp;
1662
+ }
1663
+ getMaxHealth() {
1664
+ return this.event.param[MAXHP];
1665
+ }
1666
+ getTarget() {
1667
+ return this.target;
1668
+ }
1669
+ getState() {
1670
+ return this.state;
1671
+ }
1672
+ getFaction() {
1673
+ return this.faction;
1674
+ }
1675
+ setFaction(faction) {
1676
+ this.faction = faction;
1677
+ }
1678
+ getTargets() {
1679
+ return this.targets;
1680
+ }
1681
+ setTargets(targets) {
1682
+ this.targets = targets;
1683
+ if (this.target && !this.canTarget(this.target)) {
1684
+ this.clearTarget();
1685
+ this.changeState(AiState.Idle);
1686
+ }
1687
+ }
1688
+ getEnemyType() {
1689
+ return this.enemyType;
1690
+ }
1691
+ /**
1692
+ * Clean up
1693
+ */
1694
+ destroy() {
1695
+ this.destroyed = true;
1696
+ if (this.updateInterval) {
1697
+ clearInterval(this.updateInterval);
1698
+ this.updateInterval = void 0;
1699
+ }
1700
+ this.target = null;
1701
+ this.nearbyEnemies = [];
1702
+ this.timers.forEach((timer) => clearTimeout(timer));
1703
+ this.timers = [];
1704
+ }
1705
+ };
1706
+ //#endregion
1707
+ export { AiDebug, AiState, AttackPattern, BattleAi, DEFAULT_KNOCKBACK, EnemyType };