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