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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/client/ai.server.d.ts +45 -8
  3. package/dist/client/attack-input.d.ts +3 -0
  4. package/dist/client/core/action-use.d.ts +18 -0
  5. package/dist/client/core/ai-behavior-tree.d.ts +99 -0
  6. package/dist/client/core/attack-runtime.d.ts +2 -0
  7. package/dist/client/core/defaults.d.ts +2 -1
  8. package/dist/client/core/equipment.d.ts +1 -0
  9. package/dist/client/core/targets.d.ts +15 -0
  10. package/dist/client/enemies/factory.d.ts +2 -0
  11. package/dist/client/index.d.ts +12 -7
  12. package/dist/client/index.js +16 -11
  13. package/dist/client/index10.js +32 -56
  14. package/dist/client/index11.js +99 -52
  15. package/dist/client/index12.js +76 -103
  16. package/dist/client/index13.js +72 -135
  17. package/dist/client/index14.js +67 -23
  18. package/dist/client/index15.js +197 -63
  19. package/dist/client/index16.js +112 -1337
  20. package/dist/client/index17.js +193 -7
  21. package/dist/client/index18.js +32 -58
  22. package/dist/client/index19.js +70 -8
  23. package/dist/client/index20.js +57 -501
  24. package/dist/client/index21.js +69 -0
  25. package/dist/client/index22.js +225 -0
  26. package/dist/client/index23.js +16 -0
  27. package/dist/client/index24.js +25 -0
  28. package/dist/client/index25.js +107 -0
  29. package/dist/client/index26.js +1707 -0
  30. package/dist/client/index27.js +12 -0
  31. package/dist/client/index28.js +589 -0
  32. package/dist/client/index4.js +79 -38
  33. package/dist/client/index6.js +65 -306
  34. package/dist/client/index7.js +33 -33
  35. package/dist/client/index8.js +24 -100
  36. package/dist/client/index9.js +293 -61
  37. package/dist/client/locomotion.d.ts +16 -0
  38. package/dist/client/movement.d.ts +14 -0
  39. package/dist/client/server.d.ts +7 -3
  40. package/dist/client/ui.d.ts +22 -0
  41. package/dist/client/visual.d.ts +15 -0
  42. package/dist/server/ai.server.d.ts +45 -8
  43. package/dist/server/attack-input.d.ts +3 -0
  44. package/dist/server/core/action-use.d.ts +18 -0
  45. package/dist/server/core/ai-behavior-tree.d.ts +99 -0
  46. package/dist/server/core/attack-runtime.d.ts +2 -0
  47. package/dist/server/core/defaults.d.ts +2 -1
  48. package/dist/server/core/equipment.d.ts +1 -0
  49. package/dist/server/core/targets.d.ts +15 -0
  50. package/dist/server/enemies/factory.d.ts +2 -0
  51. package/dist/server/index.d.ts +12 -7
  52. package/dist/server/index.js +14 -9
  53. package/dist/server/index10.js +64 -1336
  54. package/dist/server/index11.js +33 -33
  55. package/dist/server/index13.js +66 -11
  56. package/dist/server/index14.js +206 -484
  57. package/dist/server/index15.js +15 -9
  58. package/dist/server/index16.js +26 -0
  59. package/dist/server/index17.js +25 -0
  60. package/dist/server/index18.js +107 -0
  61. package/dist/server/index19.js +1707 -0
  62. package/dist/server/index2.js +10 -2
  63. package/dist/server/index20.js +37 -0
  64. package/dist/server/index21.js +588 -0
  65. package/dist/server/index22.js +78 -0
  66. package/dist/server/index23.js +12 -0
  67. package/dist/server/index5.js +79 -38
  68. package/dist/server/index6.js +192 -129
  69. package/dist/server/index7.js +198 -24
  70. package/dist/server/index8.js +28 -66
  71. package/dist/server/index9.js +68 -51
  72. package/dist/server/locomotion.d.ts +16 -0
  73. package/dist/server/movement.d.ts +14 -0
  74. package/dist/server/server.d.ts +7 -3
  75. package/dist/server/ui.d.ts +22 -0
  76. package/dist/server/visual.d.ts +15 -0
  77. package/package.json +10 -10
  78. package/src/ai.server.spec.ts +233 -0
  79. package/src/ai.server.ts +627 -108
  80. package/src/animations.spec.ts +40 -0
  81. package/src/animations.ts +31 -9
  82. package/src/attack-input.spec.ts +51 -0
  83. package/src/attack-input.ts +59 -0
  84. package/src/client.ts +75 -62
  85. package/src/components/action-bar.ce +2 -2
  86. package/src/config.ts +84 -37
  87. package/src/core/action-use.spec.ts +317 -0
  88. package/src/core/action-use.ts +386 -0
  89. package/src/core/ai-behavior-tree.spec.ts +116 -0
  90. package/src/core/ai-behavior-tree.ts +272 -0
  91. package/src/core/attack-profile.spec.ts +46 -0
  92. package/src/core/attack-runtime.spec.ts +35 -0
  93. package/src/core/attack-runtime.ts +32 -0
  94. package/src/core/context.ts +9 -0
  95. package/src/core/contracts.ts +146 -1
  96. package/src/core/defaults.ts +56 -0
  97. package/src/core/equipment.ts +9 -5
  98. package/src/core/targets.spec.ts +112 -0
  99. package/src/core/targets.ts +147 -0
  100. package/src/enemies/factory.ts +8 -0
  101. package/src/index.ts +111 -2
  102. package/src/locomotion.spec.ts +51 -0
  103. package/src/locomotion.ts +48 -0
  104. package/src/movement.spec.ts +78 -0
  105. package/src/movement.ts +46 -0
  106. package/src/server.ts +242 -66
  107. package/src/types.ts +105 -35
  108. package/src/ui.ts +113 -0
  109. package/src/visual.spec.ts +166 -0
  110. package/src/visual.ts +285 -0
  111. package/README.md +0 -1242
package/src/ai.server.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { MAXHP, RpgEvent, RpgPlayer } from "@rpgjs/server";
2
2
  import {
3
3
  getActionBattleAnimationRemovalDelay,
4
- playActionBattleAnimation,
5
4
  resolveActionBattleAnimation,
6
5
  } from "./animations";
6
+ import { emitActionBattleClientVisual } from "./visual";
7
7
  import { getActionBattleOptions } from "./config";
8
8
  import { getActionBattleSystems } from "./core/context";
9
9
  import {
@@ -16,10 +16,40 @@ import {
16
16
  type NormalizedActionBattleEnemyAttackProfileMap,
17
17
  } from "./core/enemy-attack-profiles";
18
18
  import {
19
- resolveActionBattleHitboxSpeed,
19
+ ActionBattleHitTracker,
20
+ runActionBattleActiveHitbox,
20
21
  scheduleActionBattleStartup,
21
22
  } from "./core/attack-runtime";
22
- import type { ActionBattleDamageResult } from "./core/contracts";
23
+ import { applyActionBattleAttackDirection } from "./attack-input";
24
+ import {
25
+ executeActionBattleUse,
26
+ getActionBattleActionRange,
27
+ } from "./core/action-use";
28
+ import { resolveActionBattleWeapon } from "./core/equipment";
29
+ import { withActionBattleAnimationUnlocked } from "./locomotion";
30
+ import { safeActionBattleDash } from "./movement";
31
+ import {
32
+ defineAiBehavior,
33
+ defineAiTree,
34
+ type ActionBattleAiIntent,
35
+ type ActionBattleAiMemory,
36
+ type ActionBattleAiSimpleBehavior,
37
+ type ActionBattleAiTreeInput,
38
+ type ActionBattleAiTreeNode,
39
+ } from "./core/ai-behavior-tree";
40
+ import type {
41
+ ActionBattleAiBehavior,
42
+ ActionBattleAiDecision,
43
+ ActionBattleAiPreset,
44
+ ActionBattleDamageResult,
45
+ ActionBattleEntity,
46
+ ActionBattleHitbox,
47
+ ActionBattleTargetSelector,
48
+ } from "./core/contracts";
49
+ import {
50
+ canActionBattleTarget,
51
+ isActionBattlePlayer,
52
+ } from "./core/targets";
23
53
  import type {
24
54
  NormalizedActionBattleAttackProfile,
25
55
  NormalizedActionBattleHitReactionProfile,
@@ -51,7 +81,7 @@ export interface BattleAiDefeatReward {
51
81
 
52
82
  export interface BattleAiDefeatedContext {
53
83
  event: RpgEvent;
54
- attacker?: RpgPlayer;
84
+ attacker?: ActionBattleEntity;
55
85
  reward: BattleAiDefeatReward;
56
86
  remove: () => void;
57
87
  }
@@ -62,10 +92,13 @@ export type BattleAiDefeatedCallback = (
62
92
 
63
93
  export type BattleAiLegacyDefeatedCallback = (
64
94
  event: RpgEvent,
65
- attacker?: RpgPlayer
95
+ attacker?: ActionBattleEntity
66
96
  ) => void;
67
97
 
68
98
  export interface BattleAiBaseOptions {
99
+ preset?: string | ActionBattleAiPreset;
100
+ faction?: string;
101
+ targets?: ActionBattleTargetSelector;
69
102
  enemyType?: EnemyType;
70
103
  attackCooldown?: number;
71
104
  visionRange?: number;
@@ -91,6 +124,9 @@ export interface BattleAiBaseOptions {
91
124
  retreatThreshold?: number;
92
125
  };
93
126
  behaviorKey?: string;
127
+ tree?: ActionBattleAiTreeInput;
128
+ behaviorTree?: ActionBattleAiTreeInput;
129
+ simpleBehavior?: ActionBattleAiSimpleBehavior;
94
130
  animations?: ActionBattleAnimationOptions;
95
131
  rewards?: BattleAiRewards;
96
132
  autoAwardRewards?: boolean;
@@ -363,6 +399,49 @@ export const DEFAULT_KNOCKBACK = {
363
399
  duration: 300
364
400
  };
365
401
 
402
+ const mergeBattleAiPresetOptions = (
403
+ options: BattleAiOptions | BattleAiLegacyOptions,
404
+ seen: Set<string> = new Set()
405
+ ): BattleAiOptions | BattleAiLegacyOptions => {
406
+ if (!options.preset) return options;
407
+
408
+ if (typeof options.preset === "string") {
409
+ if (seen.has(options.preset)) {
410
+ throw new Error(`Circular action battle AI preset: ${options.preset}`);
411
+ }
412
+ seen.add(options.preset);
413
+ }
414
+
415
+ const preset =
416
+ typeof options.preset === "string"
417
+ ? getActionBattleSystems().ai.presets[options.preset]
418
+ : options.preset;
419
+
420
+ if (!preset) {
421
+ throw new Error(`Action battle AI preset not found: ${options.preset}`);
422
+ }
423
+
424
+ const resolvedPreset = mergeBattleAiPresetOptions(preset, seen);
425
+ const { preset: _preset, ...overrides } = options;
426
+
427
+ return {
428
+ ...resolvedPreset,
429
+ ...overrides,
430
+ behavior: {
431
+ ...resolvedPreset.behavior,
432
+ ...overrides.behavior,
433
+ },
434
+ animations: {
435
+ ...resolvedPreset.animations,
436
+ ...overrides.animations,
437
+ },
438
+ rewards: {
439
+ ...resolvedPreset.rewards,
440
+ ...overrides.rewards,
441
+ },
442
+ } as BattleAiOptions | BattleAiLegacyOptions;
443
+ };
444
+
366
445
  /**
367
446
  * Advanced Battle AI Controller for events
368
447
  *
@@ -407,7 +486,7 @@ export const DEFAULT_KNOCKBACK = {
407
486
  */
408
487
  export class BattleAi {
409
488
  private event: RpgEvent;
410
- private target: InstanceType<typeof RpgPlayer> | null = null;
489
+ private target: ActionBattleEntity | null = null;
411
490
  private lastAttackTime: number = 0;
412
491
  private updateInterval?: any;
413
492
 
@@ -425,6 +504,8 @@ export class BattleAi {
425
504
 
426
505
  // Enemy type and behavior
427
506
  private enemyType: EnemyType;
507
+ private faction?: string;
508
+ private targets: ActionBattleTargetSelector = "players";
428
509
  private attackCooldown: number = 1000;
429
510
  private visionRange: number = 150;
430
511
  private attackRange: number = 60;
@@ -491,9 +572,15 @@ export class BattleAi {
491
572
  private lastRetreatTime: number = 0;
492
573
  private timers: ReturnType<typeof setTimeout>[] = [];
493
574
  private behaviorKey?: string;
575
+ private behaviorTree?: ActionBattleAiTreeNode;
576
+ private aiMemory: ActionBattleAiMemory = {};
494
577
  private poise: number = 0;
495
578
  private hitstunMs: number = 150;
496
579
  private invincibilityMs: number = 250;
580
+ private visionShape?: any;
581
+ private visionSetupRetries: number = 0;
582
+ private maxVisionSetupRetries: number = 20;
583
+ private destroyed: boolean = false;
497
584
 
498
585
  /**
499
586
  * Create a new Battle AI Controller
@@ -525,11 +612,14 @@ export class BattleAi {
525
612
  event: RpgEventWithBattleAi,
526
613
  options: BattleAiOptions | BattleAiLegacyOptions = {}
527
614
  ) {
615
+ options = mergeBattleAiPresetOptions(options);
528
616
  event.battleAi = this;
529
617
  this.event = event;
530
618
 
531
619
  // Set enemy type and apply behavior modifiers
532
620
  this.enemyType = options.enemyType || EnemyType.Aggressive;
621
+ this.faction = options.faction;
622
+ this.targets = options.targets ?? "players";
533
623
  this.behaviorKey = options.behaviorKey ?? this.enemyType;
534
624
  this.applyEnemyTypeBehavior(options);
535
625
 
@@ -597,9 +687,21 @@ export class BattleAi {
597
687
  if (options.invincibilityMs !== undefined) {
598
688
  this.invincibilityMs = Math.max(0, options.invincibilityMs);
599
689
  }
690
+ if (options.tree || options.behaviorTree) {
691
+ this.behaviorTree = defineAiTree(options.tree ?? options.behaviorTree!);
692
+ } else if (options.simpleBehavior) {
693
+ this.behaviorTree = defineAiBehavior(options.simpleBehavior);
694
+ }
695
+
696
+ if (options.attackRange === undefined) {
697
+ const actionRange = this.getCurrentActionRange();
698
+ if (actionRange !== undefined) {
699
+ this.attackRange = actionRange;
700
+ }
701
+ }
600
702
 
601
703
  // Setup AI systems
602
- this.setupVision();
704
+ this.scheduleVisionSetup();
603
705
  this.startAiBehaviorLoop();
604
706
  if (this.patrolWaypoints.length > 0) {
605
707
  this.startPatrol();
@@ -681,14 +783,37 @@ export class BattleAi {
681
783
  /**
682
784
  * Setup vision detection
683
785
  */
684
- private setupVision() {
786
+ private setupVision(): boolean {
787
+ if (this.visionShape) return true;
788
+ const map = this.event.getCurrentMap?.();
789
+ if (
790
+ map?.physic?.getEntityByUUID &&
791
+ !map.physic.getEntityByUUID(this.event.id)
792
+ ) {
793
+ return false;
794
+ }
795
+
685
796
  const diameter = this.visionRange * 2;
686
- this.event.attachShape(`vision_${this.event.id}`, {
797
+ const shape = this.event.attachShape(`vision_${this.event.id}`, {
687
798
  radius: this.visionRange,
688
799
  width: diameter,
689
800
  height: diameter,
690
801
  angle: 360,
691
802
  });
803
+ if (!shape) return false;
804
+ this.visionShape = shape;
805
+ return true;
806
+ }
807
+
808
+ private scheduleVisionSetup() {
809
+ if (this.destroyed || this.setupVision()) return;
810
+ if (this.visionSetupRetries >= this.maxVisionSetupRetries) return;
811
+
812
+ this.visionSetupRetries++;
813
+ this.schedule(() => {
814
+ if (this.destroyed || !this.event.getCurrentMap()) return;
815
+ this.scheduleVisionSetup();
816
+ }, 50);
692
817
  }
693
818
 
694
819
  /**
@@ -759,6 +884,14 @@ export class BattleAi {
759
884
  private updateAiBehavior() {
760
885
  const currentTime = Date.now();
761
886
 
887
+ if (this.target && this.isTargetDefeated(this.target)) {
888
+ this.debugLog('combat', `Target ${this.target.id} is defeated, returning to idle`);
889
+ this.clearTarget();
890
+ this.changeState(AiState.Idle);
891
+ this.checkDamageTaken();
892
+ return;
893
+ }
894
+
762
895
  // Update group behavior
763
896
  if (this.groupBehavior) {
764
897
  this.updateGroupBehavior();
@@ -784,7 +917,18 @@ export class BattleAi {
784
917
  this.updateBehavior(currentTime);
785
918
  }
786
919
 
787
- this.applyCustomBehavior(currentTime);
920
+ if (!this.target && this.state === AiState.Idle) {
921
+ const target = this.findNearestTarget();
922
+ if (target) {
923
+ this.engageTarget(target);
924
+ }
925
+ }
926
+
927
+ const customBehaviorHandled = this.applyCustomBehavior(currentTime);
928
+ if (customBehaviorHandled) {
929
+ this.checkDamageTaken();
930
+ return;
931
+ }
788
932
 
789
933
  // State-specific behavior
790
934
  switch (this.state) {
@@ -810,6 +954,12 @@ export class BattleAi {
810
954
  * Update idle behavior (patrolling)
811
955
  */
812
956
  private updateIdleBehavior() {
957
+ const target = this.findNearestTarget();
958
+ if (target) {
959
+ this.engageTarget(target);
960
+ return;
961
+ }
962
+
813
963
  if (this.patrolWaypoints.length > 0) {
814
964
  const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
815
965
  const distance = this.getDistance(this.event, {
@@ -855,9 +1005,7 @@ export class BattleAi {
855
1005
  // Check if target is still in range
856
1006
  if (distance > this.visionRange * 1.5) {
857
1007
  this.debugLog('combat', `Target out of range (dist=${distance.toFixed(1)}, maxRange=${(this.visionRange * 1.5).toFixed(1)})`);
858
- this.target = null;
859
- this.isMovingToTarget = false;
860
- this.event.stopMoveTo();
1008
+ this.clearTarget();
861
1009
  this.changeState(AiState.Idle);
862
1010
  return;
863
1011
  }
@@ -1068,10 +1216,15 @@ export class BattleAi {
1068
1216
  if (!this.target) return;
1069
1217
  const profile = this.getAttackProfile(AttackPattern.Melee);
1070
1218
 
1071
- this.faceTarget();
1219
+ this.faceTarget({ force: true });
1072
1220
  this.telegraphAttack(profile);
1073
- playActionBattleAnimation("attack", this.event, this.animations, {
1074
- target: this.target,
1221
+ withActionBattleAnimationUnlocked(this.event, () => {
1222
+ emitActionBattleClientVisual({
1223
+ moment: "attack",
1224
+ entity: this.event,
1225
+ target: this.target,
1226
+ animations: this.animations,
1227
+ });
1075
1228
  });
1076
1229
 
1077
1230
  this.scheduleAttackStartup(profile, () => {
@@ -1083,24 +1236,43 @@ export class BattleAi {
1083
1236
  profile: NormalizedActionBattleAttackProfile,
1084
1237
  pattern: AttackPattern
1085
1238
  ) {
1086
- if (!this.target) return;
1239
+ if (!this.target || this.isTargetDefeated(this.target)) return;
1087
1240
  this.debugLog('attack', `Applying ${pattern} hit`);
1088
1241
 
1089
- // Use skill if available
1090
1242
  if (this.attackSkill) {
1243
+ const resolvedSkill = this.resolveUsable(this.attackSkill);
1091
1244
  try {
1092
- playActionBattleAnimation("castSkill", this.event, this.animations, {
1093
- skill: this.attackSkill,
1245
+ executeActionBattleUse({
1246
+ attacker: this.event,
1094
1247
  target: this.target,
1248
+ usable: resolvedSkill,
1249
+ skill: resolvedSkill,
1250
+ pattern,
1251
+ profile,
1095
1252
  });
1096
- this.event.useSkill(this.attackSkill, this.target);
1097
1253
  } catch (e) {
1098
1254
  // Skill failed (no SP, etc.) - fall back to basic attack
1099
1255
  this.performBasicHitbox(profile, pattern);
1100
1256
  }
1101
- } else {
1102
- this.performBasicHitbox(profile, pattern);
1257
+ return;
1258
+ }
1259
+
1260
+ const weapon = resolveActionBattleWeapon(this.event);
1261
+ if (
1262
+ weapon &&
1263
+ executeActionBattleUse({
1264
+ attacker: this.event,
1265
+ target: this.target,
1266
+ usable: weapon,
1267
+ weapon,
1268
+ pattern,
1269
+ profile,
1270
+ })
1271
+ ) {
1272
+ return;
1103
1273
  }
1274
+
1275
+ this.performBasicHitbox(profile, pattern);
1104
1276
  }
1105
1277
 
1106
1278
  /**
@@ -1112,7 +1284,21 @@ export class BattleAi {
1112
1284
  ),
1113
1285
  pattern: AttackPattern = AttackPattern.Melee
1114
1286
  ) {
1115
- if (!this.target) return;
1287
+ if (!this.target || this.isTargetDefeated(this.target)) return;
1288
+
1289
+ const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
1290
+ runActionBattleActiveHitbox(
1291
+ { ...profile, startupMs: 0 },
1292
+ () => this.resolveBasicHitboxes(),
1293
+ (hitboxes) => {
1294
+ this.processHitboxHits(hitboxes, hitTracker, profile, pattern);
1295
+ },
1296
+ (scheduled, delay) => this.schedule(scheduled, delay)
1297
+ );
1298
+ }
1299
+
1300
+ private resolveBasicHitboxes(): ActionBattleHitbox[] {
1301
+ if (!this.target || this.isTargetDefeated(this.target)) return [];
1116
1302
 
1117
1303
  const eventX = this.event.x();
1118
1304
  const eventY = this.event.y();
@@ -1120,30 +1306,51 @@ export class BattleAi {
1120
1306
  const dy = this.target.y() - eventY;
1121
1307
  const dist = Math.sqrt(dx * dx + dy * dy);
1122
1308
 
1123
- if (dist === 0) return;
1309
+ if (dist === 0) return [];
1124
1310
 
1125
1311
  const dirX = dx / dist;
1126
1312
  const dirY = dy / dist;
1127
1313
 
1128
- const hitboxes = [{
1314
+ return [{
1129
1315
  x: eventX + dirX * 30,
1130
1316
  y: eventY + dirY * 30,
1131
1317
  width: 40,
1132
1318
  height: 40,
1133
1319
  }];
1320
+ }
1134
1321
 
1322
+ private queryHitboxCandidates(hitboxes: ActionBattleHitbox[]) {
1135
1323
  const map = this.event.getCurrentMap();
1136
- map?.createMovingHitbox(hitboxes, {
1137
- speed: resolveActionBattleHitboxSpeed(profile, hitboxes.length),
1138
- }).subscribe({
1139
- next: (hits: any[]) => {
1140
- hits.forEach((hit: any) => {
1141
- if (hit instanceof RpgPlayer && hit !== this.event) {
1142
- this.applyHit(hit, undefined, profile, pattern);
1143
- }
1144
- });
1145
- },
1146
- });
1324
+ if (!map || typeof (map as any).queryHitbox !== "function") return [];
1325
+
1326
+ const candidates = new Map<string, ActionBattleEntity>();
1327
+ for (const hitbox of hitboxes) {
1328
+ for (const hit of (map as any).queryHitbox(hitbox, {
1329
+ excludeIds: [this.event.id],
1330
+ kinds: ["players", "events"],
1331
+ })) {
1332
+ if (hit?.id) candidates.set(hit.id, hit);
1333
+ }
1334
+ }
1335
+ return Array.from(candidates.values());
1336
+ }
1337
+
1338
+ private processHitboxHits(
1339
+ hitboxes: ActionBattleHitbox[],
1340
+ hitTracker: ActionBattleHitTracker,
1341
+ profile: NormalizedActionBattleAttackProfile,
1342
+ pattern: AttackPattern
1343
+ ) {
1344
+ for (const hit of this.queryHitboxCandidates(hitboxes)) {
1345
+ if (
1346
+ hit !== this.event &&
1347
+ this.canTarget(hit) &&
1348
+ !this.isTargetDefeated(hit) &&
1349
+ hitTracker.tryHit(hit)
1350
+ ) {
1351
+ this.applyHit(hit, undefined, profile, pattern);
1352
+ }
1353
+ }
1147
1354
  }
1148
1355
 
1149
1356
  /**
@@ -1175,13 +1382,24 @@ export class BattleAi {
1175
1382
  * ```
1176
1383
  */
1177
1384
  private applyHit(
1178
- target: RpgPlayer,
1385
+ target: ActionBattleEntity,
1179
1386
  hooks?: ApplyHitHooks,
1180
1387
  profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
1181
1388
  AttackPattern.Melee
1182
1389
  ),
1183
1390
  pattern: AttackPattern = AttackPattern.Melee
1184
1391
  ): HitResult {
1392
+ if (this.isTargetDefeated(target)) {
1393
+ return {
1394
+ damage: 0,
1395
+ knockbackForce: 0,
1396
+ knockbackDuration: 0,
1397
+ defeated: true,
1398
+ attacker: this.event,
1399
+ target
1400
+ };
1401
+ }
1402
+
1185
1403
  if (isActionBattleEntityInvincible(target)) {
1186
1404
  return {
1187
1405
  damage: 0,
@@ -1219,13 +1437,16 @@ export class BattleAi {
1219
1437
  }
1220
1438
 
1221
1439
  // Visual feedback
1222
- target.flash({
1223
- type: 'tint',
1224
- tint: 'red',
1225
- duration: 200,
1226
- cycles: 1
1440
+ withActionBattleAnimationUnlocked(target, () => {
1441
+ emitActionBattleClientVisual({
1442
+ moment: "hit",
1443
+ entity: this.event,
1444
+ target,
1445
+ damage: hitResult.damage,
1446
+ result: hitResult,
1447
+ animations: this.animations,
1448
+ });
1227
1449
  });
1228
- target.showHit(`-${hitResult.damage}`);
1229
1450
  setActionBattleInvincibility(
1230
1451
  target,
1231
1452
  profile.reaction.invincibilityMs
@@ -1251,6 +1472,16 @@ export class BattleAi {
1251
1472
  hooks.onAfterHit(hitResult);
1252
1473
  }
1253
1474
 
1475
+ const targetAi = (target as any).battleAi as BattleAi | undefined;
1476
+ if (targetAi) {
1477
+ targetAi.handleDamage(this.event, {
1478
+ damage: hitResult.damage,
1479
+ defeated: hitResult.defeated,
1480
+ raw: undefined,
1481
+ reaction: profile.reaction,
1482
+ });
1483
+ }
1484
+
1254
1485
  return hitResult;
1255
1486
  }
1256
1487
 
@@ -1294,10 +1525,15 @@ export class BattleAi {
1294
1525
 
1295
1526
  this.comboCount++;
1296
1527
  const profile = this.getAttackProfile(AttackPattern.Combo);
1297
- this.faceTarget();
1528
+ this.faceTarget({ force: true });
1298
1529
  this.telegraphAttack(profile);
1299
- playActionBattleAnimation("attack", this.event, this.animations, {
1300
- target: this.target,
1530
+ withActionBattleAnimationUnlocked(this.event, () => {
1531
+ emitActionBattleClientVisual({
1532
+ moment: "attack",
1533
+ entity: this.event,
1534
+ target: this.target,
1535
+ animations: this.animations,
1536
+ });
1301
1537
  });
1302
1538
  this.scheduleAttackStartup(profile, () => {
1303
1539
  this.executeMeleeAttack(profile, AttackPattern.Combo);
@@ -1324,17 +1560,17 @@ export class BattleAi {
1324
1560
  const profile = this.getAttackProfile(AttackPattern.Charged);
1325
1561
 
1326
1562
  this.chargingAttack = true;
1327
- this.faceTarget();
1563
+ this.faceTarget({ force: true });
1328
1564
  this.telegraphAttack(profile);
1329
- playActionBattleAnimation(
1330
- "attack",
1331
- this.event,
1332
- this.animations,
1333
- {
1565
+ withActionBattleAnimationUnlocked(this.event, () => {
1566
+ emitActionBattleClientVisual({
1567
+ moment: "attack",
1568
+ entity: this.event,
1334
1569
  target: this.target,
1335
- },
1336
- { repeat: 2 }
1337
- );
1570
+ animations: this.animations,
1571
+ animationDefaults: { repeat: 2 },
1572
+ });
1573
+ });
1338
1574
 
1339
1575
  this.scheduleAttackStartup(profile, () => {
1340
1576
  if (!this.target || this.state !== AiState.Combat) {
@@ -1354,8 +1590,13 @@ export class BattleAi {
1354
1590
  private performZoneAttack() {
1355
1591
  const profile = this.getAttackProfile(AttackPattern.Zone);
1356
1592
  this.telegraphAttack(profile);
1357
- playActionBattleAnimation("attack", this.event, this.animations, {
1358
- target: this.target ?? undefined,
1593
+ withActionBattleAnimationUnlocked(this.event, () => {
1594
+ emitActionBattleClientVisual({
1595
+ moment: "attack",
1596
+ entity: this.event,
1597
+ target: this.target ?? undefined,
1598
+ animations: this.animations,
1599
+ });
1359
1600
  });
1360
1601
 
1361
1602
  const eventX = this.event.x();
@@ -1376,18 +1617,20 @@ export class BattleAi {
1376
1617
  });
1377
1618
 
1378
1619
  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
- });
1620
+ const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
1621
+ runActionBattleActiveHitbox(
1622
+ { ...profile, startupMs: 0 },
1623
+ () => hitboxes,
1624
+ (activeHitboxes) => {
1625
+ this.processHitboxHits(
1626
+ activeHitboxes,
1627
+ hitTracker,
1628
+ profile,
1629
+ AttackPattern.Zone
1630
+ );
1389
1631
  },
1390
- });
1632
+ (scheduled, delay) => this.schedule(scheduled, delay)
1633
+ );
1391
1634
  });
1392
1635
  }
1393
1636
 
@@ -1395,7 +1638,7 @@ export class BattleAi {
1395
1638
  * Perform dash attack
1396
1639
  */
1397
1640
  private performDashAttack() {
1398
- if (!this.target) return;
1641
+ if (!this.target || this.isTargetDefeated(this.target)) return;
1399
1642
  const profile = this.getAttackProfile(AttackPattern.DashAttack);
1400
1643
 
1401
1644
  const dx = this.target.x() - this.event.x();
@@ -1407,12 +1650,12 @@ export class BattleAi {
1407
1650
  const dirX = dx / dist;
1408
1651
  const dirY = dy / dist;
1409
1652
 
1410
- this.faceTarget();
1653
+ this.faceTarget({ force: true });
1411
1654
  this.telegraphAttack(profile);
1412
1655
 
1413
1656
  this.scheduleAttackStartup(profile, () => {
1414
1657
  if (!this.target || this.state !== AiState.Combat) return;
1415
- this.event.dash({ x: dirX, y: dirY }, 10, 200);
1658
+ safeActionBattleDash(this.event, { x: dirX, y: dirY }, 10, 200);
1416
1659
  this.schedule(() => {
1417
1660
  if (!this.target || this.state !== AiState.Combat) return;
1418
1661
  this.executeMeleeAttack(profile, AttackPattern.DashAttack);
@@ -1455,7 +1698,7 @@ export class BattleAi {
1455
1698
  * 2. When near diagonal, require significant difference to change
1456
1699
  * 3. Only change if direction is clearly wrong (opposite)
1457
1700
  */
1458
- private faceTarget() {
1701
+ private faceTarget(options: { force?: boolean } = {}) {
1459
1702
  if (!this.target) return;
1460
1703
 
1461
1704
  const dx = this.target.x() - this.event.x();
@@ -1464,11 +1707,12 @@ export class BattleAi {
1464
1707
  const absY = Math.abs(dy);
1465
1708
  const distance = Math.sqrt(dx * dx + dy * dy);
1466
1709
 
1467
- // When very close to target (in collision range), don't change direction
1468
- // This prevents flickering when in melee combat
1469
- const minDistanceForDirectionChange = 40;
1710
+ // Avoid undefined facing only when both entities are effectively stacked.
1711
+ // A larger dead-zone makes close melee targets keep stale directions after
1712
+ // knockback, so keep this threshold intentionally tiny.
1713
+ const minDistanceForDirectionChange = 4;
1470
1714
  if (this.lastFacingDirection && distance < minDistanceForDirectionChange) {
1471
- return; // Keep current direction when in collision
1715
+ return;
1472
1716
  }
1473
1717
 
1474
1718
  // Calculate the "ideal" direction
@@ -1500,7 +1744,11 @@ export class BattleAi {
1500
1744
  }
1501
1745
 
1502
1746
  this.lastFacingDirection = newDirection;
1503
- this.event.changeDirection(newDirection as any);
1747
+ if (options.force) {
1748
+ applyActionBattleAttackDirection(this.event, newDirection as any);
1749
+ } else {
1750
+ this.event.changeDirection(newDirection as any);
1751
+ }
1504
1752
  }
1505
1753
 
1506
1754
  /**
@@ -1531,7 +1779,9 @@ export class BattleAi {
1531
1779
  const side = Math.random() > 0.5 ? 1 : -1;
1532
1780
 
1533
1781
  this.debugLog('dodge', `Dodging (dir=${side > 0 ? 'right' : 'left'})`);
1534
- this.event.dash({ x: dodgeDirX * side, y: dodgeDirY * side }, 12, 300);
1782
+ if (!safeActionBattleDash(this.event, { x: dodgeDirX * side, y: dodgeDirY * side }, 12, 300)) {
1783
+ return false;
1784
+ }
1535
1785
  this.lastDodgeTime = currentTime;
1536
1786
 
1537
1787
  // Counter-attack for defensive types
@@ -1593,7 +1843,9 @@ export class BattleAi {
1593
1843
 
1594
1844
  if (dist === 0) return;
1595
1845
 
1596
- this.event.dash({ x: dx / dist, y: dy / dist }, 8, 200);
1846
+ if (!safeActionBattleDash(this.event, { x: dx / dist, y: dy / dist }, 8, 200)) {
1847
+ return;
1848
+ }
1597
1849
  this.lastRetreatTime = currentTime;
1598
1850
  }
1599
1851
 
@@ -1695,9 +1947,14 @@ export class BattleAi {
1695
1947
  /**
1696
1948
  * Handle player entering vision
1697
1949
  */
1698
- onDetectInShape(player: InstanceType<typeof RpgPlayer>, shape: any) {
1699
- this.debugLog('vision', `Player ${player.id} entered vision (state=${this.state})`);
1700
- this.target = player;
1950
+ onDetectInShape(target: ActionBattleEntity, shape: any) {
1951
+ if (!this.canTarget(target) || this.isTargetDefeated(target)) return;
1952
+ this.debugLog('vision', `Target ${target.id} entered vision (state=${this.state})`);
1953
+ this.engageTarget(target);
1954
+ }
1955
+
1956
+ private engageTarget(target: ActionBattleEntity) {
1957
+ this.target = target;
1701
1958
 
1702
1959
  if (this.state === AiState.Idle) {
1703
1960
  this.changeState(AiState.Alert);
@@ -1709,12 +1966,10 @@ export class BattleAi {
1709
1966
  /**
1710
1967
  * Handle player leaving vision
1711
1968
  */
1712
- onDetectOutShape(player: InstanceType<typeof RpgPlayer>, shape: any) {
1713
- this.debugLog('vision', `Player ${player.id} left vision (wasTarget=${this.target === player})`);
1714
- if (this.target === player) {
1715
- this.target = null;
1716
- this.isMovingToTarget = false;
1717
- this.event.stopMoveTo();
1969
+ onDetectOutShape(target: ActionBattleEntity, shape: any) {
1970
+ this.debugLog('vision', `Target ${target.id} left vision (wasTarget=${this.target === target})`);
1971
+ if (this.target === target) {
1972
+ this.clearTarget();
1718
1973
  this.changeState(AiState.Idle);
1719
1974
  }
1720
1975
  }
@@ -1725,7 +1980,7 @@ export class BattleAi {
1725
1980
  * This triggers state changes like stun and flee check.
1726
1981
  * The actual damage is applied externally via RPGJS API.
1727
1982
  */
1728
- takeDamage(attacker: RpgPlayer): boolean {
1983
+ takeDamage(attacker: ActionBattleEntity): boolean {
1729
1984
  if (this.defeated) return true;
1730
1985
  // Apply damage using RPGJS system
1731
1986
  const raw = this.event.applyDamage(attacker);
@@ -1737,7 +1992,7 @@ export class BattleAi {
1737
1992
  }
1738
1993
 
1739
1994
  handleDamage(
1740
- attacker: RpgPlayer,
1995
+ attacker: ActionBattleEntity,
1741
1996
  damageResult: ActionBattleDamageResult & {
1742
1997
  reaction?: NormalizedActionBattleHitReactionProfile;
1743
1998
  }
@@ -1747,15 +2002,17 @@ export class BattleAi {
1747
2002
  this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
1748
2003
 
1749
2004
  // Visual feedback
1750
- this.event.flash({
1751
- type: 'tint',
1752
- tint: 'red',
1753
- duration: 200,
1754
- cycles: 1
1755
- });
1756
- this.event.showHit(`-${damage}`);
1757
- playActionBattleAnimation("hurt", this.event, this.animations, {
1758
- attacker,
2005
+ withActionBattleAnimationUnlocked(this.event, () => {
2006
+ emitActionBattleClientVisual({
2007
+ moment: "hurt",
2008
+ entity: this.event,
2009
+ target: this.event,
2010
+ attacker,
2011
+ damage,
2012
+ defeated: damageResult.defeated,
2013
+ result: damageResult,
2014
+ animations: this.animations,
2015
+ });
1759
2016
  });
1760
2017
 
1761
2018
  // Track damage
@@ -1794,7 +2051,7 @@ export class BattleAi {
1794
2051
  * Stops all movements, cleans up resources, calls the onDefeated hook,
1795
2052
  * and removes the event from the map.
1796
2053
  */
1797
- private kill(attacker?: RpgPlayer) {
2054
+ private kill(attacker?: ActionBattleEntity) {
1798
2055
  if (this.defeated) return;
1799
2056
  this.defeated = true;
1800
2057
 
@@ -1828,7 +2085,7 @@ export class BattleAi {
1828
2085
  });
1829
2086
  };
1830
2087
 
1831
- if (this.autoAwardRewards) {
2088
+ if (this.autoAwardRewards && attacker && isActionBattlePlayer(attacker)) {
1832
2089
  reward.giveTo(attacker);
1833
2090
  }
1834
2091
 
@@ -1846,7 +2103,7 @@ export class BattleAi {
1846
2103
  (
1847
2104
  this.onDefeatedCallback as (
1848
2105
  event: RpgEvent,
1849
- attacker?: RpgPlayer
2106
+ attacker?: ActionBattleEntity
1850
2107
  ) => void
1851
2108
  )(this.event, attacker);
1852
2109
  } else {
@@ -1869,6 +2126,68 @@ export class BattleAi {
1869
2126
  return Math.sqrt(dx * dx + dy * dy);
1870
2127
  }
1871
2128
 
2129
+ private resolveUsable(usable: any) {
2130
+ if (!usable) return usable;
2131
+ const id = typeof usable === "string" ? usable : usable.id;
2132
+ const learned = id ? (this.event as any).getSkill?.(id) : undefined;
2133
+ if (learned) return learned;
2134
+ try {
2135
+ return id ? (this.event as any).databaseById?.(id) ?? usable : usable;
2136
+ } catch {
2137
+ return usable;
2138
+ }
2139
+ }
2140
+
2141
+ private getCurrentActionRange(): number | undefined {
2142
+ const skillRange = this.attackSkill
2143
+ ? getActionBattleActionRange(this.resolveUsable(this.attackSkill))
2144
+ : undefined;
2145
+ if (skillRange !== undefined) return skillRange;
2146
+ return getActionBattleActionRange(resolveActionBattleWeapon(this.event));
2147
+ }
2148
+
2149
+ private canTarget(target: ActionBattleEntity): boolean {
2150
+ return canActionBattleTarget(
2151
+ this.event,
2152
+ target,
2153
+ this.targets,
2154
+ getActionBattleOptions().combat?.targets
2155
+ );
2156
+ }
2157
+
2158
+ private findNearestTarget(): ActionBattleEntity | null {
2159
+ const map = this.event.getCurrentMap();
2160
+ if (!map) return null;
2161
+
2162
+ const candidates: ActionBattleEntity[] = [];
2163
+ map.getPlayers?.().forEach((player: RpgPlayer) => candidates.push(player));
2164
+ map.getEvents?.().forEach((event: RpgEvent) => candidates.push(event));
2165
+
2166
+ let nearest: ActionBattleEntity | null = null;
2167
+ let nearestDistance = Number.POSITIVE_INFINITY;
2168
+ for (const candidate of candidates) {
2169
+ if (!this.canTarget(candidate)) continue;
2170
+ const distance = this.getDistance(this.event, candidate);
2171
+ if (distance > this.visionRange) continue;
2172
+ if (distance < nearestDistance) {
2173
+ nearest = candidate;
2174
+ nearestDistance = distance;
2175
+ }
2176
+ }
2177
+
2178
+ return nearest;
2179
+ }
2180
+
2181
+ private isTargetDefeated(target: ActionBattleEntity | null | undefined): boolean {
2182
+ return !target || target.hp <= 0;
2183
+ }
2184
+
2185
+ private clearTarget() {
2186
+ this.target = null;
2187
+ this.isMovingToTarget = false;
2188
+ this.event.stopMoveTo();
2189
+ }
2190
+
1872
2191
  private updateBehavior(currentTime: number) {
1873
2192
  if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) {
1874
2193
  return;
@@ -1927,10 +2246,25 @@ export class BattleAi {
1927
2246
  }
1928
2247
  }
1929
2248
 
1930
- private applyCustomBehavior(currentTime: number) {
1931
- if (!this.behaviorKey) return;
2249
+ private applyCustomBehavior(currentTime: number): boolean {
2250
+ let handled = false;
2251
+
2252
+ if (this.behaviorTree) {
2253
+ const result = this.behaviorTree.tick(this.createAiTreeContext(currentTime));
2254
+ if (result.decision) {
2255
+ handled = this.applyAiDecision(result.decision, currentTime) || handled;
2256
+ }
2257
+ if (result.intent) {
2258
+ handled = this.executeAiIntents(result.intent, currentTime) || handled;
2259
+ }
2260
+ if (result.status === "running") {
2261
+ handled = true;
2262
+ }
2263
+ }
2264
+
2265
+ if (!this.behaviorKey) return handled;
1932
2266
  const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
1933
- if (!behavior) return;
2267
+ if (!behavior) return handled;
1934
2268
  const maxHp = this.event.param[MAXHP];
1935
2269
  const decision = behavior({
1936
2270
  event: this.event,
@@ -1941,7 +2275,16 @@ export class BattleAi {
1941
2275
  hpPercent: maxHp ? this.event.hp / maxHp : null,
1942
2276
  now: currentTime,
1943
2277
  });
1944
- if (!decision) return;
2278
+ if (!decision) return handled;
2279
+ return this.applyAiDecision(decision, currentTime) || handled;
2280
+ }
2281
+
2282
+ private applyAiDecision(
2283
+ decision: ActionBattleAiDecision | ReturnType<ActionBattleAiBehavior>,
2284
+ currentTime: number
2285
+ ): boolean {
2286
+ if (!decision) return false;
2287
+ let handled = false;
1945
2288
  if (decision.attackCooldown !== undefined) {
1946
2289
  this.attackCooldown = decision.attackCooldown;
1947
2290
  }
@@ -1955,6 +2298,164 @@ export class BattleAi {
1955
2298
  this.behaviorMode = decision.mode;
1956
2299
  this.behaviorEnabled = true;
1957
2300
  }
2301
+ if (decision.intent) {
2302
+ handled = this.executeAiIntents(decision.intent, currentTime);
2303
+ }
2304
+ return handled;
2305
+ }
2306
+
2307
+ private createAiTreeContext(currentTime: number) {
2308
+ const maxHp = this.event.param[MAXHP];
2309
+ const distance = this.target ? this.getDistance(this.event, this.target) : null;
2310
+ return {
2311
+ event: this.event,
2312
+ target: this.target,
2313
+ state: this.state,
2314
+ enemyType: this.enemyType,
2315
+ distance,
2316
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
2317
+ now: currentTime,
2318
+ self: {
2319
+ event: this.event,
2320
+ state: this.state,
2321
+ enemyType: this.enemyType,
2322
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
2323
+ attackRange: this.attackRange,
2324
+ },
2325
+ targetInfo:
2326
+ this.target && distance !== null
2327
+ ? {
2328
+ entity: this.target,
2329
+ distance,
2330
+ inAttackRange: distance <= this.attackRange,
2331
+ visible: true,
2332
+ }
2333
+ : null,
2334
+ memory: this.aiMemory,
2335
+ };
2336
+ }
2337
+
2338
+ private executeAiIntents(
2339
+ input: ActionBattleAiIntent | ActionBattleAiIntent[],
2340
+ currentTime: number
2341
+ ): boolean {
2342
+ const intents = Array.isArray(input) ? input : [input];
2343
+ let handled = false;
2344
+ for (const intent of intents) {
2345
+ handled = this.executeAiIntent(intent, currentTime) || handled;
2346
+ }
2347
+ return handled;
2348
+ }
2349
+
2350
+ private executeAiIntent(
2351
+ intent: ActionBattleAiIntent,
2352
+ currentTime: number
2353
+ ): boolean {
2354
+ const consumes = intent.consume !== false;
2355
+
2356
+ switch (intent.type) {
2357
+ case "setMode":
2358
+ this.behaviorMode = intent.mode;
2359
+ this.behaviorEnabled = true;
2360
+ return consumes;
2361
+ case "idle":
2362
+ this.isMovingToTarget = false;
2363
+ this.event.stopMoveTo();
2364
+ return consumes;
2365
+ case "patrol":
2366
+ this.startPatrol();
2367
+ return consumes;
2368
+ case "faceTarget":
2369
+ this.faceTarget();
2370
+ return consumes;
2371
+ case "moveToTarget":
2372
+ if (!this.target) return false;
2373
+ this.isMovingToTarget = true;
2374
+ this.requestMoveTo(this.target);
2375
+ return consumes;
2376
+ case "fleeFromTarget":
2377
+ if (!this.target) return false;
2378
+ this.isMovingToTarget = false;
2379
+ if (this.state === AiState.Combat) {
2380
+ this.changeState(AiState.Flee);
2381
+ } else {
2382
+ this.fleeFromTarget();
2383
+ }
2384
+ return consumes;
2385
+ case "keepDistance":
2386
+ return this.executeKeepDistance(intent, consumes);
2387
+ case "useAttack":
2388
+ return this.executeRequestedAttack(intent.pattern, currentTime, consumes);
2389
+ case "useSkill":
2390
+ return this.executeRequestedSkill(intent.skill, currentTime, consumes);
2391
+ }
2392
+ }
2393
+
2394
+ private executeKeepDistance(
2395
+ intent: Extract<ActionBattleAiIntent, { type: "keepDistance" }>,
2396
+ consumes: boolean
2397
+ ): boolean {
2398
+ if (!this.target) return false;
2399
+ const tolerance = intent.tolerance ?? Math.max(8, intent.distance * 0.15);
2400
+ const distance = this.getDistance(this.event, this.target);
2401
+ if (distance < intent.distance - tolerance) {
2402
+ this.isMovingToTarget = false;
2403
+ this.retreatFromTarget();
2404
+ return consumes;
2405
+ }
2406
+ if (distance > intent.distance + tolerance) {
2407
+ this.isMovingToTarget = true;
2408
+ this.requestMoveTo(this.target);
2409
+ return consumes;
2410
+ }
2411
+ if (this.isMovingToTarget) {
2412
+ this.isMovingToTarget = false;
2413
+ this.event.stopMoveTo();
2414
+ }
2415
+ return consumes;
2416
+ }
2417
+
2418
+ private executeRequestedAttack(
2419
+ pattern: AttackPattern | string | undefined,
2420
+ currentTime: number,
2421
+ consumes: boolean
2422
+ ): boolean {
2423
+ if (!this.target || this.isTargetDefeated(this.target) || this.chargingAttack) return false;
2424
+ const distance = this.getDistance(this.event, this.target);
2425
+ if (distance > this.attackRange) return false;
2426
+ if (currentTime - this.lastAttackTime < this.attackCooldown) return false;
2427
+
2428
+ if (pattern) {
2429
+ this.performAttackPattern(pattern as AttackPattern);
2430
+ } else {
2431
+ this.selectAndPerformAttack();
2432
+ }
2433
+ this.lastAttackTime = currentTime;
2434
+ return consumes;
2435
+ }
2436
+
2437
+ private executeRequestedSkill(
2438
+ skill: any,
2439
+ currentTime: number,
2440
+ consumes: boolean
2441
+ ): boolean {
2442
+ if (!this.target || this.isTargetDefeated(this.target) || !skill) return false;
2443
+ const distance = this.getDistance(this.event, this.target);
2444
+ const resolvedSkill = this.resolveUsable(skill);
2445
+ const range = getActionBattleActionRange(resolvedSkill) ?? this.attackRange;
2446
+ const cooldownRemaining = this.attackCooldown - (currentTime - this.lastAttackTime);
2447
+ if (distance > range) return false;
2448
+ if (cooldownRemaining > 0) return false;
2449
+
2450
+ executeActionBattleUse({
2451
+ attacker: this.event,
2452
+ target: this.target,
2453
+ usable: resolvedSkill,
2454
+ skill: resolvedSkill,
2455
+ profile: this.getAttackProfile(AttackPattern.Melee),
2456
+ });
2457
+ this.lastAttackTime = currentTime;
2458
+ return consumes;
1958
2459
  }
1959
2460
 
1960
2461
  private handleTacticalMovement(distance: number) {
@@ -2016,6 +2517,7 @@ export class BattleAi {
2016
2517
  private schedule(callback: () => void, delay: number) {
2017
2518
  const timer = setTimeout(() => {
2018
2519
  this.timers = this.timers.filter((entry) => entry !== timer);
2520
+ if (this.destroyed) return;
2019
2521
  callback();
2020
2522
  }, delay);
2021
2523
  this.timers.push(timer);
@@ -2025,14 +2527,31 @@ export class BattleAi {
2025
2527
  // Public getters
2026
2528
  getHealth(): number { return this.event.hp; }
2027
2529
  getMaxHealth(): number { return this.event.param[MAXHP]; }
2028
- getTarget(): InstanceType<typeof RpgPlayer> | null { return this.target; }
2530
+ getTarget(): ActionBattleEntity | null { return this.target; }
2029
2531
  getState(): AiState { return this.state; }
2532
+ getFaction(): string | undefined {
2533
+ return this.faction;
2534
+ }
2535
+ setFaction(faction: string | undefined): void {
2536
+ this.faction = faction;
2537
+ }
2538
+ getTargets(): ActionBattleTargetSelector {
2539
+ return this.targets;
2540
+ }
2541
+ setTargets(targets: ActionBattleTargetSelector): void {
2542
+ this.targets = targets;
2543
+ if (this.target && !this.canTarget(this.target)) {
2544
+ this.clearTarget();
2545
+ this.changeState(AiState.Idle);
2546
+ }
2547
+ }
2030
2548
  getEnemyType(): EnemyType { return this.enemyType; }
2031
2549
 
2032
2550
  /**
2033
2551
  * Clean up
2034
2552
  */
2035
2553
  destroy() {
2554
+ this.destroyed = true;
2036
2555
  if (this.updateInterval) {
2037
2556
  clearInterval(this.updateInterval);
2038
2557
  this.updateInterval = undefined;