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

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