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

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 +49 -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 +15 -15
  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 +7 -5
  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
package/src/ai.server.ts CHANGED
@@ -1,9 +1,111 @@
1
1
  import { MAXHP, RpgEvent, RpgPlayer } from "@rpgjs/server";
2
+ import {
3
+ getActionBattleAnimationRemovalDelay,
4
+ playActionBattleAnimation,
5
+ resolveActionBattleAnimation,
6
+ } from "./animations";
7
+ import { getActionBattleOptions } from "./config";
8
+ import { getActionBattleSystems } from "./core/context";
9
+ import {
10
+ isActionBattleEntityInvincible,
11
+ setActionBattleInvincibility,
12
+ } from "./core/hit-reaction";
13
+ import {
14
+ normalizeActionBattleEnemyAttackProfiles,
15
+ type ActionBattleEnemyAttackProfileMap,
16
+ type NormalizedActionBattleEnemyAttackProfileMap,
17
+ } from "./core/enemy-attack-profiles";
18
+ import {
19
+ resolveActionBattleHitboxSpeed,
20
+ scheduleActionBattleStartup,
21
+ } from "./core/attack-runtime";
22
+ import type { ActionBattleDamageResult } from "./core/contracts";
23
+ import type {
24
+ NormalizedActionBattleAttackProfile,
25
+ NormalizedActionBattleHitReactionProfile,
26
+ } from "./types";
27
+ import type { ActionBattleAnimationOptions } from "./types";
2
28
 
3
29
  type RpgEventWithBattleAi = RpgEvent & {
4
- battleAi: BattleAi;
30
+ battleAi?: BattleAi;
5
31
  };
6
32
 
33
+ export interface BattleAiRewardItem {
34
+ item?: any;
35
+ itemId?: string;
36
+ amount?: number;
37
+ chance?: number;
38
+ }
39
+
40
+ export interface BattleAiRewards {
41
+ exp?: number;
42
+ gold?: number;
43
+ items?: Array<BattleAiRewardItem | string>;
44
+ showNotification?: boolean;
45
+ }
46
+
47
+ export interface BattleAiDefeatReward {
48
+ readonly awarded: boolean;
49
+ giveTo(player?: RpgPlayer | null): void;
50
+ }
51
+
52
+ export interface BattleAiDefeatedContext {
53
+ event: RpgEvent;
54
+ attacker?: RpgPlayer;
55
+ reward: BattleAiDefeatReward;
56
+ remove: () => void;
57
+ }
58
+
59
+ export type BattleAiDefeatedCallback = (
60
+ context: BattleAiDefeatedContext
61
+ ) => void;
62
+
63
+ export type BattleAiLegacyDefeatedCallback = (
64
+ event: RpgEvent,
65
+ attacker?: RpgPlayer
66
+ ) => void;
67
+
68
+ export interface BattleAiBaseOptions {
69
+ enemyType?: EnemyType;
70
+ attackCooldown?: number;
71
+ visionRange?: number;
72
+ attackRange?: number;
73
+ dodgeChance?: number;
74
+ dodgeCooldown?: number;
75
+ fleeThreshold?: number;
76
+ attackSkill?: any;
77
+ attackPatterns?: AttackPattern[];
78
+ attackProfiles?: ActionBattleEnemyAttackProfileMap;
79
+ patrolWaypoints?: Array<{ x: number; y: number }>;
80
+ groupBehavior?: boolean;
81
+ moveToCooldown?: number;
82
+ retreatCooldown?: number;
83
+ poise?: number;
84
+ hitstunMs?: number;
85
+ invincibilityMs?: number;
86
+ behavior?: {
87
+ baseScore?: number;
88
+ updateInterval?: number;
89
+ minStateDuration?: number;
90
+ assaultThreshold?: number;
91
+ retreatThreshold?: number;
92
+ };
93
+ behaviorKey?: string;
94
+ animations?: ActionBattleAnimationOptions;
95
+ rewards?: BattleAiRewards;
96
+ autoAwardRewards?: boolean;
97
+ }
98
+
99
+ export interface BattleAiOptions extends BattleAiBaseOptions {
100
+ /** Callback called when the AI is defeated */
101
+ onDefeated?: BattleAiDefeatedCallback;
102
+ }
103
+
104
+ export interface BattleAiLegacyOptions extends BattleAiBaseOptions {
105
+ /** @deprecated Use the context callback signature instead. */
106
+ onDefeated?: BattleAiLegacyDefeatedCallback;
107
+ }
108
+
7
109
  /**
8
110
  * Hit result data returned after applying damage
9
111
  *
@@ -38,6 +140,84 @@ export interface HitResult {
38
140
  target: RpgPlayer | RpgEvent;
39
141
  }
40
142
 
143
+ const normalizeRewardItem = (
144
+ item: BattleAiRewardItem | string
145
+ ): BattleAiRewardItem => {
146
+ if (typeof item === "string") {
147
+ return { itemId: item, amount: 1, chance: 100 };
148
+ }
149
+ return {
150
+ ...item,
151
+ amount: item.amount ?? 1,
152
+ chance: item.chance ?? 100,
153
+ };
154
+ };
155
+
156
+ const getRewardItemRef = (item: BattleAiRewardItem) => item.item ?? item.itemId;
157
+
158
+ const getPlayerMap = (player: RpgPlayer) => {
159
+ return typeof (player as any).getCurrentMap === "function"
160
+ ? (player as any).getCurrentMap()
161
+ : undefined;
162
+ };
163
+
164
+ const getRewardItemName = (inventoryItem: any, itemRef: any): string => {
165
+ if (inventoryItem && typeof inventoryItem.name === "function") {
166
+ return inventoryItem.name();
167
+ }
168
+ if (inventoryItem?.name) return inventoryItem.name;
169
+ if (typeof itemRef === "string") return itemRef;
170
+ if (itemRef?.name) return itemRef.name;
171
+ if (itemRef?.id) return itemRef.id;
172
+ return "item";
173
+ };
174
+
175
+ const createDefeatReward = (
176
+ rewards: BattleAiRewards | undefined
177
+ ): BattleAiDefeatReward => {
178
+ let awarded = false;
179
+ return {
180
+ get awarded() {
181
+ return awarded;
182
+ },
183
+ giveTo(player?: RpgPlayer | null) {
184
+ if (!player || awarded || !rewards) return;
185
+ awarded = true;
186
+
187
+ const exp = rewards.exp ?? 0;
188
+ const gold = rewards.gold ?? 0;
189
+ if (exp > 0) player.exp += exp;
190
+ if (gold > 0) player.gold += gold;
191
+
192
+ if (rewards.showNotification && (exp > 0 || gold > 0)) {
193
+ player.showNotification(`You won ${exp} experience and ${gold} gold`);
194
+ }
195
+
196
+ for (const rawItem of rewards.items ?? []) {
197
+ const item = normalizeRewardItem(rawItem);
198
+ const itemRef = getRewardItemRef(item);
199
+ if (!itemRef) continue;
200
+ if (Math.random() * 100 >= (item.chance ?? 100)) continue;
201
+
202
+ const amount = item.amount ?? 1;
203
+ const inventoryItem = player.addItem(itemRef, amount);
204
+ if (rewards.showNotification) {
205
+ const itemData =
206
+ typeof itemRef === "string"
207
+ ? getPlayerMap(player)?.database?.()?.[itemRef]
208
+ : undefined;
209
+ player.showNotification(
210
+ `You won ${amount} ${getRewardItemName(inventoryItem, itemRef)}`,
211
+ {
212
+ icon: itemData?.icon,
213
+ }
214
+ );
215
+ }
216
+ }
217
+ },
218
+ };
219
+ };
220
+
41
221
  /**
42
222
  * Hook options for customizing hit behavior
43
223
  *
@@ -99,7 +279,9 @@ export interface ApplyHitHooks {
99
279
  */
100
280
  export const AiDebug = {
101
281
  /** Enable/disable all AI debug logs */
102
- enabled: (typeof process !== 'undefined' && process.env?.RPGJS_DEBUG_AI === '1') || false,
282
+ enabled:
283
+ ((globalThis as { process?: { env?: Record<string, string> } }).process
284
+ ?.env?.RPGJS_DEBUG_AI === "1") || false,
103
285
 
104
286
  /** Filter logs to a specific event ID (null = all events) */
105
287
  filterEventId: null as string | null,
@@ -243,21 +425,23 @@ export class BattleAi {
243
425
 
244
426
  // Enemy type and behavior
245
427
  private enemyType: EnemyType;
246
- private attackCooldown: number;
247
- private visionRange: number;
248
- private attackRange: number;
428
+ private attackCooldown: number = 1000;
429
+ private visionRange: number = 150;
430
+ private attackRange: number = 60;
249
431
 
250
432
  // Dodge system
251
- private dodgeChance: number;
252
- private dodgeCooldown: number;
433
+ private dodgeChance: number = 0.2;
434
+ private dodgeCooldown: number = 2000;
253
435
  private lastDodgeTime: number = 0;
254
436
 
255
437
  // Flee threshold (HP percentage)
256
- private fleeThreshold: number;
438
+ private fleeThreshold: number = 0.2;
257
439
 
258
440
  // Attack configuration
259
441
  private attackSkill: any | null; // Skill to use for attacks
260
442
  private attackPatterns: AttackPattern[];
443
+ private attackProfiles: NormalizedActionBattleEnemyAttackProfileMap;
444
+ private animations?: ActionBattleAnimationOptions;
261
445
  private comboCount: number = 0;
262
446
  private comboMax: number = 3;
263
447
  private chargingAttack: boolean = false;
@@ -280,7 +464,12 @@ export class BattleAi {
280
464
  private isMovingToTarget: boolean = false;
281
465
 
282
466
  // Callback when AI is defeated
283
- private onDefeatedCallback?: (event: RpgEvent, attacker?: RpgPlayer) => void;
467
+ private onDefeatedCallback?:
468
+ | BattleAiDefeatedCallback
469
+ | BattleAiLegacyDefeatedCallback;
470
+ private rewards?: BattleAiRewards;
471
+ private autoAwardRewards: boolean = true;
472
+ private defeated: boolean = false;
284
473
 
285
474
  // Direction hysteresis to prevent animation flickering
286
475
  private lastFacingDirection: string | null = null;
@@ -300,6 +489,11 @@ export class BattleAi {
300
489
  private lastMoveToTime: number = 0;
301
490
  private retreatCooldown: number = 600;
302
491
  private lastRetreatTime: number = 0;
492
+ private timers: ReturnType<typeof setTimeout>[] = [];
493
+ private behaviorKey?: string;
494
+ private poise: number = 0;
495
+ private hitstunMs: number = 150;
496
+ private invincibilityMs: number = 250;
303
497
 
304
498
  /**
305
499
  * Create a new Battle AI Controller
@@ -325,42 +519,26 @@ export class BattleAi {
325
519
  * });
326
520
  * ```
327
521
  */
522
+ constructor(event: RpgEventWithBattleAi, options?: BattleAiOptions);
523
+ constructor(event: RpgEventWithBattleAi, options?: BattleAiLegacyOptions);
328
524
  constructor(
329
525
  event: RpgEventWithBattleAi,
330
- options: {
331
- enemyType?: EnemyType;
332
- attackCooldown?: number;
333
- visionRange?: number;
334
- attackRange?: number;
335
- dodgeChance?: number;
336
- dodgeCooldown?: number;
337
- fleeThreshold?: number;
338
- attackSkill?: any;
339
- attackPatterns?: AttackPattern[];
340
- patrolWaypoints?: Array<{ x: number; y: number }>;
341
- groupBehavior?: boolean;
342
- moveToCooldown?: number;
343
- retreatCooldown?: number;
344
- behavior?: {
345
- baseScore?: number;
346
- updateInterval?: number;
347
- minStateDuration?: number;
348
- assaultThreshold?: number;
349
- retreatThreshold?: number;
350
- };
351
- /** Callback called when the AI is defeated */
352
- onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
353
- } = {}
526
+ options: BattleAiOptions | BattleAiLegacyOptions = {}
354
527
  ) {
355
528
  event.battleAi = this;
356
529
  this.event = event;
357
530
 
358
531
  // Set enemy type and apply behavior modifiers
359
532
  this.enemyType = options.enemyType || EnemyType.Aggressive;
533
+ this.behaviorKey = options.behaviorKey ?? this.enemyType;
360
534
  this.applyEnemyTypeBehavior(options);
361
535
 
362
536
  // Store attack skill reference
363
537
  this.attackSkill = options.attackSkill || null;
538
+ this.animations = {
539
+ ...getActionBattleOptions().animations,
540
+ ...options.animations,
541
+ };
364
542
 
365
543
  // Initialize attack patterns
366
544
  this.attackPatterns = options.attackPatterns || [
@@ -368,6 +546,9 @@ export class BattleAi {
368
546
  AttackPattern.Combo,
369
547
  AttackPattern.DashAttack
370
548
  ];
549
+ this.attackProfiles = normalizeActionBattleEnemyAttackProfiles(
550
+ options.attackProfiles
551
+ );
371
552
 
372
553
  // Initialize group behavior
373
554
  this.groupBehavior = options.groupBehavior || false;
@@ -378,6 +559,8 @@ export class BattleAi {
378
559
 
379
560
  // Initialize defeat callback
380
561
  this.onDefeatedCallback = options.onDefeated;
562
+ this.rewards = options.rewards;
563
+ this.autoAwardRewards = options.autoAwardRewards ?? true;
381
564
 
382
565
  // Behavior gauge settings
383
566
  if (options.behavior) {
@@ -405,11 +588,22 @@ export class BattleAi {
405
588
  if (options.retreatCooldown !== undefined) {
406
589
  this.retreatCooldown = options.retreatCooldown;
407
590
  }
591
+ if (options.poise !== undefined) {
592
+ this.poise = Math.max(0, options.poise);
593
+ }
594
+ if (options.hitstunMs !== undefined) {
595
+ this.hitstunMs = Math.max(0, options.hitstunMs);
596
+ }
597
+ if (options.invincibilityMs !== undefined) {
598
+ this.invincibilityMs = Math.max(0, options.invincibilityMs);
599
+ }
408
600
 
409
601
  // Setup AI systems
410
602
  this.setupVision();
411
603
  this.startAiBehaviorLoop();
412
- this.changeState(AiState.Idle);
604
+ if (this.patrolWaypoints.length > 0) {
605
+ this.startPatrol();
606
+ }
413
607
 
414
608
  this.debugLog('init', `AI created (type=${this.enemyType}, visionRange=${this.visionRange}, attackRange=${this.attackRange})`);
415
609
  }
@@ -516,6 +710,9 @@ export class BattleAi {
516
710
  * Change AI state with validated transitions
517
711
  */
518
712
  private changeState(newState: AiState) {
713
+ if (newState === this.state) {
714
+ return;
715
+ }
519
716
  const validTransitions: Record<AiState, AiState[]> = {
520
717
  [AiState.Idle]: [AiState.Alert, AiState.Combat],
521
718
  [AiState.Alert]: [AiState.Idle, AiState.Combat],
@@ -587,6 +784,8 @@ export class BattleAi {
587
784
  this.updateBehavior(currentTime);
588
785
  }
589
786
 
787
+ this.applyCustomBehavior(currentTime);
788
+
590
789
  // State-specific behavior
591
790
  switch (this.state) {
592
791
  case AiState.Idle:
@@ -620,6 +819,7 @@ export class BattleAi {
620
819
 
621
820
  if (distance < 10) {
622
821
  this.currentPatrolIndex = (this.currentPatrolIndex + 1) % this.patrolWaypoints.length;
822
+ this.startPatrol();
623
823
  }
624
824
  }
625
825
  }
@@ -676,9 +876,10 @@ export class BattleAi {
676
876
  // Try dodge
677
877
  if (this.canDodge() && this.shouldDodge()) {
678
878
  this.debugLog('combat', 'Attempting dodge');
679
- this.isMovingToTarget = false;
680
- this.tryDodge();
681
- return;
879
+ if (this.tryDodge()) {
880
+ this.isMovingToTarget = false;
881
+ return;
882
+ }
682
883
  }
683
884
 
684
885
  if (this.behaviorEnabled) {
@@ -865,27 +1066,52 @@ export class BattleAi {
865
1066
  */
866
1067
  private performMeleeAttack() {
867
1068
  if (!this.target) return;
1069
+ const profile = this.getAttackProfile(AttackPattern.Melee);
868
1070
 
869
1071
  this.faceTarget();
870
- this.event.setGraphicAnimation('attack', 1);
1072
+ this.telegraphAttack(profile);
1073
+ playActionBattleAnimation("attack", this.event, this.animations, {
1074
+ target: this.target,
1075
+ });
1076
+
1077
+ this.scheduleAttackStartup(profile, () => {
1078
+ this.executeMeleeAttack(profile, AttackPattern.Melee);
1079
+ });
1080
+ }
1081
+
1082
+ private executeMeleeAttack(
1083
+ profile: NormalizedActionBattleAttackProfile,
1084
+ pattern: AttackPattern
1085
+ ) {
1086
+ if (!this.target) return;
1087
+ this.debugLog('attack', `Applying ${pattern} hit`);
871
1088
 
872
1089
  // Use skill if available
873
1090
  if (this.attackSkill) {
874
1091
  try {
1092
+ playActionBattleAnimation("castSkill", this.event, this.animations, {
1093
+ skill: this.attackSkill,
1094
+ target: this.target,
1095
+ });
875
1096
  this.event.useSkill(this.attackSkill, this.target);
876
1097
  } catch (e) {
877
1098
  // Skill failed (no SP, etc.) - fall back to basic attack
878
- this.performBasicHitbox();
1099
+ this.performBasicHitbox(profile, pattern);
879
1100
  }
880
1101
  } else {
881
- this.performBasicHitbox();
1102
+ this.performBasicHitbox(profile, pattern);
882
1103
  }
883
1104
  }
884
1105
 
885
1106
  /**
886
1107
  * Perform basic hitbox attack when no skill is set
887
1108
  */
888
- private performBasicHitbox() {
1109
+ private performBasicHitbox(
1110
+ profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
1111
+ AttackPattern.Melee
1112
+ ),
1113
+ pattern: AttackPattern = AttackPattern.Melee
1114
+ ) {
889
1115
  if (!this.target) return;
890
1116
 
891
1117
  const eventX = this.event.x();
@@ -907,11 +1133,13 @@ export class BattleAi {
907
1133
  }];
908
1134
 
909
1135
  const map = this.event.getCurrentMap();
910
- map?.createMovingHitbox(hitboxes, { speed: 5 }).subscribe({
911
- next: (hits) => {
912
- hits.forEach((hit) => {
1136
+ map?.createMovingHitbox(hitboxes, {
1137
+ speed: resolveActionBattleHitboxSpeed(profile, hitboxes.length),
1138
+ }).subscribe({
1139
+ next: (hits: any[]) => {
1140
+ hits.forEach((hit: any) => {
913
1141
  if (hit instanceof RpgPlayer && hit !== this.event) {
914
- this.applyHit(hit);
1142
+ this.applyHit(hit, undefined, profile, pattern);
915
1143
  }
916
1144
  });
917
1145
  },
@@ -946,7 +1174,25 @@ export class BattleAi {
946
1174
  * });
947
1175
  * ```
948
1176
  */
949
- private applyHit(target: RpgPlayer, hooks?: ApplyHitHooks): HitResult {
1177
+ private applyHit(
1178
+ target: RpgPlayer,
1179
+ hooks?: ApplyHitHooks,
1180
+ profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
1181
+ AttackPattern.Melee
1182
+ ),
1183
+ pattern: AttackPattern = AttackPattern.Melee
1184
+ ): HitResult {
1185
+ if (isActionBattleEntityInvincible(target)) {
1186
+ return {
1187
+ damage: 0,
1188
+ knockbackForce: 0,
1189
+ knockbackDuration: 0,
1190
+ defeated: false,
1191
+ attacker: this.event,
1192
+ target
1193
+ };
1194
+ }
1195
+
950
1196
  // Use RPGJS damage formula
951
1197
  const { damage } = target.applyDamage(this.event as any);
952
1198
 
@@ -980,6 +1226,10 @@ export class BattleAi {
980
1226
  cycles: 1
981
1227
  });
982
1228
  target.showHit(`-${hitResult.damage}`);
1229
+ setActionBattleInvincibility(
1230
+ target,
1231
+ profile.reaction.invincibilityMs
1232
+ );
983
1233
 
984
1234
  // Apply knockback
985
1235
  if (hitResult.knockbackForce > 0) {
@@ -1043,10 +1293,18 @@ export class BattleAi {
1043
1293
  if (!this.target) return;
1044
1294
 
1045
1295
  this.comboCount++;
1046
- this.performMeleeAttack();
1296
+ const profile = this.getAttackProfile(AttackPattern.Combo);
1297
+ this.faceTarget();
1298
+ this.telegraphAttack(profile);
1299
+ playActionBattleAnimation("attack", this.event, this.animations, {
1300
+ target: this.target,
1301
+ });
1302
+ this.scheduleAttackStartup(profile, () => {
1303
+ this.executeMeleeAttack(profile, AttackPattern.Combo);
1304
+ });
1047
1305
 
1048
1306
  if (this.comboCount < this.comboMax) {
1049
- setTimeout(() => {
1307
+ this.schedule(() => {
1050
1308
  if (this.target && this.state === AiState.Combat) {
1051
1309
  this.performComboAttack();
1052
1310
  } else {
@@ -1063,37 +1321,42 @@ export class BattleAi {
1063
1321
  */
1064
1322
  private performChargedAttack() {
1065
1323
  if (!this.target) return;
1324
+ const profile = this.getAttackProfile(AttackPattern.Charged);
1066
1325
 
1067
1326
  this.chargingAttack = true;
1068
1327
  this.faceTarget();
1069
- this.event.setGraphicAnimation('attack', 2);
1328
+ this.telegraphAttack(profile);
1329
+ playActionBattleAnimation(
1330
+ "attack",
1331
+ this.event,
1332
+ this.animations,
1333
+ {
1334
+ target: this.target,
1335
+ },
1336
+ { repeat: 2 }
1337
+ );
1070
1338
 
1071
- setTimeout(() => {
1339
+ this.scheduleAttackStartup(profile, () => {
1072
1340
  if (!this.target || this.state !== AiState.Combat) {
1073
1341
  this.chargingAttack = false;
1074
1342
  return;
1075
1343
  }
1076
-
1077
- // Charged attacks can use a stronger skill or wider hitbox
1078
- if (this.attackSkill) {
1079
- try {
1080
- this.event.useSkill(this.attackSkill, this.target);
1081
- } catch (e) {
1082
- this.performBasicHitbox();
1083
- }
1084
- } else {
1085
- this.performBasicHitbox();
1086
- }
1087
-
1344
+ this.executeMeleeAttack(profile, AttackPattern.Charged);
1345
+ });
1346
+ this.schedule(() => {
1088
1347
  this.chargingAttack = false;
1089
- }, 800);
1348
+ }, profile.totalDurationMs);
1090
1349
  }
1091
1350
 
1092
1351
  /**
1093
1352
  * Perform zone attack (360 degrees)
1094
1353
  */
1095
1354
  private performZoneAttack() {
1096
- this.event.setGraphicAnimation('attack', 1);
1355
+ const profile = this.getAttackProfile(AttackPattern.Zone);
1356
+ this.telegraphAttack(profile);
1357
+ playActionBattleAnimation("attack", this.event, this.animations, {
1358
+ target: this.target ?? undefined,
1359
+ });
1097
1360
 
1098
1361
  const eventX = this.event.x();
1099
1362
  const eventY = this.event.y();
@@ -1112,15 +1375,19 @@ export class BattleAi {
1112
1375
  });
1113
1376
  });
1114
1377
 
1115
- const map = this.event.getCurrentMap();
1116
- map?.createMovingHitbox(hitboxes, { speed: 5 }).subscribe({
1117
- next: (hits) => {
1118
- hits.forEach((hit) => {
1119
- if (hit instanceof RpgPlayer && hit !== this.event) {
1120
- this.applyHit(hit);
1121
- }
1122
- });
1123
- },
1378
+ this.scheduleAttackStartup(profile, () => {
1379
+ const map = this.event.getCurrentMap();
1380
+ map?.createMovingHitbox(hitboxes, {
1381
+ speed: resolveActionBattleHitboxSpeed(profile, hitboxes.length),
1382
+ }).subscribe({
1383
+ next: (hits: any[]) => {
1384
+ hits.forEach((hit: any) => {
1385
+ if (hit instanceof RpgPlayer && hit !== this.event) {
1386
+ this.applyHit(hit, undefined, profile, AttackPattern.Zone);
1387
+ }
1388
+ });
1389
+ },
1390
+ });
1124
1391
  });
1125
1392
  }
1126
1393
 
@@ -1129,6 +1396,7 @@ export class BattleAi {
1129
1396
  */
1130
1397
  private performDashAttack() {
1131
1398
  if (!this.target) return;
1399
+ const profile = this.getAttackProfile(AttackPattern.DashAttack);
1132
1400
 
1133
1401
  const dx = this.target.x() - this.event.x();
1134
1402
  const dy = this.target.y() - this.event.y();
@@ -1140,12 +1408,43 @@ export class BattleAi {
1140
1408
  const dirY = dy / dist;
1141
1409
 
1142
1410
  this.faceTarget();
1143
- this.event.dash({ x: dirX, y: dirY }, 10, 200);
1411
+ this.telegraphAttack(profile);
1144
1412
 
1145
- setTimeout(() => {
1413
+ this.scheduleAttackStartup(profile, () => {
1146
1414
  if (!this.target || this.state !== AiState.Combat) return;
1147
- this.performMeleeAttack();
1148
- }, 200);
1415
+ this.event.dash({ x: dirX, y: dirY }, 10, 200);
1416
+ this.schedule(() => {
1417
+ if (!this.target || this.state !== AiState.Combat) return;
1418
+ this.executeMeleeAttack(profile, AttackPattern.DashAttack);
1419
+ }, 200);
1420
+ });
1421
+ }
1422
+
1423
+ private getAttackProfile(
1424
+ pattern: AttackPattern
1425
+ ): NormalizedActionBattleAttackProfile {
1426
+ return this.attackProfiles[
1427
+ pattern as keyof NormalizedActionBattleEnemyAttackProfileMap
1428
+ ] ?? this.attackProfiles.melee;
1429
+ }
1430
+
1431
+ private telegraphAttack(profile: NormalizedActionBattleAttackProfile) {
1432
+ if (profile.startupMs <= 0) return;
1433
+ this.event.flash({
1434
+ type: 'tint',
1435
+ tint: 'white',
1436
+ duration: Math.min(profile.startupMs, 300),
1437
+ cycles: 1
1438
+ });
1439
+ }
1440
+
1441
+ private scheduleAttackStartup(
1442
+ profile: NormalizedActionBattleAttackProfile,
1443
+ callback: () => void
1444
+ ) {
1445
+ return scheduleActionBattleStartup(profile, callback, (scheduled, delay) =>
1446
+ this.schedule(scheduled, delay)
1447
+ );
1149
1448
  }
1150
1449
 
1151
1450
  /**
@@ -1207,24 +1506,24 @@ export class BattleAi {
1207
1506
  /**
1208
1507
  * Try to dodge
1209
1508
  */
1210
- private tryDodge() {
1509
+ private tryDodge(): boolean {
1211
1510
  const currentTime = Date.now();
1212
1511
 
1213
1512
  if (currentTime - this.lastDodgeTime < this.dodgeCooldown) {
1214
1513
  this.debugLog('dodge', `Dodge on cooldown (${this.dodgeCooldown - (currentTime - this.lastDodgeTime)}ms remaining)`);
1215
- return;
1514
+ return false;
1216
1515
  }
1217
1516
  if (Math.random() > this.dodgeChance) {
1218
1517
  this.debugLog('dodge', `Dodge roll failed (chance=${(this.dodgeChance * 100).toFixed(0)}%)`);
1219
- return;
1518
+ return false;
1220
1519
  }
1221
- if (!this.target) return;
1520
+ if (!this.target) return false;
1222
1521
 
1223
1522
  const dx = this.target.x() - this.event.x();
1224
1523
  const dy = this.target.y() - this.event.y();
1225
1524
  const dist = Math.sqrt(dx * dx + dy * dy);
1226
1525
 
1227
- if (dist === 0) return;
1526
+ if (dist === 0) return false;
1228
1527
 
1229
1528
  // Perpendicular direction
1230
1529
  const dodgeDirX = -dy / dist;
@@ -1238,12 +1537,13 @@ export class BattleAi {
1238
1537
  // Counter-attack for defensive types
1239
1538
  if (this.enemyType === EnemyType.Defensive && Math.random() < 0.5) {
1240
1539
  this.debugLog('dodge', 'Counter-attack after dodge');
1241
- setTimeout(() => {
1540
+ this.schedule(() => {
1242
1541
  if (this.target && this.state === AiState.Combat) {
1243
1542
  this.selectAndPerformAttack();
1244
1543
  }
1245
1544
  }, 400);
1246
1545
  }
1546
+ return true;
1247
1547
  }
1248
1548
 
1249
1549
  private canDodge(): boolean {
@@ -1426,9 +1726,24 @@ export class BattleAi {
1426
1726
  * The actual damage is applied externally via RPGJS API.
1427
1727
  */
1428
1728
  takeDamage(attacker: RpgPlayer): boolean {
1729
+ if (this.defeated) return true;
1429
1730
  // Apply damage using RPGJS system
1430
- const { damage } = this.event.applyDamage(attacker);
1731
+ const raw = this.event.applyDamage(attacker);
1732
+ return this.handleDamage(attacker, {
1733
+ damage: raw.damage ?? 0,
1734
+ defeated: this.event.hp <= 0,
1735
+ raw,
1736
+ });
1737
+ }
1431
1738
 
1739
+ handleDamage(
1740
+ attacker: RpgPlayer,
1741
+ damageResult: ActionBattleDamageResult & {
1742
+ reaction?: NormalizedActionBattleHitReactionProfile;
1743
+ }
1744
+ ): boolean {
1745
+ if (this.defeated) return true;
1746
+ const damage = damageResult.damage;
1432
1747
  this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
1433
1748
 
1434
1749
  // Visual feedback
@@ -1439,20 +1754,32 @@ export class BattleAi {
1439
1754
  cycles: 1
1440
1755
  });
1441
1756
  this.event.showHit(`-${damage}`);
1757
+ playActionBattleAnimation("hurt", this.event, this.animations, {
1758
+ attacker,
1759
+ });
1442
1760
 
1443
1761
  // Track damage
1444
1762
  this.recentDamageTaken += damage;
1445
1763
 
1764
+ const reaction = damageResult.reaction;
1765
+ const staggerPower = reaction?.staggerPower ?? damage;
1766
+ const hitstunMs = reaction?.hitstunMs ?? this.hitstunMs;
1767
+ const shouldStun = staggerPower >= this.poise && hitstunMs > 0;
1768
+ setActionBattleInvincibility(
1769
+ this.event,
1770
+ reaction?.invincibilityMs ?? this.invincibilityMs
1771
+ );
1772
+
1446
1773
  // Brief stun
1447
- if (this.state !== AiState.Stunned && this.state !== AiState.Flee) {
1774
+ if (shouldStun && this.state !== AiState.Stunned && this.state !== AiState.Flee) {
1448
1775
  this.debugLog('damage', 'Stunned from damage');
1449
1776
  this.isMovingToTarget = false;
1450
- this.stunnedUntil = Date.now() + 150;
1777
+ this.stunnedUntil = Date.now() + hitstunMs;
1451
1778
  this.changeState(AiState.Stunned);
1452
1779
  }
1453
1780
 
1454
1781
  // Check death
1455
- if (this.event.hp <= 0) {
1782
+ if (damageResult.defeated || this.event.hp <= 0) {
1456
1783
  this.debugLog('damage', 'Defeated!');
1457
1784
  this.kill(attacker);
1458
1785
  return true;
@@ -1468,13 +1795,69 @@ export class BattleAi {
1468
1795
  * and removes the event from the map.
1469
1796
  */
1470
1797
  private kill(attacker?: RpgPlayer) {
1471
- // Call onDefeated hook before cleanup
1798
+ if (this.defeated) return;
1799
+ this.defeated = true;
1800
+
1801
+ const dieAnimation = resolveActionBattleAnimation(
1802
+ "die",
1803
+ this.event,
1804
+ this.animations,
1805
+ {
1806
+ attacker,
1807
+ }
1808
+ );
1809
+ const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
1810
+ const reward = createDefeatReward(this.rewards);
1811
+ let removed = false;
1812
+ const remove = () => {
1813
+ if (removed) return;
1814
+ removed = true;
1815
+ this.event.remove({
1816
+ reason: "defeated",
1817
+ data: {
1818
+ animation: dieAnimation,
1819
+ },
1820
+ transition: dieAnimation
1821
+ ? {
1822
+ animation: dieAnimation.animationName,
1823
+ graphic: dieAnimation.graphic,
1824
+ duration: removeDelay,
1825
+ }
1826
+ : undefined,
1827
+ timeoutMs: removeDelay,
1828
+ });
1829
+ };
1830
+
1831
+ if (this.autoAwardRewards) {
1832
+ reward.giveTo(attacker);
1833
+ }
1834
+
1835
+ const context: BattleAiDefeatedContext = {
1836
+ event: this.event,
1837
+ attacker,
1838
+ reward,
1839
+ remove,
1840
+ };
1841
+
1842
+ // Call onDefeated hook before cleanup. One-argument callbacks receive the
1843
+ // newer context object; two-argument callbacks keep the legacy signature.
1472
1844
  if (this.onDefeatedCallback) {
1473
- this.onDefeatedCallback(this.event, attacker);
1845
+ if (this.onDefeatedCallback.length >= 2) {
1846
+ (
1847
+ this.onDefeatedCallback as (
1848
+ event: RpgEvent,
1849
+ attacker?: RpgPlayer
1850
+ ) => void
1851
+ )(this.event, attacker);
1852
+ } else {
1853
+ (this.onDefeatedCallback as (context: BattleAiDefeatedContext) => void)(
1854
+ context
1855
+ );
1856
+ }
1474
1857
  }
1475
-
1858
+
1476
1859
  this.destroy();
1477
- this.event.remove();
1860
+ remove();
1478
1861
  }
1479
1862
 
1480
1863
  /**
@@ -1544,6 +1927,36 @@ export class BattleAi {
1544
1927
  }
1545
1928
  }
1546
1929
 
1930
+ private applyCustomBehavior(currentTime: number) {
1931
+ if (!this.behaviorKey) return;
1932
+ const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
1933
+ if (!behavior) return;
1934
+ const maxHp = this.event.param[MAXHP];
1935
+ const decision = behavior({
1936
+ event: this.event,
1937
+ target: this.target,
1938
+ state: this.state,
1939
+ enemyType: this.enemyType,
1940
+ distance: this.target ? this.getDistance(this.event, this.target) : null,
1941
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
1942
+ now: currentTime,
1943
+ });
1944
+ if (!decision) return;
1945
+ if (decision.attackCooldown !== undefined) {
1946
+ this.attackCooldown = decision.attackCooldown;
1947
+ }
1948
+ if (decision.moveToCooldown !== undefined) {
1949
+ this.moveToCooldown = decision.moveToCooldown;
1950
+ }
1951
+ if (decision.attackPatterns?.length) {
1952
+ this.attackPatterns = decision.attackPatterns;
1953
+ }
1954
+ if (decision.mode) {
1955
+ this.behaviorMode = decision.mode;
1956
+ this.behaviorEnabled = true;
1957
+ }
1958
+ }
1959
+
1547
1960
  private handleTacticalMovement(distance: number) {
1548
1961
  if (!this.target) return;
1549
1962
  const minRange = this.attackRange * 0.7;
@@ -1600,6 +2013,15 @@ export class BattleAi {
1600
2013
  return true;
1601
2014
  }
1602
2015
 
2016
+ private schedule(callback: () => void, delay: number) {
2017
+ const timer = setTimeout(() => {
2018
+ this.timers = this.timers.filter((entry) => entry !== timer);
2019
+ callback();
2020
+ }, delay);
2021
+ this.timers.push(timer);
2022
+ return timer;
2023
+ }
2024
+
1603
2025
  // Public getters
1604
2026
  getHealth(): number { return this.event.hp; }
1605
2027
  getMaxHealth(): number { return this.event.param[MAXHP]; }
@@ -1617,5 +2039,7 @@ export class BattleAi {
1617
2039
  }
1618
2040
  this.target = null;
1619
2041
  this.nearbyEnemies = [];
2042
+ this.timers.forEach((timer) => clearTimeout(timer));
2043
+ this.timers = [];
1620
2044
  }
1621
2045
  }