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

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 +57 -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 +3 -2
  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 +203 -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 +70 -0
  25. package/dist/client/index22.js +226 -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 +1949 -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 +57 -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 +3 -2
  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 +67 -11
  56. package/dist/server/index14.js +207 -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 +1949 -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 +208 -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 +5 -5
  78. package/src/ai.server.spec.ts +380 -1
  79. package/src/ai.server.ts +963 -137
  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/config.ts +84 -37
  86. package/src/core/action-use.spec.ts +317 -0
  87. package/src/core/action-use.ts +387 -0
  88. package/src/core/ai-behavior-tree.spec.ts +116 -0
  89. package/src/core/ai-behavior-tree.ts +272 -0
  90. package/src/core/attack-profile.spec.ts +46 -0
  91. package/src/core/attack-runtime.spec.ts +35 -0
  92. package/src/core/attack-runtime.ts +32 -0
  93. package/src/core/context.ts +9 -0
  94. package/src/core/contracts.ts +146 -1
  95. package/src/core/defaults.ts +72 -1
  96. package/src/core/equipment.ts +9 -5
  97. package/src/core/hit.spec.ts +21 -0
  98. package/src/core/targets.spec.ts +124 -0
  99. package/src/core/targets.ts +150 -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,
@@ -30,6 +60,31 @@ type RpgEventWithBattleAi = RpgEvent & {
30
60
  battleAi?: BattleAi;
31
61
  };
32
62
 
63
+ type ResolvedMoveTarget =
64
+ | {
65
+ kind: "entity";
66
+ target: ActionBattleEntity;
67
+ id?: string;
68
+ x?: number;
69
+ y?: number;
70
+ signature: string;
71
+ }
72
+ | {
73
+ kind: "position";
74
+ target: { x: number; y: number };
75
+ x: number;
76
+ y: number;
77
+ signature: string;
78
+ };
79
+
80
+ const resolveMoveCoordinate = (value: unknown): number | undefined => {
81
+ const raw = typeof value === "function" ? (value as () => unknown)() : value;
82
+ return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
83
+ };
84
+
85
+ const createMoveSignature = (x: number, y: number): string =>
86
+ `position:${Math.round(x)}:${Math.round(y)}`;
87
+
33
88
  export interface BattleAiRewardItem {
34
89
  item?: any;
35
90
  itemId?: string;
@@ -51,7 +106,7 @@ export interface BattleAiDefeatReward {
51
106
 
52
107
  export interface BattleAiDefeatedContext {
53
108
  event: RpgEvent;
54
- attacker?: RpgPlayer;
109
+ attacker?: ActionBattleEntity;
55
110
  reward: BattleAiDefeatReward;
56
111
  remove: () => void;
57
112
  }
@@ -62,10 +117,13 @@ export type BattleAiDefeatedCallback = (
62
117
 
63
118
  export type BattleAiLegacyDefeatedCallback = (
64
119
  event: RpgEvent,
65
- attacker?: RpgPlayer
120
+ attacker?: ActionBattleEntity
66
121
  ) => void;
67
122
 
68
123
  export interface BattleAiBaseOptions {
124
+ preset?: string | ActionBattleAiPreset;
125
+ faction?: string;
126
+ targets?: ActionBattleTargetSelector;
69
127
  enemyType?: EnemyType;
70
128
  attackCooldown?: number;
71
129
  visionRange?: number;
@@ -91,6 +149,9 @@ export interface BattleAiBaseOptions {
91
149
  retreatThreshold?: number;
92
150
  };
93
151
  behaviorKey?: string;
152
+ tree?: ActionBattleAiTreeInput;
153
+ behaviorTree?: ActionBattleAiTreeInput;
154
+ simpleBehavior?: ActionBattleAiSimpleBehavior;
94
155
  animations?: ActionBattleAnimationOptions;
95
156
  rewards?: BattleAiRewards;
96
157
  autoAwardRewards?: boolean;
@@ -298,16 +359,10 @@ export const AiDebug = {
298
359
  * @param data - Optional additional data
299
360
  */
300
361
  log(category: string, eventId: string | undefined, message: string, data?: any): void {
301
- if (!this.enabled) return;
302
- if (this.filterEventId && eventId !== this.filterEventId) return;
303
- if (this.categories.length > 0 && !this.categories.includes(category)) return;
304
-
305
- const prefix = `[AI:${category}]${eventId ? ` [${eventId.substring(0, 8)}]` : ''}`;
306
- if (data !== undefined) {
307
- console.log(prefix, message, data);
308
- } else {
309
- console.log(prefix, message);
310
- }
362
+ void category;
363
+ void eventId;
364
+ void message;
365
+ void data;
311
366
  }
312
367
  };
313
368
 
@@ -363,6 +418,49 @@ export const DEFAULT_KNOCKBACK = {
363
418
  duration: 300
364
419
  };
365
420
 
421
+ const mergeBattleAiPresetOptions = (
422
+ options: BattleAiOptions | BattleAiLegacyOptions,
423
+ seen: Set<string> = new Set()
424
+ ): BattleAiOptions | BattleAiLegacyOptions => {
425
+ if (!options.preset) return options;
426
+
427
+ if (typeof options.preset === "string") {
428
+ if (seen.has(options.preset)) {
429
+ throw new Error(`Circular action battle AI preset: ${options.preset}`);
430
+ }
431
+ seen.add(options.preset);
432
+ }
433
+
434
+ const preset =
435
+ typeof options.preset === "string"
436
+ ? getActionBattleSystems().ai.presets[options.preset]
437
+ : options.preset;
438
+
439
+ if (!preset) {
440
+ throw new Error(`Action battle AI preset not found: ${options.preset}`);
441
+ }
442
+
443
+ const resolvedPreset = mergeBattleAiPresetOptions(preset, seen);
444
+ const { preset: _preset, ...overrides } = options;
445
+
446
+ return {
447
+ ...resolvedPreset,
448
+ ...overrides,
449
+ behavior: {
450
+ ...resolvedPreset.behavior,
451
+ ...overrides.behavior,
452
+ },
453
+ animations: {
454
+ ...resolvedPreset.animations,
455
+ ...overrides.animations,
456
+ },
457
+ rewards: {
458
+ ...resolvedPreset.rewards,
459
+ ...overrides.rewards,
460
+ },
461
+ } as BattleAiOptions | BattleAiLegacyOptions;
462
+ };
463
+
366
464
  /**
367
465
  * Advanced Battle AI Controller for events
368
466
  *
@@ -407,7 +505,7 @@ export const DEFAULT_KNOCKBACK = {
407
505
  */
408
506
  export class BattleAi {
409
507
  private event: RpgEvent;
410
- private target: InstanceType<typeof RpgPlayer> | null = null;
508
+ private target: ActionBattleEntity | null = null;
411
509
  private lastAttackTime: number = 0;
412
510
  private updateInterval?: any;
413
511
 
@@ -418,6 +516,37 @@ export class BattleAi {
418
516
  AiDebug.log(category, this.event.id, message, data);
419
517
  }
420
518
 
519
+ private traceLog(category: string, message: string, data?: any): void {
520
+ void category;
521
+ void message;
522
+ void data;
523
+ }
524
+
525
+ private lockActionUntil(until: number, reason: string, data?: any): void {
526
+ if (until <= this.actionLockedUntil) return;
527
+ this.actionLockedUntil = until;
528
+ this.traceLog("state", "action locked", {
529
+ reason,
530
+ lockedMs: Math.max(0, until - Date.now()),
531
+ ...data,
532
+ });
533
+ }
534
+
535
+ private lockForAttack(
536
+ profile: NormalizedActionBattleAttackProfile,
537
+ pattern: AttackPattern
538
+ ): void {
539
+ this.isMovingToTarget = false;
540
+ this.event.stopMoveTo();
541
+ this.lockActionUntil(Date.now() + profile.totalDurationMs, "attack", {
542
+ pattern,
543
+ totalDurationMs: profile.totalDurationMs,
544
+ startupMs: profile.startupMs,
545
+ activeMs: profile.activeMs,
546
+ recoveryMs: profile.recoveryMs,
547
+ });
548
+ }
549
+
421
550
  // State machine
422
551
  private state: AiState = AiState.Idle;
423
552
  private stateStartTime: number = 0;
@@ -425,6 +554,8 @@ export class BattleAi {
425
554
 
426
555
  // Enemy type and behavior
427
556
  private enemyType: EnemyType;
557
+ private faction?: string;
558
+ private targets: ActionBattleTargetSelector = "players";
428
559
  private attackCooldown: number = 1000;
429
560
  private visionRange: number = 150;
430
561
  private attackRange: number = 60;
@@ -489,11 +620,23 @@ export class BattleAi {
489
620
  private lastMoveToTime: number = 0;
490
621
  private retreatCooldown: number = 600;
491
622
  private lastRetreatTime: number = 0;
623
+ private actionLockedUntil: number = 0;
624
+ private lastActionLockTraceTime: number = 0;
625
+ private lastMoveToCooldownTraceTime: number = 0;
626
+ private lastMoveToCooldownTraceSignature: string | null = null;
627
+ private lastTargetMovementSkipTraceTime: number = 0;
492
628
  private timers: ReturnType<typeof setTimeout>[] = [];
493
629
  private behaviorKey?: string;
630
+ private behaviorTree?: ActionBattleAiTreeNode;
631
+ private aiMemory: ActionBattleAiMemory = {};
494
632
  private poise: number = 0;
495
633
  private hitstunMs: number = 150;
496
634
  private invincibilityMs: number = 250;
635
+ private visionShape?: any;
636
+ private visionSetupRetries: number = 0;
637
+ private maxVisionSetupRetries: number = 20;
638
+ private destroyed: boolean = false;
639
+ private lastNoTargetTraceTime: number = 0;
497
640
 
498
641
  /**
499
642
  * Create a new Battle AI Controller
@@ -525,11 +668,14 @@ export class BattleAi {
525
668
  event: RpgEventWithBattleAi,
526
669
  options: BattleAiOptions | BattleAiLegacyOptions = {}
527
670
  ) {
671
+ options = mergeBattleAiPresetOptions(options);
528
672
  event.battleAi = this;
529
673
  this.event = event;
530
674
 
531
675
  // Set enemy type and apply behavior modifiers
532
676
  this.enemyType = options.enemyType || EnemyType.Aggressive;
677
+ this.faction = options.faction;
678
+ this.targets = options.targets ?? "players";
533
679
  this.behaviorKey = options.behaviorKey ?? this.enemyType;
534
680
  this.applyEnemyTypeBehavior(options);
535
681
 
@@ -597,9 +743,21 @@ export class BattleAi {
597
743
  if (options.invincibilityMs !== undefined) {
598
744
  this.invincibilityMs = Math.max(0, options.invincibilityMs);
599
745
  }
746
+ if (options.tree || options.behaviorTree) {
747
+ this.behaviorTree = defineAiTree(options.tree ?? options.behaviorTree!);
748
+ } else if (options.simpleBehavior) {
749
+ this.behaviorTree = defineAiBehavior(options.simpleBehavior);
750
+ }
751
+
752
+ if (options.attackRange === undefined) {
753
+ const actionRange = this.getCurrentActionRange();
754
+ if (actionRange !== undefined) {
755
+ this.attackRange = actionRange;
756
+ }
757
+ }
600
758
 
601
759
  // Setup AI systems
602
- this.setupVision();
760
+ this.scheduleVisionSetup();
603
761
  this.startAiBehaviorLoop();
604
762
  if (this.patrolWaypoints.length > 0) {
605
763
  this.startPatrol();
@@ -681,14 +839,56 @@ export class BattleAi {
681
839
  /**
682
840
  * Setup vision detection
683
841
  */
684
- private setupVision() {
842
+ private setupVision(): boolean {
843
+ if (this.visionShape) return true;
844
+ const map = this.event.getCurrentMap?.();
845
+ if (
846
+ map?.physic?.getEntityByUUID &&
847
+ !map.physic.getEntityByUUID(this.event.id)
848
+ ) {
849
+ this.traceLog("vision", "physics body not ready", {
850
+ retries: this.visionSetupRetries,
851
+ hasMap: !!map,
852
+ });
853
+ return false;
854
+ }
855
+
685
856
  const diameter = this.visionRange * 2;
686
- this.event.attachShape(`vision_${this.event.id}`, {
857
+ const shape = this.event.attachShape(`vision_${this.event.id}`, {
687
858
  radius: this.visionRange,
688
859
  width: diameter,
689
860
  height: diameter,
690
861
  angle: 360,
691
862
  });
863
+ if (!shape) {
864
+ this.traceLog("vision", "attachShape returned no shape", {
865
+ retries: this.visionSetupRetries,
866
+ visionRange: this.visionRange,
867
+ });
868
+ return false;
869
+ }
870
+ this.visionShape = shape;
871
+ this.traceLog("vision", "vision attached", {
872
+ shapeId: (shape as any)?.id,
873
+ visionRange: this.visionRange,
874
+ });
875
+ return true;
876
+ }
877
+
878
+ private scheduleVisionSetup() {
879
+ if (this.destroyed || this.setupVision()) return;
880
+ if (this.visionSetupRetries >= this.maxVisionSetupRetries) {
881
+ this.traceLog("vision", "vision setup gave up", {
882
+ retries: this.visionSetupRetries,
883
+ });
884
+ return;
885
+ }
886
+
887
+ this.visionSetupRetries++;
888
+ this.schedule(() => {
889
+ if (this.destroyed || !this.event.getCurrentMap()) return;
890
+ this.scheduleVisionSetup();
891
+ }, 50);
692
892
  }
693
893
 
694
894
  /**
@@ -723,10 +923,19 @@ export class BattleAi {
723
923
 
724
924
  if (!validTransitions[this.state].includes(newState)) {
725
925
  this.debugLog('state', `INVALID transition ${this.state} -> ${newState}`);
926
+ this.traceLog("state", "invalid transition", {
927
+ from: this.state,
928
+ to: newState,
929
+ });
726
930
  return;
727
931
  }
728
932
 
729
933
  this.debugLog('state', `STATE change: ${this.state} -> ${newState}`);
934
+ this.traceLog("state", "state change", {
935
+ from: this.state,
936
+ to: newState,
937
+ targetId: this.target?.id,
938
+ });
730
939
  this.state = newState;
731
940
  this.stateStartTime = Date.now();
732
941
 
@@ -759,6 +968,14 @@ export class BattleAi {
759
968
  private updateAiBehavior() {
760
969
  const currentTime = Date.now();
761
970
 
971
+ if (this.target && this.isTargetDefeated(this.target)) {
972
+ this.debugLog('combat', `Target ${this.target.id} is defeated, returning to idle`);
973
+ this.clearTarget();
974
+ this.changeState(AiState.Idle);
975
+ this.checkDamageTaken();
976
+ return;
977
+ }
978
+
762
979
  // Update group behavior
763
980
  if (this.groupBehavior) {
764
981
  this.updateGroupBehavior();
@@ -772,6 +989,20 @@ export class BattleAi {
772
989
  return;
773
990
  }
774
991
 
992
+ if (currentTime < this.actionLockedUntil) {
993
+ if (this.target) this.faceTarget();
994
+ if (currentTime - this.lastActionLockTraceTime > 250) {
995
+ this.lastActionLockTraceTime = currentTime;
996
+ this.traceLog("state", "waiting action recovery", {
997
+ remainingMs: this.actionLockedUntil - currentTime,
998
+ state: this.state,
999
+ targetId: this.target?.id,
1000
+ });
1001
+ }
1002
+ this.checkDamageTaken();
1003
+ return;
1004
+ }
1005
+
775
1006
  // Berserker: faster attacks when HP is low
776
1007
  if (this.enemyType === EnemyType.Berserker && this.event.param[MAXHP]) {
777
1008
  const hpPercent = this.event.hp / this.event.param[MAXHP];
@@ -784,7 +1015,18 @@ export class BattleAi {
784
1015
  this.updateBehavior(currentTime);
785
1016
  }
786
1017
 
787
- this.applyCustomBehavior(currentTime);
1018
+ if (!this.target && this.state === AiState.Idle) {
1019
+ const target = this.findNearestTarget();
1020
+ if (target) {
1021
+ this.engageTarget(target);
1022
+ }
1023
+ }
1024
+
1025
+ const customBehaviorHandled = this.applyCustomBehavior(currentTime);
1026
+ if (customBehaviorHandled) {
1027
+ this.checkDamageTaken();
1028
+ return;
1029
+ }
788
1030
 
789
1031
  // State-specific behavior
790
1032
  switch (this.state) {
@@ -810,6 +1052,12 @@ export class BattleAi {
810
1052
  * Update idle behavior (patrolling)
811
1053
  */
812
1054
  private updateIdleBehavior() {
1055
+ const target = this.findNearestTarget();
1056
+ if (target) {
1057
+ this.engageTarget(target);
1058
+ return;
1059
+ }
1060
+
813
1061
  if (this.patrolWaypoints.length > 0) {
814
1062
  const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
815
1063
  const distance = this.getDistance(this.event, {
@@ -832,8 +1080,28 @@ export class BattleAi {
832
1080
  this.faceTarget();
833
1081
 
834
1082
  const distance = this.getDistance(this.event, this.target);
1083
+ this.traceLog("movement", "alert update", {
1084
+ targetId: this.target.id,
1085
+ distance,
1086
+ attackRange: this.attackRange,
1087
+ visionRange: this.visionRange,
1088
+ isMovingToTarget: this.isMovingToTarget,
1089
+ });
835
1090
  if (distance <= this.attackRange * 1.5) {
1091
+ if (this.isMovingToTarget) {
1092
+ this.isMovingToTarget = false;
1093
+ this.event.stopMoveTo();
1094
+ }
836
1095
  this.changeState(AiState.Combat);
1096
+ } else if (distance <= this.visionRange * 1.5) {
1097
+ if (!this.isMovingToTarget) {
1098
+ this.debugLog('movement', `Alert approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1099
+ this.requestTargetMovement();
1100
+ }
1101
+ } else {
1102
+ this.debugLog('combat', `Alert target out of range (dist=${distance.toFixed(1)})`);
1103
+ this.clearTarget();
1104
+ this.changeState(AiState.Idle);
837
1105
  }
838
1106
  } else {
839
1107
  this.changeState(AiState.Idle);
@@ -851,13 +1119,20 @@ export class BattleAi {
851
1119
  }
852
1120
 
853
1121
  const distance = this.getDistance(this.event, this.target);
1122
+ this.traceLog("combat", "combat update", {
1123
+ targetId: this.target.id,
1124
+ distance,
1125
+ attackRange: this.attackRange,
1126
+ visionRange: this.visionRange,
1127
+ isMovingToTarget: this.isMovingToTarget,
1128
+ behaviorEnabled: this.behaviorEnabled,
1129
+ behaviorMode: this.behaviorMode,
1130
+ });
854
1131
 
855
1132
  // Check if target is still in range
856
1133
  if (distance > this.visionRange * 1.5) {
857
1134
  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();
1135
+ this.clearTarget();
861
1136
  this.changeState(AiState.Idle);
862
1137
  return;
863
1138
  }
@@ -907,8 +1182,7 @@ export class BattleAi {
907
1182
  } else if (distance > this.attackRange) {
908
1183
  if (!this.isMovingToTarget) {
909
1184
  this.debugLog('movement', `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
910
- this.isMovingToTarget = true;
911
- this.requestMoveTo(this.target);
1185
+ this.requestTargetMovement();
912
1186
  }
913
1187
  } else {
914
1188
  if (this.isMovingToTarget) {
@@ -921,8 +1195,7 @@ export class BattleAi {
921
1195
  if (distance > this.attackRange) {
922
1196
  if (!this.isMovingToTarget) {
923
1197
  this.debugLog('movement', `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
924
- this.isMovingToTarget = true;
925
- this.requestMoveTo(this.target);
1198
+ this.requestTargetMovement();
926
1199
  }
927
1200
  } else {
928
1201
  if (this.isMovingToTarget) {
@@ -1068,11 +1341,10 @@ export class BattleAi {
1068
1341
  if (!this.target) return;
1069
1342
  const profile = this.getAttackProfile(AttackPattern.Melee);
1070
1343
 
1071
- this.faceTarget();
1344
+ this.faceTarget({ force: true });
1345
+ this.lockForAttack(profile, AttackPattern.Melee);
1072
1346
  this.telegraphAttack(profile);
1073
- playActionBattleAnimation("attack", this.event, this.animations, {
1074
- target: this.target,
1075
- });
1347
+ this.playAttackVisual(profile, AttackPattern.Melee);
1076
1348
 
1077
1349
  this.scheduleAttackStartup(profile, () => {
1078
1350
  this.executeMeleeAttack(profile, AttackPattern.Melee);
@@ -1083,24 +1355,43 @@ export class BattleAi {
1083
1355
  profile: NormalizedActionBattleAttackProfile,
1084
1356
  pattern: AttackPattern
1085
1357
  ) {
1086
- if (!this.target) return;
1358
+ if (!this.target || this.isTargetDefeated(this.target)) return;
1087
1359
  this.debugLog('attack', `Applying ${pattern} hit`);
1088
1360
 
1089
- // Use skill if available
1090
1361
  if (this.attackSkill) {
1362
+ const resolvedSkill = this.resolveUsable(this.attackSkill);
1091
1363
  try {
1092
- playActionBattleAnimation("castSkill", this.event, this.animations, {
1093
- skill: this.attackSkill,
1364
+ executeActionBattleUse({
1365
+ attacker: this.event,
1094
1366
  target: this.target,
1367
+ usable: resolvedSkill,
1368
+ skill: resolvedSkill,
1369
+ pattern,
1370
+ profile,
1095
1371
  });
1096
- this.event.useSkill(this.attackSkill, this.target);
1097
1372
  } catch (e) {
1098
1373
  // Skill failed (no SP, etc.) - fall back to basic attack
1099
1374
  this.performBasicHitbox(profile, pattern);
1100
1375
  }
1101
- } else {
1102
- this.performBasicHitbox(profile, pattern);
1376
+ return;
1377
+ }
1378
+
1379
+ const weapon = resolveActionBattleWeapon(this.event);
1380
+ if (
1381
+ weapon &&
1382
+ executeActionBattleUse({
1383
+ attacker: this.event,
1384
+ target: this.target,
1385
+ usable: weapon,
1386
+ weapon,
1387
+ pattern,
1388
+ profile,
1389
+ })
1390
+ ) {
1391
+ return;
1103
1392
  }
1393
+
1394
+ this.performBasicHitbox(profile, pattern);
1104
1395
  }
1105
1396
 
1106
1397
  /**
@@ -1112,7 +1403,21 @@ export class BattleAi {
1112
1403
  ),
1113
1404
  pattern: AttackPattern = AttackPattern.Melee
1114
1405
  ) {
1115
- if (!this.target) return;
1406
+ if (!this.target || this.isTargetDefeated(this.target)) return;
1407
+
1408
+ const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
1409
+ runActionBattleActiveHitbox(
1410
+ { ...profile, startupMs: 0 },
1411
+ () => this.resolveBasicHitboxes(),
1412
+ (hitboxes) => {
1413
+ this.processHitboxHits(hitboxes, hitTracker, profile, pattern);
1414
+ },
1415
+ (scheduled, delay) => this.schedule(scheduled, delay)
1416
+ );
1417
+ }
1418
+
1419
+ private resolveBasicHitboxes(): ActionBattleHitbox[] {
1420
+ if (!this.target || this.isTargetDefeated(this.target)) return [];
1116
1421
 
1117
1422
  const eventX = this.event.x();
1118
1423
  const eventY = this.event.y();
@@ -1120,30 +1425,51 @@ export class BattleAi {
1120
1425
  const dy = this.target.y() - eventY;
1121
1426
  const dist = Math.sqrt(dx * dx + dy * dy);
1122
1427
 
1123
- if (dist === 0) return;
1428
+ if (dist === 0) return [];
1124
1429
 
1125
1430
  const dirX = dx / dist;
1126
1431
  const dirY = dy / dist;
1127
1432
 
1128
- const hitboxes = [{
1433
+ return [{
1129
1434
  x: eventX + dirX * 30,
1130
1435
  y: eventY + dirY * 30,
1131
1436
  width: 40,
1132
1437
  height: 40,
1133
1438
  }];
1439
+ }
1134
1440
 
1441
+ private queryHitboxCandidates(hitboxes: ActionBattleHitbox[]) {
1135
1442
  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
- });
1443
+ if (!map || typeof (map as any).queryHitbox !== "function") return [];
1444
+
1445
+ const candidates = new Map<string, ActionBattleEntity>();
1446
+ for (const hitbox of hitboxes) {
1447
+ for (const hit of (map as any).queryHitbox(hitbox, {
1448
+ excludeIds: [this.event.id],
1449
+ kinds: ["players", "events"],
1450
+ })) {
1451
+ if (hit?.id) candidates.set(hit.id, hit);
1452
+ }
1453
+ }
1454
+ return Array.from(candidates.values());
1455
+ }
1456
+
1457
+ private processHitboxHits(
1458
+ hitboxes: ActionBattleHitbox[],
1459
+ hitTracker: ActionBattleHitTracker,
1460
+ profile: NormalizedActionBattleAttackProfile,
1461
+ pattern: AttackPattern
1462
+ ) {
1463
+ for (const hit of this.queryHitboxCandidates(hitboxes)) {
1464
+ if (
1465
+ hit !== this.event &&
1466
+ this.canTarget(hit) &&
1467
+ !this.isTargetDefeated(hit) &&
1468
+ hitTracker.tryHit(hit)
1469
+ ) {
1470
+ this.applyHit(hit, undefined, profile, pattern);
1471
+ }
1472
+ }
1147
1473
  }
1148
1474
 
1149
1475
  /**
@@ -1175,13 +1501,24 @@ export class BattleAi {
1175
1501
  * ```
1176
1502
  */
1177
1503
  private applyHit(
1178
- target: RpgPlayer,
1504
+ target: ActionBattleEntity,
1179
1505
  hooks?: ApplyHitHooks,
1180
1506
  profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
1181
1507
  AttackPattern.Melee
1182
1508
  ),
1183
1509
  pattern: AttackPattern = AttackPattern.Melee
1184
1510
  ): HitResult {
1511
+ if (this.isTargetDefeated(target)) {
1512
+ return {
1513
+ damage: 0,
1514
+ knockbackForce: 0,
1515
+ knockbackDuration: 0,
1516
+ defeated: true,
1517
+ attacker: this.event,
1518
+ target
1519
+ };
1520
+ }
1521
+
1185
1522
  if (isActionBattleEntityInvincible(target)) {
1186
1523
  return {
1187
1524
  damage: 0,
@@ -1219,13 +1556,16 @@ export class BattleAi {
1219
1556
  }
1220
1557
 
1221
1558
  // Visual feedback
1222
- target.flash({
1223
- type: 'tint',
1224
- tint: 'red',
1225
- duration: 200,
1226
- cycles: 1
1559
+ withActionBattleAnimationUnlocked(target, () => {
1560
+ emitActionBattleClientVisual({
1561
+ moment: "hurt",
1562
+ entity: this.event,
1563
+ target,
1564
+ attacker: this.event,
1565
+ damage: hitResult.damage,
1566
+ result: hitResult,
1567
+ });
1227
1568
  });
1228
- target.showHit(`-${hitResult.damage}`);
1229
1569
  setActionBattleInvincibility(
1230
1570
  target,
1231
1571
  profile.reaction.invincibilityMs
@@ -1251,6 +1591,16 @@ export class BattleAi {
1251
1591
  hooks.onAfterHit(hitResult);
1252
1592
  }
1253
1593
 
1594
+ const targetAi = (target as any).battleAi as BattleAi | undefined;
1595
+ if (targetAi) {
1596
+ targetAi.handleDamage(this.event, {
1597
+ damage: hitResult.damage,
1598
+ defeated: hitResult.defeated,
1599
+ raw: undefined,
1600
+ reaction: profile.reaction,
1601
+ });
1602
+ }
1603
+
1254
1604
  return hitResult;
1255
1605
  }
1256
1606
 
@@ -1294,11 +1644,10 @@ export class BattleAi {
1294
1644
 
1295
1645
  this.comboCount++;
1296
1646
  const profile = this.getAttackProfile(AttackPattern.Combo);
1297
- this.faceTarget();
1647
+ this.faceTarget({ force: true });
1648
+ this.lockForAttack(profile, AttackPattern.Combo);
1298
1649
  this.telegraphAttack(profile);
1299
- playActionBattleAnimation("attack", this.event, this.animations, {
1300
- target: this.target,
1301
- });
1650
+ this.playAttackVisual(profile, AttackPattern.Combo);
1302
1651
  this.scheduleAttackStartup(profile, () => {
1303
1652
  this.executeMeleeAttack(profile, AttackPattern.Combo);
1304
1653
  });
@@ -1324,17 +1673,10 @@ export class BattleAi {
1324
1673
  const profile = this.getAttackProfile(AttackPattern.Charged);
1325
1674
 
1326
1675
  this.chargingAttack = true;
1327
- this.faceTarget();
1676
+ this.faceTarget({ force: true });
1677
+ this.lockForAttack(profile, AttackPattern.Charged);
1328
1678
  this.telegraphAttack(profile);
1329
- playActionBattleAnimation(
1330
- "attack",
1331
- this.event,
1332
- this.animations,
1333
- {
1334
- target: this.target,
1335
- },
1336
- { repeat: 2 }
1337
- );
1679
+ this.playAttackVisual(profile, AttackPattern.Charged, { repeat: 2 });
1338
1680
 
1339
1681
  this.scheduleAttackStartup(profile, () => {
1340
1682
  if (!this.target || this.state !== AiState.Combat) {
@@ -1353,10 +1695,9 @@ export class BattleAi {
1353
1695
  */
1354
1696
  private performZoneAttack() {
1355
1697
  const profile = this.getAttackProfile(AttackPattern.Zone);
1698
+ this.lockForAttack(profile, AttackPattern.Zone);
1356
1699
  this.telegraphAttack(profile);
1357
- playActionBattleAnimation("attack", this.event, this.animations, {
1358
- target: this.target ?? undefined,
1359
- });
1700
+ this.playAttackVisual(profile, AttackPattern.Zone);
1360
1701
 
1361
1702
  const eventX = this.event.x();
1362
1703
  const eventY = this.event.y();
@@ -1376,18 +1717,20 @@ export class BattleAi {
1376
1717
  });
1377
1718
 
1378
1719
  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
- });
1720
+ const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
1721
+ runActionBattleActiveHitbox(
1722
+ { ...profile, startupMs: 0 },
1723
+ () => hitboxes,
1724
+ (activeHitboxes) => {
1725
+ this.processHitboxHits(
1726
+ activeHitboxes,
1727
+ hitTracker,
1728
+ profile,
1729
+ AttackPattern.Zone
1730
+ );
1389
1731
  },
1390
- });
1732
+ (scheduled, delay) => this.schedule(scheduled, delay)
1733
+ );
1391
1734
  });
1392
1735
  }
1393
1736
 
@@ -1395,7 +1738,7 @@ export class BattleAi {
1395
1738
  * Perform dash attack
1396
1739
  */
1397
1740
  private performDashAttack() {
1398
- if (!this.target) return;
1741
+ if (!this.target || this.isTargetDefeated(this.target)) return;
1399
1742
  const profile = this.getAttackProfile(AttackPattern.DashAttack);
1400
1743
 
1401
1744
  const dx = this.target.x() - this.event.x();
@@ -1407,12 +1750,14 @@ export class BattleAi {
1407
1750
  const dirX = dx / dist;
1408
1751
  const dirY = dy / dist;
1409
1752
 
1410
- this.faceTarget();
1753
+ this.faceTarget({ force: true });
1754
+ this.lockForAttack(profile, AttackPattern.DashAttack);
1411
1755
  this.telegraphAttack(profile);
1756
+ this.playAttackVisual(profile, AttackPattern.DashAttack);
1412
1757
 
1413
1758
  this.scheduleAttackStartup(profile, () => {
1414
1759
  if (!this.target || this.state !== AiState.Combat) return;
1415
- this.event.dash({ x: dirX, y: dirY }, 10, 200);
1760
+ safeActionBattleDash(this.event, { x: dirX, y: dirY }, 10, 200);
1416
1761
  this.schedule(() => {
1417
1762
  if (!this.target || this.state !== AiState.Combat) return;
1418
1763
  this.executeMeleeAttack(profile, AttackPattern.DashAttack);
@@ -1428,6 +1773,28 @@ export class BattleAi {
1428
1773
  ] ?? this.attackProfiles.melee;
1429
1774
  }
1430
1775
 
1776
+ private playAttackVisual(
1777
+ profile: NormalizedActionBattleAttackProfile,
1778
+ pattern: AttackPattern,
1779
+ animationDefaults?: { animationName?: string; repeat?: number }
1780
+ ): void {
1781
+ const moment =
1782
+ profile.animationKey === "castSkill" || profile.animationKey === "castSpell"
1783
+ ? "castSkill"
1784
+ : "attack";
1785
+
1786
+ withActionBattleAnimationUnlocked(this.event, () => {
1787
+ emitActionBattleClientVisual({
1788
+ moment,
1789
+ entity: this.event,
1790
+ target: this.target ?? undefined,
1791
+ pattern,
1792
+ animations: this.animations,
1793
+ animationDefaults,
1794
+ });
1795
+ });
1796
+ }
1797
+
1431
1798
  private telegraphAttack(profile: NormalizedActionBattleAttackProfile) {
1432
1799
  if (profile.startupMs <= 0) return;
1433
1800
  this.event.flash({
@@ -1455,7 +1822,7 @@ export class BattleAi {
1455
1822
  * 2. When near diagonal, require significant difference to change
1456
1823
  * 3. Only change if direction is clearly wrong (opposite)
1457
1824
  */
1458
- private faceTarget() {
1825
+ private faceTarget(options: { force?: boolean } = {}) {
1459
1826
  if (!this.target) return;
1460
1827
 
1461
1828
  const dx = this.target.x() - this.event.x();
@@ -1464,11 +1831,12 @@ export class BattleAi {
1464
1831
  const absY = Math.abs(dy);
1465
1832
  const distance = Math.sqrt(dx * dx + dy * dy);
1466
1833
 
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;
1834
+ // Avoid undefined facing only when both entities are effectively stacked.
1835
+ // A larger dead-zone makes close melee targets keep stale directions after
1836
+ // knockback, so keep this threshold intentionally tiny.
1837
+ const minDistanceForDirectionChange = 4;
1470
1838
  if (this.lastFacingDirection && distance < minDistanceForDirectionChange) {
1471
- return; // Keep current direction when in collision
1839
+ return;
1472
1840
  }
1473
1841
 
1474
1842
  // Calculate the "ideal" direction
@@ -1500,7 +1868,11 @@ export class BattleAi {
1500
1868
  }
1501
1869
 
1502
1870
  this.lastFacingDirection = newDirection;
1503
- this.event.changeDirection(newDirection as any);
1871
+ if (options.force) {
1872
+ applyActionBattleAttackDirection(this.event, newDirection as any);
1873
+ } else {
1874
+ this.event.changeDirection(newDirection as any);
1875
+ }
1504
1876
  }
1505
1877
 
1506
1878
  /**
@@ -1531,7 +1903,9 @@ export class BattleAi {
1531
1903
  const side = Math.random() > 0.5 ? 1 : -1;
1532
1904
 
1533
1905
  this.debugLog('dodge', `Dodging (dir=${side > 0 ? 'right' : 'left'})`);
1534
- this.event.dash({ x: dodgeDirX * side, y: dodgeDirY * side }, 12, 300);
1906
+ if (!safeActionBattleDash(this.event, { x: dodgeDirX * side, y: dodgeDirY * side }, 12, 300)) {
1907
+ return false;
1908
+ }
1535
1909
  this.lastDodgeTime = currentTime;
1536
1910
 
1537
1911
  // Counter-attack for defensive types
@@ -1570,8 +1944,8 @@ export class BattleAi {
1570
1944
  if (dist === 0) return;
1571
1945
 
1572
1946
  const fleeTarget = {
1573
- x: () => this.event.x() + (dx / dist) * 200,
1574
- y: () => this.event.y() + (dy / dist) * 200
1947
+ x: this.event.x() + (dx / dist) * 200,
1948
+ y: this.event.y() + (dy / dist) * 200
1575
1949
  };
1576
1950
 
1577
1951
  this.requestMoveTo(fleeTarget as any);
@@ -1593,7 +1967,9 @@ export class BattleAi {
1593
1967
 
1594
1968
  if (dist === 0) return;
1595
1969
 
1596
- this.event.dash({ x: dx / dist, y: dy / dist }, 8, 200);
1970
+ if (!safeActionBattleDash(this.event, { x: dx / dist, y: dy / dist }, 8, 200)) {
1971
+ return;
1972
+ }
1597
1973
  this.lastRetreatTime = currentTime;
1598
1974
  }
1599
1975
 
@@ -1616,7 +1992,7 @@ export class BattleAi {
1616
1992
  if (this.patrolWaypoints.length === 0) return;
1617
1993
 
1618
1994
  const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
1619
- this.requestMoveTo({ x: () => waypoint.x, y: () => waypoint.y } as any);
1995
+ this.requestMoveTo({ x: waypoint.x, y: waypoint.y } as any);
1620
1996
  }
1621
1997
 
1622
1998
  /**
@@ -1688,16 +2064,37 @@ export class BattleAi {
1688
2064
  );
1689
2065
 
1690
2066
  if (distanceToFormation > 20) {
1691
- this.requestMoveTo({ x: () => formationX, y: () => formationY } as any);
2067
+ this.requestMoveTo({ x: formationX, y: formationY } as any);
1692
2068
  }
1693
2069
  }
1694
2070
 
1695
2071
  /**
1696
2072
  * Handle player entering vision
1697
2073
  */
1698
- onDetectInShape(player: InstanceType<typeof RpgPlayer>, shape: any) {
1699
- this.debugLog('vision', `Player ${player.id} entered vision (state=${this.state})`);
1700
- this.target = player;
2074
+ onDetectInShape(target: ActionBattleEntity, shape: any) {
2075
+ const canTarget = this.canTarget(target);
2076
+ const defeated = this.isTargetDefeated(target);
2077
+ this.traceLog("vision", "detect in shape", {
2078
+ targetId: target?.id,
2079
+ shapeId: shape?.id,
2080
+ canTarget,
2081
+ defeated,
2082
+ state: this.state,
2083
+ targetHp: (target as any)?.hp,
2084
+ });
2085
+ if (!canTarget || defeated) return;
2086
+ this.debugLog('vision', `Target ${target.id} entered vision (state=${this.state})`);
2087
+ this.engageTarget(target);
2088
+ }
2089
+
2090
+ private engageTarget(target: ActionBattleEntity) {
2091
+ this.traceLog("target", "engage target", {
2092
+ targetId: target.id,
2093
+ previousTargetId: this.target?.id,
2094
+ state: this.state,
2095
+ distance: this.getDistance(this.event, target),
2096
+ });
2097
+ this.target = target;
1701
2098
 
1702
2099
  if (this.state === AiState.Idle) {
1703
2100
  this.changeState(AiState.Alert);
@@ -1709,12 +2106,16 @@ export class BattleAi {
1709
2106
  /**
1710
2107
  * Handle player leaving vision
1711
2108
  */
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();
2109
+ onDetectOutShape(target: ActionBattleEntity, shape: any) {
2110
+ this.debugLog('vision', `Target ${target.id} left vision (wasTarget=${this.target === target})`);
2111
+ this.traceLog("vision", "detect out shape", {
2112
+ targetId: target?.id,
2113
+ shapeId: shape?.id,
2114
+ wasTarget: this.target === target,
2115
+ state: this.state,
2116
+ });
2117
+ if (this.target === target) {
2118
+ this.clearTarget();
1718
2119
  this.changeState(AiState.Idle);
1719
2120
  }
1720
2121
  }
@@ -1725,7 +2126,7 @@ export class BattleAi {
1725
2126
  * This triggers state changes like stun and flee check.
1726
2127
  * The actual damage is applied externally via RPGJS API.
1727
2128
  */
1728
- takeDamage(attacker: RpgPlayer): boolean {
2129
+ takeDamage(attacker: ActionBattleEntity): boolean {
1729
2130
  if (this.defeated) return true;
1730
2131
  // Apply damage using RPGJS system
1731
2132
  const raw = this.event.applyDamage(attacker);
@@ -1737,34 +2138,75 @@ export class BattleAi {
1737
2138
  }
1738
2139
 
1739
2140
  handleDamage(
1740
- attacker: RpgPlayer,
2141
+ attacker: ActionBattleEntity,
1741
2142
  damageResult: ActionBattleDamageResult & {
1742
2143
  reaction?: NormalizedActionBattleHitReactionProfile;
1743
2144
  }
1744
2145
  ): boolean {
1745
2146
  if (this.defeated) return true;
1746
- const damage = damageResult.damage;
2147
+ const damage = Number.isFinite(damageResult.damage) ? damageResult.damage : 0;
1747
2148
  this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
2149
+ const canRetaliate = attacker ? this.canTarget(attacker) : false;
2150
+ const attackerDefeated = this.isTargetDefeated(attacker);
2151
+ this.traceLog("damage", "handle damage", {
2152
+ attackerId: attacker?.id,
2153
+ damage,
2154
+ defeated: damageResult.defeated,
2155
+ eventHp: this.event.hp,
2156
+ maxHp: this.event.param[MAXHP],
2157
+ state: this.state,
2158
+ canRetaliate,
2159
+ attackerDefeated,
2160
+ currentTargetId: this.target?.id,
2161
+ });
1748
2162
 
1749
2163
  // 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,
2164
+ withActionBattleAnimationUnlocked(this.event, () => {
2165
+ emitActionBattleClientVisual({
2166
+ moment: "hurt",
2167
+ entity: this.event,
2168
+ target: this.event,
2169
+ attacker,
2170
+ damage,
2171
+ defeated: damageResult.defeated,
2172
+ result: damageResult,
2173
+ animations: this.animations,
2174
+ });
1759
2175
  });
1760
2176
 
1761
2177
  // Track damage
1762
2178
  this.recentDamageTaken += damage;
1763
2179
 
2180
+ if (
2181
+ attacker &&
2182
+ this.canTarget(attacker) &&
2183
+ !this.isTargetDefeated(attacker) &&
2184
+ this.state !== AiState.Flee
2185
+ ) {
2186
+ this.traceLog("target", "retaliate against attacker", {
2187
+ attackerId: attacker.id,
2188
+ previousTargetId: this.target?.id,
2189
+ state: this.state,
2190
+ });
2191
+ this.target = attacker;
2192
+ if (this.state === AiState.Idle || this.state === AiState.Alert) {
2193
+ this.isMovingToTarget = false;
2194
+ this.changeState(AiState.Combat);
2195
+ }
2196
+ }
2197
+
1764
2198
  const reaction = damageResult.reaction;
1765
2199
  const staggerPower = reaction?.staggerPower ?? damage;
1766
2200
  const hitstunMs = reaction?.hitstunMs ?? this.hitstunMs;
1767
- const shouldStun = staggerPower >= this.poise && hitstunMs > 0;
2201
+ const shouldStun =
2202
+ (damage > 0 || (reaction?.staggerPower ?? 0) > 0) &&
2203
+ staggerPower >= this.poise &&
2204
+ hitstunMs > 0;
2205
+ this.lockActionUntil(Date.now() + Math.max(220, hitstunMs + 120), "damage recovery", {
2206
+ attackerId: attacker?.id,
2207
+ damage,
2208
+ hitstunMs,
2209
+ });
1768
2210
  setActionBattleInvincibility(
1769
2211
  this.event,
1770
2212
  reaction?.invincibilityMs ?? this.invincibilityMs
@@ -1794,7 +2236,7 @@ export class BattleAi {
1794
2236
  * Stops all movements, cleans up resources, calls the onDefeated hook,
1795
2237
  * and removes the event from the map.
1796
2238
  */
1797
- private kill(attacker?: RpgPlayer) {
2239
+ private kill(attacker?: ActionBattleEntity) {
1798
2240
  if (this.defeated) return;
1799
2241
  this.defeated = true;
1800
2242
 
@@ -1828,7 +2270,7 @@ export class BattleAi {
1828
2270
  });
1829
2271
  };
1830
2272
 
1831
- if (this.autoAwardRewards) {
2273
+ if (this.autoAwardRewards && attacker && isActionBattlePlayer(attacker)) {
1832
2274
  reward.giveTo(attacker);
1833
2275
  }
1834
2276
 
@@ -1846,7 +2288,7 @@ export class BattleAi {
1846
2288
  (
1847
2289
  this.onDefeatedCallback as (
1848
2290
  event: RpgEvent,
1849
- attacker?: RpgPlayer
2291
+ attacker?: ActionBattleEntity
1850
2292
  ) => void
1851
2293
  )(this.event, attacker);
1852
2294
  } else {
@@ -1869,6 +2311,96 @@ export class BattleAi {
1869
2311
  return Math.sqrt(dx * dx + dy * dy);
1870
2312
  }
1871
2313
 
2314
+ private resolveUsable(usable: any) {
2315
+ if (!usable) return usable;
2316
+ const id = typeof usable === "string" ? usable : usable.id;
2317
+ const learned = id ? (this.event as any).getSkill?.(id) : undefined;
2318
+ if (learned) return learned;
2319
+ try {
2320
+ return id ? (this.event as any).databaseById?.(id) ?? usable : usable;
2321
+ } catch {
2322
+ return usable;
2323
+ }
2324
+ }
2325
+
2326
+ private getCurrentActionRange(): number | undefined {
2327
+ const skillRange = this.attackSkill
2328
+ ? getActionBattleActionRange(this.resolveUsable(this.attackSkill))
2329
+ : undefined;
2330
+ if (skillRange !== undefined) return skillRange;
2331
+ return getActionBattleActionRange(resolveActionBattleWeapon(this.event));
2332
+ }
2333
+
2334
+ private canTarget(target: ActionBattleEntity): boolean {
2335
+ return canActionBattleTarget(
2336
+ this.event,
2337
+ target,
2338
+ this.targets,
2339
+ getActionBattleOptions().combat?.targets
2340
+ );
2341
+ }
2342
+
2343
+ private findNearestTarget(): ActionBattleEntity | null {
2344
+ const map = this.event.getCurrentMap();
2345
+ if (!map) {
2346
+ this.traceLog("target", "find nearest skipped: no map");
2347
+ return null;
2348
+ }
2349
+
2350
+ const candidates: ActionBattleEntity[] = [];
2351
+ map.getPlayers?.().forEach((player: RpgPlayer) => candidates.push(player));
2352
+ map.getEvents?.().forEach((event: RpgEvent) => candidates.push(event));
2353
+
2354
+ let nearest: ActionBattleEntity | null = null;
2355
+ let nearestDistance = Number.POSITIVE_INFINITY;
2356
+ for (const candidate of candidates) {
2357
+ if (!this.canTarget(candidate)) continue;
2358
+ const distance = this.getDistance(this.event, candidate);
2359
+ if (distance > this.visionRange) continue;
2360
+ if (distance < nearestDistance) {
2361
+ nearest = candidate;
2362
+ nearestDistance = distance;
2363
+ }
2364
+ }
2365
+
2366
+ const now = Date.now();
2367
+ if (nearest) {
2368
+ this.traceLog("target", "nearest target found", {
2369
+ targetId: nearest.id,
2370
+ distance: nearestDistance,
2371
+ visionRange: this.visionRange,
2372
+ candidates: candidates.length,
2373
+ });
2374
+ } else if (now - this.lastNoTargetTraceTime > 1000) {
2375
+ this.lastNoTargetTraceTime = now;
2376
+ this.traceLog("target", "no target found", {
2377
+ visionRange: this.visionRange,
2378
+ candidates: candidates.map((candidate) => {
2379
+ const distance = this.getDistance(this.event, candidate);
2380
+ return {
2381
+ id: candidate.id,
2382
+ hp: (candidate as any).hp,
2383
+ canTarget: this.canTarget(candidate),
2384
+ defeated: this.isTargetDefeated(candidate),
2385
+ distance,
2386
+ };
2387
+ }),
2388
+ });
2389
+ }
2390
+
2391
+ return nearest;
2392
+ }
2393
+
2394
+ private isTargetDefeated(target: ActionBattleEntity | null | undefined): boolean {
2395
+ return !target || target.hp <= 0;
2396
+ }
2397
+
2398
+ private clearTarget() {
2399
+ this.target = null;
2400
+ this.isMovingToTarget = false;
2401
+ this.event.stopMoveTo();
2402
+ }
2403
+
1872
2404
  private updateBehavior(currentTime: number) {
1873
2405
  if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) {
1874
2406
  return;
@@ -1927,10 +2459,25 @@ export class BattleAi {
1927
2459
  }
1928
2460
  }
1929
2461
 
1930
- private applyCustomBehavior(currentTime: number) {
1931
- if (!this.behaviorKey) return;
2462
+ private applyCustomBehavior(currentTime: number): boolean {
2463
+ let handled = false;
2464
+
2465
+ if (this.behaviorTree) {
2466
+ const result = this.behaviorTree.tick(this.createAiTreeContext(currentTime));
2467
+ if (result.decision) {
2468
+ handled = this.applyAiDecision(result.decision, currentTime) || handled;
2469
+ }
2470
+ if (result.intent) {
2471
+ handled = this.executeAiIntents(result.intent, currentTime) || handled;
2472
+ }
2473
+ if (result.status === "running") {
2474
+ handled = true;
2475
+ }
2476
+ }
2477
+
2478
+ if (!this.behaviorKey) return handled;
1932
2479
  const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
1933
- if (!behavior) return;
2480
+ if (!behavior) return handled;
1934
2481
  const maxHp = this.event.param[MAXHP];
1935
2482
  const decision = behavior({
1936
2483
  event: this.event,
@@ -1941,7 +2488,16 @@ export class BattleAi {
1941
2488
  hpPercent: maxHp ? this.event.hp / maxHp : null,
1942
2489
  now: currentTime,
1943
2490
  });
1944
- if (!decision) return;
2491
+ if (!decision) return handled;
2492
+ return this.applyAiDecision(decision, currentTime) || handled;
2493
+ }
2494
+
2495
+ private applyAiDecision(
2496
+ decision: ActionBattleAiDecision | ReturnType<ActionBattleAiBehavior>,
2497
+ currentTime: number
2498
+ ): boolean {
2499
+ if (!decision) return false;
2500
+ let handled = false;
1945
2501
  if (decision.attackCooldown !== undefined) {
1946
2502
  this.attackCooldown = decision.attackCooldown;
1947
2503
  }
@@ -1955,6 +2511,162 @@ export class BattleAi {
1955
2511
  this.behaviorMode = decision.mode;
1956
2512
  this.behaviorEnabled = true;
1957
2513
  }
2514
+ if (decision.intent) {
2515
+ handled = this.executeAiIntents(decision.intent, currentTime);
2516
+ }
2517
+ return handled;
2518
+ }
2519
+
2520
+ private createAiTreeContext(currentTime: number) {
2521
+ const maxHp = this.event.param[MAXHP];
2522
+ const distance = this.target ? this.getDistance(this.event, this.target) : null;
2523
+ return {
2524
+ event: this.event,
2525
+ target: this.target,
2526
+ state: this.state,
2527
+ enemyType: this.enemyType,
2528
+ distance,
2529
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
2530
+ now: currentTime,
2531
+ self: {
2532
+ event: this.event,
2533
+ state: this.state,
2534
+ enemyType: this.enemyType,
2535
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
2536
+ attackRange: this.attackRange,
2537
+ },
2538
+ targetInfo:
2539
+ this.target && distance !== null
2540
+ ? {
2541
+ entity: this.target,
2542
+ distance,
2543
+ inAttackRange: distance <= this.attackRange,
2544
+ visible: true,
2545
+ }
2546
+ : null,
2547
+ memory: this.aiMemory,
2548
+ };
2549
+ }
2550
+
2551
+ private executeAiIntents(
2552
+ input: ActionBattleAiIntent | ActionBattleAiIntent[],
2553
+ currentTime: number
2554
+ ): boolean {
2555
+ const intents = Array.isArray(input) ? input : [input];
2556
+ let handled = false;
2557
+ for (const intent of intents) {
2558
+ handled = this.executeAiIntent(intent, currentTime) || handled;
2559
+ }
2560
+ return handled;
2561
+ }
2562
+
2563
+ private executeAiIntent(
2564
+ intent: ActionBattleAiIntent,
2565
+ currentTime: number
2566
+ ): boolean {
2567
+ const consumes = intent.consume !== false;
2568
+
2569
+ switch (intent.type) {
2570
+ case "setMode":
2571
+ this.behaviorMode = intent.mode;
2572
+ this.behaviorEnabled = true;
2573
+ return consumes;
2574
+ case "idle":
2575
+ this.isMovingToTarget = false;
2576
+ this.event.stopMoveTo();
2577
+ return consumes;
2578
+ case "patrol":
2579
+ this.startPatrol();
2580
+ return consumes;
2581
+ case "faceTarget":
2582
+ this.faceTarget();
2583
+ return consumes;
2584
+ case "moveToTarget":
2585
+ if (!this.target) return false;
2586
+ this.requestTargetMovement();
2587
+ return consumes;
2588
+ case "fleeFromTarget":
2589
+ if (!this.target) return false;
2590
+ this.isMovingToTarget = false;
2591
+ if (this.state === AiState.Combat) {
2592
+ this.changeState(AiState.Flee);
2593
+ } else {
2594
+ this.fleeFromTarget();
2595
+ }
2596
+ return consumes;
2597
+ case "keepDistance":
2598
+ return this.executeKeepDistance(intent, consumes);
2599
+ case "useAttack":
2600
+ return this.executeRequestedAttack(intent.pattern, currentTime, consumes);
2601
+ case "useSkill":
2602
+ return this.executeRequestedSkill(intent.skill, currentTime, consumes);
2603
+ }
2604
+ }
2605
+
2606
+ private executeKeepDistance(
2607
+ intent: Extract<ActionBattleAiIntent, { type: "keepDistance" }>,
2608
+ consumes: boolean
2609
+ ): boolean {
2610
+ if (!this.target) return false;
2611
+ const tolerance = intent.tolerance ?? Math.max(8, intent.distance * 0.15);
2612
+ const distance = this.getDistance(this.event, this.target);
2613
+ if (distance < intent.distance - tolerance) {
2614
+ this.isMovingToTarget = false;
2615
+ this.retreatFromTarget();
2616
+ return consumes;
2617
+ }
2618
+ if (distance > intent.distance + tolerance) {
2619
+ this.requestTargetMovement();
2620
+ return consumes;
2621
+ }
2622
+ if (this.isMovingToTarget) {
2623
+ this.isMovingToTarget = false;
2624
+ this.event.stopMoveTo();
2625
+ }
2626
+ return consumes;
2627
+ }
2628
+
2629
+ private executeRequestedAttack(
2630
+ pattern: AttackPattern | string | undefined,
2631
+ currentTime: number,
2632
+ consumes: boolean
2633
+ ): boolean {
2634
+ if (!this.target || this.isTargetDefeated(this.target) || this.chargingAttack) return false;
2635
+ const distance = this.getDistance(this.event, this.target);
2636
+ if (distance > this.attackRange) return false;
2637
+ if (currentTime - this.lastAttackTime < this.attackCooldown) return false;
2638
+
2639
+ if (pattern) {
2640
+ this.performAttackPattern(pattern as AttackPattern);
2641
+ } else {
2642
+ this.selectAndPerformAttack();
2643
+ }
2644
+ this.lastAttackTime = currentTime;
2645
+ return consumes;
2646
+ }
2647
+
2648
+ private executeRequestedSkill(
2649
+ skill: any,
2650
+ currentTime: number,
2651
+ consumes: boolean
2652
+ ): boolean {
2653
+ if (!this.target || this.isTargetDefeated(this.target) || !skill) return false;
2654
+ const distance = this.getDistance(this.event, this.target);
2655
+ const resolvedSkill = this.resolveUsable(skill);
2656
+ const range = getActionBattleActionRange(resolvedSkill) ?? this.attackRange;
2657
+ const cooldownRemaining = this.attackCooldown - (currentTime - this.lastAttackTime);
2658
+ if (distance > range) return false;
2659
+ if (cooldownRemaining > 0) return false;
2660
+
2661
+ executeActionBattleUse({
2662
+ attacker: this.event,
2663
+ target: this.target,
2664
+ usable: resolvedSkill,
2665
+ skill: resolvedSkill,
2666
+ profile: this.getAttackProfile(AttackPattern.Melee),
2667
+ });
2668
+ this.lastAttackTime = currentTime;
2669
+ return consumes;
1958
2670
  }
1959
2671
 
1960
2672
  private handleTacticalMovement(distance: number) {
@@ -1972,8 +2684,7 @@ export class BattleAi {
1972
2684
  if (distance > maxRange) {
1973
2685
  if (!this.isMovingToTarget) {
1974
2686
  this.debugLog('movement', `Tactical approach (dist=${distance.toFixed(1)}, maxRange=${maxRange.toFixed(1)})`);
1975
- this.isMovingToTarget = true;
1976
- this.requestMoveTo(this.target);
2687
+ this.requestTargetMovement();
1977
2688
  }
1978
2689
  return;
1979
2690
  }
@@ -1990,8 +2701,7 @@ export class BattleAi {
1990
2701
  if (distance > this.attackRange) {
1991
2702
  if (!this.isMovingToTarget) {
1992
2703
  this.debugLog('movement', `Assault approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1993
- this.isMovingToTarget = true;
1994
- this.requestMoveTo(this.target);
2704
+ this.requestTargetMovement();
1995
2705
  }
1996
2706
  return;
1997
2707
  }
@@ -2003,19 +2713,118 @@ export class BattleAi {
2003
2713
  }
2004
2714
  }
2005
2715
 
2716
+ private resolveMoveTarget(target: any): ResolvedMoveTarget | null {
2717
+ if (!target) return null;
2718
+
2719
+ const targetId =
2720
+ target.id !== undefined && target.id !== null ? String(target.id) : undefined;
2721
+ const x = resolveMoveCoordinate(target.x);
2722
+ const y = resolveMoveCoordinate(target.y);
2723
+
2724
+ if (targetId) {
2725
+ return {
2726
+ kind: "entity",
2727
+ target,
2728
+ id: targetId,
2729
+ x,
2730
+ y,
2731
+ signature: `entity:${targetId}`,
2732
+ };
2733
+ }
2734
+
2735
+ if (x === undefined || y === undefined) {
2736
+ return null;
2737
+ }
2738
+
2739
+ return {
2740
+ kind: "position",
2741
+ target: { x, y },
2742
+ x,
2743
+ y,
2744
+ signature: createMoveSignature(x, y),
2745
+ };
2746
+ }
2747
+
2006
2748
  private requestMoveTo(target: any): boolean {
2007
2749
  const currentTime = Date.now();
2750
+ const resolvedTarget = this.resolveMoveTarget(target);
2751
+ if (!resolvedTarget) {
2752
+ this.traceLog("movement", "moveTo skipped: invalid target", {
2753
+ target,
2754
+ });
2755
+ return false;
2756
+ }
2757
+
2008
2758
  if (currentTime - this.lastMoveToTime < this.moveToCooldown) {
2759
+ if (
2760
+ this.lastMoveToCooldownTraceSignature !== resolvedTarget.signature ||
2761
+ currentTime - this.lastMoveToCooldownTraceTime > 1000
2762
+ ) {
2763
+ this.lastMoveToCooldownTraceTime = currentTime;
2764
+ this.lastMoveToCooldownTraceSignature = resolvedTarget.signature;
2765
+ this.traceLog("movement", "moveTo skipped: cooldown", {
2766
+ targetKind: resolvedTarget.kind,
2767
+ targetId:
2768
+ resolvedTarget.kind === "entity" ? resolvedTarget.id : undefined,
2769
+ targetPosition: {
2770
+ x: resolvedTarget.x,
2771
+ y: resolvedTarget.y,
2772
+ },
2773
+ elapsed: currentTime - this.lastMoveToTime,
2774
+ moveToCooldown: this.moveToCooldown,
2775
+ });
2776
+ }
2009
2777
  return false;
2010
2778
  }
2011
- this.event.moveTo(target as any);
2779
+ const map = this.event.getCurrentMap?.() as any;
2780
+ const hasBody =
2781
+ !!map?.physic?.getEntityByUUID?.(this.event.id) ||
2782
+ !!map?.getBody?.(this.event.id);
2783
+ this.traceLog("movement", "moveTo requested", {
2784
+ targetKind: resolvedTarget.kind,
2785
+ targetId:
2786
+ resolvedTarget.kind === "entity" ? resolvedTarget.id : undefined,
2787
+ eventPosition: {
2788
+ x: this.event.x?.(),
2789
+ y: this.event.y?.(),
2790
+ },
2791
+ targetPosition: {
2792
+ x: resolvedTarget.x,
2793
+ y: resolvedTarget.y,
2794
+ },
2795
+ hasMap: !!map,
2796
+ hasMovementBody: hasBody,
2797
+ });
2798
+ this.event.moveTo(resolvedTarget.target as any);
2012
2799
  this.lastMoveToTime = currentTime;
2013
2800
  return true;
2014
2801
  }
2015
2802
 
2803
+ private requestTargetMovement(target: ActionBattleEntity | null = this.target): boolean {
2804
+ if (!target) {
2805
+ this.traceLog("movement", "target movement skipped: no target");
2806
+ return false;
2807
+ }
2808
+ const started = this.requestMoveTo(target);
2809
+ if (started) {
2810
+ this.isMovingToTarget = true;
2811
+ } else {
2812
+ const now = Date.now();
2813
+ if (now - this.lastTargetMovementSkipTraceTime > 1000) {
2814
+ this.lastTargetMovementSkipTraceTime = now;
2815
+ this.traceLog("movement", "target movement did not start", {
2816
+ targetId: target.id,
2817
+ isMovingToTarget: this.isMovingToTarget,
2818
+ });
2819
+ }
2820
+ }
2821
+ return started;
2822
+ }
2823
+
2016
2824
  private schedule(callback: () => void, delay: number) {
2017
2825
  const timer = setTimeout(() => {
2018
2826
  this.timers = this.timers.filter((entry) => entry !== timer);
2827
+ if (this.destroyed) return;
2019
2828
  callback();
2020
2829
  }, delay);
2021
2830
  this.timers.push(timer);
@@ -2025,14 +2834,31 @@ export class BattleAi {
2025
2834
  // Public getters
2026
2835
  getHealth(): number { return this.event.hp; }
2027
2836
  getMaxHealth(): number { return this.event.param[MAXHP]; }
2028
- getTarget(): InstanceType<typeof RpgPlayer> | null { return this.target; }
2837
+ getTarget(): ActionBattleEntity | null { return this.target; }
2029
2838
  getState(): AiState { return this.state; }
2839
+ getFaction(): string | undefined {
2840
+ return this.faction;
2841
+ }
2842
+ setFaction(faction: string | undefined): void {
2843
+ this.faction = faction;
2844
+ }
2845
+ getTargets(): ActionBattleTargetSelector {
2846
+ return this.targets;
2847
+ }
2848
+ setTargets(targets: ActionBattleTargetSelector): void {
2849
+ this.targets = targets;
2850
+ if (this.target && !this.canTarget(this.target)) {
2851
+ this.clearTarget();
2852
+ this.changeState(AiState.Idle);
2853
+ }
2854
+ }
2030
2855
  getEnemyType(): EnemyType { return this.enemyType; }
2031
2856
 
2032
2857
  /**
2033
2858
  * Clean up
2034
2859
  */
2035
2860
  destroy() {
2861
+ this.destroyed = true;
2036
2862
  if (this.updateInterval) {
2037
2863
  clearInterval(this.updateInterval);
2038
2864
  this.updateInterval = undefined;