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