@rpgjs/action-battle 5.0.0-beta.5 → 5.0.0-beta.7

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 (66) hide show
  1. package/README.md +161 -22
  2. package/dist/ai.server.d.ts +55 -4
  3. package/dist/client/index.js +13 -8
  4. package/dist/client/index10.js +54 -136
  5. package/dist/client/index11.js +52 -23
  6. package/dist/client/index12.js +101 -1217
  7. package/dist/client/index13.js +139 -42
  8. package/dist/client/index14.js +23 -8
  9. package/dist/client/index15.js +68 -444
  10. package/dist/client/index16.js +1343 -0
  11. package/dist/client/index17.js +13 -0
  12. package/dist/client/index18.js +60 -0
  13. package/dist/client/index19.js +10 -0
  14. package/dist/client/index2.js +25 -87
  15. package/dist/client/index20.js +504 -0
  16. package/dist/client/index3.js +45 -83
  17. package/dist/client/index4.js +98 -297
  18. package/dist/client/index5.js +81 -33
  19. package/dist/client/index6.js +284 -78
  20. package/dist/client/index7.js +33 -74
  21. package/dist/client/index8.js +95 -55
  22. package/dist/client/index9.js +75 -96
  23. package/dist/core/attack-profile.d.ts +9 -0
  24. package/dist/core/attack-runtime.d.ts +20 -0
  25. package/dist/core/enemy-attack-profiles.d.ts +6 -0
  26. package/dist/core/equipment.d.ts +2 -0
  27. package/dist/core/hit-reaction.d.ts +5 -0
  28. package/dist/index.d.ts +7 -2
  29. package/dist/server/index.js +12 -7
  30. package/dist/server/index10.js +1340 -8
  31. package/dist/server/index11.js +37 -0
  32. package/dist/server/index12.js +60 -0
  33. package/dist/server/index13.js +13 -0
  34. package/dist/server/index14.js +503 -0
  35. package/dist/server/index15.js +10 -0
  36. package/dist/server/index2.js +1 -1
  37. package/dist/server/index3.js +25 -87
  38. package/dist/server/index4.js +45 -141
  39. package/dist/server/index5.js +104 -21
  40. package/dist/server/index6.js +137 -1215
  41. package/dist/server/index7.js +22 -34
  42. package/dist/server/index8.js +70 -44
  43. package/dist/server/index9.js +44 -437
  44. package/dist/server.d.ts +8 -2
  45. package/dist/ui/state.d.ts +5 -5
  46. package/package.json +5 -5
  47. package/src/ai.server.spec.ts +120 -0
  48. package/src/ai.server.ts +362 -56
  49. package/src/client.ts +21 -12
  50. package/src/config.ts +17 -2
  51. package/src/core/attack-profile.spec.ts +118 -0
  52. package/src/core/attack-profile.ts +100 -0
  53. package/src/core/attack-runtime.spec.ts +103 -0
  54. package/src/core/attack-runtime.ts +83 -0
  55. package/src/core/contracts.ts +3 -0
  56. package/src/core/enemy-attack-profiles.spec.ts +35 -0
  57. package/src/core/enemy-attack-profiles.ts +103 -0
  58. package/src/core/equipment.spec.ts +37 -0
  59. package/src/core/equipment.ts +17 -0
  60. package/src/core/hit-reaction.spec.ts +43 -0
  61. package/src/core/hit-reaction.ts +70 -0
  62. package/src/core/hit.spec.ts +54 -1
  63. package/src/core/hit.ts +26 -0
  64. package/src/index.ts +48 -1
  65. package/src/server.ts +192 -34
  66. package/src/types.ts +62 -6
package/src/ai.server.ts CHANGED
@@ -2,17 +2,70 @@ import { MAXHP, RpgEvent, RpgPlayer } from "@rpgjs/server";
2
2
  import {
3
3
  getActionBattleAnimationRemovalDelay,
4
4
  playActionBattleAnimation,
5
+ resolveActionBattleAnimation,
5
6
  } from "./animations";
6
7
  import { getActionBattleOptions } from "./config";
7
8
  import { getActionBattleSystems } from "./core/context";
9
+ import {
10
+ isActionBattleEntityInvincible,
11
+ setActionBattleInvincibility,
12
+ } from "./core/hit-reaction";
13
+ import {
14
+ normalizeActionBattleEnemyAttackProfiles,
15
+ type ActionBattleEnemyAttackProfileMap,
16
+ type NormalizedActionBattleEnemyAttackProfileMap,
17
+ } from "./core/enemy-attack-profiles";
18
+ import {
19
+ resolveActionBattleHitboxSpeed,
20
+ scheduleActionBattleStartup,
21
+ } from "./core/attack-runtime";
8
22
  import type { ActionBattleDamageResult } from "./core/contracts";
23
+ import type {
24
+ NormalizedActionBattleAttackProfile,
25
+ NormalizedActionBattleHitReactionProfile,
26
+ } from "./types";
9
27
  import type { ActionBattleAnimationOptions } from "./types";
10
28
 
11
29
  type RpgEventWithBattleAi = RpgEvent & {
12
30
  battleAi?: BattleAi;
13
31
  };
14
32
 
15
- export interface BattleAiOptions {
33
+ export interface BattleAiRewardItem {
34
+ item?: any;
35
+ itemId?: string;
36
+ amount?: number;
37
+ chance?: number;
38
+ }
39
+
40
+ export interface BattleAiRewards {
41
+ exp?: number;
42
+ gold?: number;
43
+ items?: Array<BattleAiRewardItem | string>;
44
+ showNotification?: boolean;
45
+ }
46
+
47
+ export interface BattleAiDefeatReward {
48
+ readonly awarded: boolean;
49
+ giveTo(player?: RpgPlayer | null): void;
50
+ }
51
+
52
+ export interface BattleAiDefeatedContext {
53
+ event: RpgEvent;
54
+ attacker?: RpgPlayer;
55
+ reward: BattleAiDefeatReward;
56
+ remove: () => void;
57
+ }
58
+
59
+ export type BattleAiDefeatedCallback = (
60
+ context: BattleAiDefeatedContext
61
+ ) => void;
62
+
63
+ export type BattleAiLegacyDefeatedCallback = (
64
+ event: RpgEvent,
65
+ attacker?: RpgPlayer
66
+ ) => void;
67
+
68
+ export interface BattleAiBaseOptions {
16
69
  enemyType?: EnemyType;
17
70
  attackCooldown?: number;
18
71
  visionRange?: number;
@@ -22,10 +75,14 @@ export interface BattleAiOptions {
22
75
  fleeThreshold?: number;
23
76
  attackSkill?: any;
24
77
  attackPatterns?: AttackPattern[];
78
+ attackProfiles?: ActionBattleEnemyAttackProfileMap;
25
79
  patrolWaypoints?: Array<{ x: number; y: number }>;
26
80
  groupBehavior?: boolean;
27
81
  moveToCooldown?: number;
28
82
  retreatCooldown?: number;
83
+ poise?: number;
84
+ hitstunMs?: number;
85
+ invincibilityMs?: number;
29
86
  behavior?: {
30
87
  baseScore?: number;
31
88
  updateInterval?: number;
@@ -35,8 +92,18 @@ export interface BattleAiOptions {
35
92
  };
36
93
  behaviorKey?: string;
37
94
  animations?: ActionBattleAnimationOptions;
95
+ rewards?: BattleAiRewards;
96
+ autoAwardRewards?: boolean;
97
+ }
98
+
99
+ export interface BattleAiOptions extends BattleAiBaseOptions {
38
100
  /** Callback called when the AI is defeated */
39
- onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
101
+ onDefeated?: BattleAiDefeatedCallback;
102
+ }
103
+
104
+ export interface BattleAiLegacyOptions extends BattleAiBaseOptions {
105
+ /** @deprecated Use the context callback signature instead. */
106
+ onDefeated?: BattleAiLegacyDefeatedCallback;
40
107
  }
41
108
 
42
109
  /**
@@ -73,6 +140,84 @@ export interface HitResult {
73
140
  target: RpgPlayer | RpgEvent;
74
141
  }
75
142
 
143
+ const normalizeRewardItem = (
144
+ item: BattleAiRewardItem | string
145
+ ): BattleAiRewardItem => {
146
+ if (typeof item === "string") {
147
+ return { itemId: item, amount: 1, chance: 100 };
148
+ }
149
+ return {
150
+ ...item,
151
+ amount: item.amount ?? 1,
152
+ chance: item.chance ?? 100,
153
+ };
154
+ };
155
+
156
+ const getRewardItemRef = (item: BattleAiRewardItem) => item.item ?? item.itemId;
157
+
158
+ const getPlayerMap = (player: RpgPlayer) => {
159
+ return typeof (player as any).getCurrentMap === "function"
160
+ ? (player as any).getCurrentMap()
161
+ : undefined;
162
+ };
163
+
164
+ const getRewardItemName = (inventoryItem: any, itemRef: any): string => {
165
+ if (inventoryItem && typeof inventoryItem.name === "function") {
166
+ return inventoryItem.name();
167
+ }
168
+ if (inventoryItem?.name) return inventoryItem.name;
169
+ if (typeof itemRef === "string") return itemRef;
170
+ if (itemRef?.name) return itemRef.name;
171
+ if (itemRef?.id) return itemRef.id;
172
+ return "item";
173
+ };
174
+
175
+ const createDefeatReward = (
176
+ rewards: BattleAiRewards | undefined
177
+ ): BattleAiDefeatReward => {
178
+ let awarded = false;
179
+ return {
180
+ get awarded() {
181
+ return awarded;
182
+ },
183
+ giveTo(player?: RpgPlayer | null) {
184
+ if (!player || awarded || !rewards) return;
185
+ awarded = true;
186
+
187
+ const exp = rewards.exp ?? 0;
188
+ const gold = rewards.gold ?? 0;
189
+ if (exp > 0) player.exp += exp;
190
+ if (gold > 0) player.gold += gold;
191
+
192
+ if (rewards.showNotification && (exp > 0 || gold > 0)) {
193
+ player.showNotification(`You won ${exp} experience and ${gold} gold`);
194
+ }
195
+
196
+ for (const rawItem of rewards.items ?? []) {
197
+ const item = normalizeRewardItem(rawItem);
198
+ const itemRef = getRewardItemRef(item);
199
+ if (!itemRef) continue;
200
+ if (Math.random() * 100 >= (item.chance ?? 100)) continue;
201
+
202
+ const amount = item.amount ?? 1;
203
+ const inventoryItem = player.addItem(itemRef, amount);
204
+ if (rewards.showNotification) {
205
+ const itemData =
206
+ typeof itemRef === "string"
207
+ ? getPlayerMap(player)?.database?.()?.[itemRef]
208
+ : undefined;
209
+ player.showNotification(
210
+ `You won ${amount} ${getRewardItemName(inventoryItem, itemRef)}`,
211
+ {
212
+ icon: itemData?.icon,
213
+ }
214
+ );
215
+ }
216
+ }
217
+ },
218
+ };
219
+ };
220
+
76
221
  /**
77
222
  * Hook options for customizing hit behavior
78
223
  *
@@ -295,6 +440,7 @@ export class BattleAi {
295
440
  // Attack configuration
296
441
  private attackSkill: any | null; // Skill to use for attacks
297
442
  private attackPatterns: AttackPattern[];
443
+ private attackProfiles: NormalizedActionBattleEnemyAttackProfileMap;
298
444
  private animations?: ActionBattleAnimationOptions;
299
445
  private comboCount: number = 0;
300
446
  private comboMax: number = 3;
@@ -318,7 +464,12 @@ export class BattleAi {
318
464
  private isMovingToTarget: boolean = false;
319
465
 
320
466
  // Callback when AI is defeated
321
- private onDefeatedCallback?: (event: RpgEvent, attacker?: RpgPlayer) => void;
467
+ private onDefeatedCallback?:
468
+ | BattleAiDefeatedCallback
469
+ | BattleAiLegacyDefeatedCallback;
470
+ private rewards?: BattleAiRewards;
471
+ private autoAwardRewards: boolean = true;
472
+ private defeated: boolean = false;
322
473
 
323
474
  // Direction hysteresis to prevent animation flickering
324
475
  private lastFacingDirection: string | null = null;
@@ -340,6 +491,9 @@ export class BattleAi {
340
491
  private lastRetreatTime: number = 0;
341
492
  private timers: ReturnType<typeof setTimeout>[] = [];
342
493
  private behaviorKey?: string;
494
+ private poise: number = 0;
495
+ private hitstunMs: number = 150;
496
+ private invincibilityMs: number = 250;
343
497
 
344
498
  /**
345
499
  * Create a new Battle AI Controller
@@ -365,9 +519,11 @@ export class BattleAi {
365
519
  * });
366
520
  * ```
367
521
  */
522
+ constructor(event: RpgEventWithBattleAi, options?: BattleAiOptions);
523
+ constructor(event: RpgEventWithBattleAi, options?: BattleAiLegacyOptions);
368
524
  constructor(
369
525
  event: RpgEventWithBattleAi,
370
- options: BattleAiOptions = {}
526
+ options: BattleAiOptions | BattleAiLegacyOptions = {}
371
527
  ) {
372
528
  event.battleAi = this;
373
529
  this.event = event;
@@ -390,6 +546,9 @@ export class BattleAi {
390
546
  AttackPattern.Combo,
391
547
  AttackPattern.DashAttack
392
548
  ];
549
+ this.attackProfiles = normalizeActionBattleEnemyAttackProfiles(
550
+ options.attackProfiles
551
+ );
393
552
 
394
553
  // Initialize group behavior
395
554
  this.groupBehavior = options.groupBehavior || false;
@@ -400,6 +559,8 @@ export class BattleAi {
400
559
 
401
560
  // Initialize defeat callback
402
561
  this.onDefeatedCallback = options.onDefeated;
562
+ this.rewards = options.rewards;
563
+ this.autoAwardRewards = options.autoAwardRewards ?? true;
403
564
 
404
565
  // Behavior gauge settings
405
566
  if (options.behavior) {
@@ -427,6 +588,15 @@ export class BattleAi {
427
588
  if (options.retreatCooldown !== undefined) {
428
589
  this.retreatCooldown = options.retreatCooldown;
429
590
  }
591
+ if (options.poise !== undefined) {
592
+ this.poise = Math.max(0, options.poise);
593
+ }
594
+ if (options.hitstunMs !== undefined) {
595
+ this.hitstunMs = Math.max(0, options.hitstunMs);
596
+ }
597
+ if (options.invincibilityMs !== undefined) {
598
+ this.invincibilityMs = Math.max(0, options.invincibilityMs);
599
+ }
430
600
 
431
601
  // Setup AI systems
432
602
  this.setupVision();
@@ -896,12 +1066,26 @@ export class BattleAi {
896
1066
  */
897
1067
  private performMeleeAttack() {
898
1068
  if (!this.target) return;
1069
+ const profile = this.getAttackProfile(AttackPattern.Melee);
899
1070
 
900
1071
  this.faceTarget();
1072
+ this.telegraphAttack(profile);
901
1073
  playActionBattleAnimation("attack", this.event, this.animations, {
902
1074
  target: this.target,
903
1075
  });
904
1076
 
1077
+ this.scheduleAttackStartup(profile, () => {
1078
+ this.executeMeleeAttack(profile, AttackPattern.Melee);
1079
+ });
1080
+ }
1081
+
1082
+ private executeMeleeAttack(
1083
+ profile: NormalizedActionBattleAttackProfile,
1084
+ pattern: AttackPattern
1085
+ ) {
1086
+ if (!this.target) return;
1087
+ this.debugLog('attack', `Applying ${pattern} hit`);
1088
+
905
1089
  // Use skill if available
906
1090
  if (this.attackSkill) {
907
1091
  try {
@@ -912,17 +1096,22 @@ export class BattleAi {
912
1096
  this.event.useSkill(this.attackSkill, this.target);
913
1097
  } catch (e) {
914
1098
  // Skill failed (no SP, etc.) - fall back to basic attack
915
- this.performBasicHitbox();
1099
+ this.performBasicHitbox(profile, pattern);
916
1100
  }
917
1101
  } else {
918
- this.performBasicHitbox();
1102
+ this.performBasicHitbox(profile, pattern);
919
1103
  }
920
1104
  }
921
1105
 
922
1106
  /**
923
1107
  * Perform basic hitbox attack when no skill is set
924
1108
  */
925
- private performBasicHitbox() {
1109
+ private performBasicHitbox(
1110
+ profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
1111
+ AttackPattern.Melee
1112
+ ),
1113
+ pattern: AttackPattern = AttackPattern.Melee
1114
+ ) {
926
1115
  if (!this.target) return;
927
1116
 
928
1117
  const eventX = this.event.x();
@@ -944,11 +1133,13 @@ export class BattleAi {
944
1133
  }];
945
1134
 
946
1135
  const map = this.event.getCurrentMap();
947
- map?.createMovingHitbox(hitboxes, { speed: 5 }).subscribe({
948
- next: (hits) => {
949
- hits.forEach((hit) => {
1136
+ map?.createMovingHitbox(hitboxes, {
1137
+ speed: resolveActionBattleHitboxSpeed(profile, hitboxes.length),
1138
+ }).subscribe({
1139
+ next: (hits: any[]) => {
1140
+ hits.forEach((hit: any) => {
950
1141
  if (hit instanceof RpgPlayer && hit !== this.event) {
951
- this.applyHit(hit);
1142
+ this.applyHit(hit, undefined, profile, pattern);
952
1143
  }
953
1144
  });
954
1145
  },
@@ -983,7 +1174,25 @@ export class BattleAi {
983
1174
  * });
984
1175
  * ```
985
1176
  */
986
- private applyHit(target: RpgPlayer, hooks?: ApplyHitHooks): HitResult {
1177
+ private applyHit(
1178
+ target: RpgPlayer,
1179
+ hooks?: ApplyHitHooks,
1180
+ profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
1181
+ AttackPattern.Melee
1182
+ ),
1183
+ pattern: AttackPattern = AttackPattern.Melee
1184
+ ): HitResult {
1185
+ if (isActionBattleEntityInvincible(target)) {
1186
+ return {
1187
+ damage: 0,
1188
+ knockbackForce: 0,
1189
+ knockbackDuration: 0,
1190
+ defeated: false,
1191
+ attacker: this.event,
1192
+ target
1193
+ };
1194
+ }
1195
+
987
1196
  // Use RPGJS damage formula
988
1197
  const { damage } = target.applyDamage(this.event as any);
989
1198
 
@@ -1017,6 +1226,10 @@ export class BattleAi {
1017
1226
  cycles: 1
1018
1227
  });
1019
1228
  target.showHit(`-${hitResult.damage}`);
1229
+ setActionBattleInvincibility(
1230
+ target,
1231
+ profile.reaction.invincibilityMs
1232
+ );
1020
1233
 
1021
1234
  // Apply knockback
1022
1235
  if (hitResult.knockbackForce > 0) {
@@ -1080,7 +1293,15 @@ export class BattleAi {
1080
1293
  if (!this.target) return;
1081
1294
 
1082
1295
  this.comboCount++;
1083
- this.performMeleeAttack();
1296
+ const profile = this.getAttackProfile(AttackPattern.Combo);
1297
+ this.faceTarget();
1298
+ this.telegraphAttack(profile);
1299
+ playActionBattleAnimation("attack", this.event, this.animations, {
1300
+ target: this.target,
1301
+ });
1302
+ this.scheduleAttackStartup(profile, () => {
1303
+ this.executeMeleeAttack(profile, AttackPattern.Combo);
1304
+ });
1084
1305
 
1085
1306
  if (this.comboCount < this.comboMax) {
1086
1307
  this.schedule(() => {
@@ -1100,9 +1321,11 @@ export class BattleAi {
1100
1321
  */
1101
1322
  private performChargedAttack() {
1102
1323
  if (!this.target) return;
1324
+ const profile = this.getAttackProfile(AttackPattern.Charged);
1103
1325
 
1104
1326
  this.chargingAttack = true;
1105
1327
  this.faceTarget();
1328
+ this.telegraphAttack(profile);
1106
1329
  playActionBattleAnimation(
1107
1330
  "attack",
1108
1331
  this.event,
@@ -1113,35 +1336,24 @@ export class BattleAi {
1113
1336
  { repeat: 2 }
1114
1337
  );
1115
1338
 
1116
- this.schedule(() => {
1339
+ this.scheduleAttackStartup(profile, () => {
1117
1340
  if (!this.target || this.state !== AiState.Combat) {
1118
1341
  this.chargingAttack = false;
1119
1342
  return;
1120
1343
  }
1121
-
1122
- // Charged attacks can use a stronger skill or wider hitbox
1123
- if (this.attackSkill) {
1124
- try {
1125
- playActionBattleAnimation("castSkill", this.event, this.animations, {
1126
- skill: this.attackSkill,
1127
- target: this.target,
1128
- });
1129
- this.event.useSkill(this.attackSkill, this.target);
1130
- } catch (e) {
1131
- this.performBasicHitbox();
1132
- }
1133
- } else {
1134
- this.performBasicHitbox();
1135
- }
1136
-
1344
+ this.executeMeleeAttack(profile, AttackPattern.Charged);
1345
+ });
1346
+ this.schedule(() => {
1137
1347
  this.chargingAttack = false;
1138
- }, 800);
1348
+ }, profile.totalDurationMs);
1139
1349
  }
1140
1350
 
1141
1351
  /**
1142
1352
  * Perform zone attack (360 degrees)
1143
1353
  */
1144
1354
  private performZoneAttack() {
1355
+ const profile = this.getAttackProfile(AttackPattern.Zone);
1356
+ this.telegraphAttack(profile);
1145
1357
  playActionBattleAnimation("attack", this.event, this.animations, {
1146
1358
  target: this.target ?? undefined,
1147
1359
  });
@@ -1163,15 +1375,19 @@ export class BattleAi {
1163
1375
  });
1164
1376
  });
1165
1377
 
1166
- const map = this.event.getCurrentMap();
1167
- map?.createMovingHitbox(hitboxes, { speed: 5 }).subscribe({
1168
- next: (hits) => {
1169
- hits.forEach((hit) => {
1170
- if (hit instanceof RpgPlayer && hit !== this.event) {
1171
- this.applyHit(hit);
1172
- }
1173
- });
1174
- },
1378
+ this.scheduleAttackStartup(profile, () => {
1379
+ const map = this.event.getCurrentMap();
1380
+ map?.createMovingHitbox(hitboxes, {
1381
+ speed: resolveActionBattleHitboxSpeed(profile, hitboxes.length),
1382
+ }).subscribe({
1383
+ next: (hits: any[]) => {
1384
+ hits.forEach((hit: any) => {
1385
+ if (hit instanceof RpgPlayer && hit !== this.event) {
1386
+ this.applyHit(hit, undefined, profile, AttackPattern.Zone);
1387
+ }
1388
+ });
1389
+ },
1390
+ });
1175
1391
  });
1176
1392
  }
1177
1393
 
@@ -1180,6 +1396,7 @@ export class BattleAi {
1180
1396
  */
1181
1397
  private performDashAttack() {
1182
1398
  if (!this.target) return;
1399
+ const profile = this.getAttackProfile(AttackPattern.DashAttack);
1183
1400
 
1184
1401
  const dx = this.target.x() - this.event.x();
1185
1402
  const dy = this.target.y() - this.event.y();
@@ -1191,12 +1408,43 @@ export class BattleAi {
1191
1408
  const dirY = dy / dist;
1192
1409
 
1193
1410
  this.faceTarget();
1194
- this.event.dash({ x: dirX, y: dirY }, 10, 200);
1411
+ this.telegraphAttack(profile);
1195
1412
 
1196
- this.schedule(() => {
1413
+ this.scheduleAttackStartup(profile, () => {
1197
1414
  if (!this.target || this.state !== AiState.Combat) return;
1198
- this.performMeleeAttack();
1199
- }, 200);
1415
+ this.event.dash({ x: dirX, y: dirY }, 10, 200);
1416
+ this.schedule(() => {
1417
+ if (!this.target || this.state !== AiState.Combat) return;
1418
+ this.executeMeleeAttack(profile, AttackPattern.DashAttack);
1419
+ }, 200);
1420
+ });
1421
+ }
1422
+
1423
+ private getAttackProfile(
1424
+ pattern: AttackPattern
1425
+ ): NormalizedActionBattleAttackProfile {
1426
+ return this.attackProfiles[
1427
+ pattern as keyof NormalizedActionBattleEnemyAttackProfileMap
1428
+ ] ?? this.attackProfiles.melee;
1429
+ }
1430
+
1431
+ private telegraphAttack(profile: NormalizedActionBattleAttackProfile) {
1432
+ if (profile.startupMs <= 0) return;
1433
+ this.event.flash({
1434
+ type: 'tint',
1435
+ tint: 'white',
1436
+ duration: Math.min(profile.startupMs, 300),
1437
+ cycles: 1
1438
+ });
1439
+ }
1440
+
1441
+ private scheduleAttackStartup(
1442
+ profile: NormalizedActionBattleAttackProfile,
1443
+ callback: () => void
1444
+ ) {
1445
+ return scheduleActionBattleStartup(profile, callback, (scheduled, delay) =>
1446
+ this.schedule(scheduled, delay)
1447
+ );
1200
1448
  }
1201
1449
 
1202
1450
  /**
@@ -1478,6 +1726,7 @@ export class BattleAi {
1478
1726
  * The actual damage is applied externally via RPGJS API.
1479
1727
  */
1480
1728
  takeDamage(attacker: RpgPlayer): boolean {
1729
+ if (this.defeated) return true;
1481
1730
  // Apply damage using RPGJS system
1482
1731
  const raw = this.event.applyDamage(attacker);
1483
1732
  return this.handleDamage(attacker, {
@@ -1487,7 +1736,13 @@ export class BattleAi {
1487
1736
  });
1488
1737
  }
1489
1738
 
1490
- handleDamage(attacker: RpgPlayer, damageResult: ActionBattleDamageResult): boolean {
1739
+ handleDamage(
1740
+ attacker: RpgPlayer,
1741
+ damageResult: ActionBattleDamageResult & {
1742
+ reaction?: NormalizedActionBattleHitReactionProfile;
1743
+ }
1744
+ ): boolean {
1745
+ if (this.defeated) return true;
1491
1746
  const damage = damageResult.damage;
1492
1747
  this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
1493
1748
 
@@ -1506,11 +1761,20 @@ export class BattleAi {
1506
1761
  // Track damage
1507
1762
  this.recentDamageTaken += damage;
1508
1763
 
1764
+ const reaction = damageResult.reaction;
1765
+ const staggerPower = reaction?.staggerPower ?? damage;
1766
+ const hitstunMs = reaction?.hitstunMs ?? this.hitstunMs;
1767
+ const shouldStun = staggerPower >= this.poise && hitstunMs > 0;
1768
+ setActionBattleInvincibility(
1769
+ this.event,
1770
+ reaction?.invincibilityMs ?? this.invincibilityMs
1771
+ );
1772
+
1509
1773
  // Brief stun
1510
- if (this.state !== AiState.Stunned && this.state !== AiState.Flee) {
1774
+ if (shouldStun && this.state !== AiState.Stunned && this.state !== AiState.Flee) {
1511
1775
  this.debugLog('damage', 'Stunned from damage');
1512
1776
  this.isMovingToTarget = false;
1513
- this.stunnedUntil = Date.now() + 150;
1777
+ this.stunnedUntil = Date.now() + hitstunMs;
1514
1778
  this.changeState(AiState.Stunned);
1515
1779
  }
1516
1780
 
@@ -1531,7 +1795,10 @@ export class BattleAi {
1531
1795
  * and removes the event from the map.
1532
1796
  */
1533
1797
  private kill(attacker?: RpgPlayer) {
1534
- const dieAnimation = playActionBattleAnimation(
1798
+ if (this.defeated) return;
1799
+ this.defeated = true;
1800
+
1801
+ const dieAnimation = resolveActionBattleAnimation(
1535
1802
  "die",
1536
1803
  this.event,
1537
1804
  this.animations,
@@ -1540,18 +1807,57 @@ export class BattleAi {
1540
1807
  }
1541
1808
  );
1542
1809
  const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
1810
+ const reward = createDefeatReward(this.rewards);
1811
+ let removed = false;
1812
+ const remove = () => {
1813
+ if (removed) return;
1814
+ removed = true;
1815
+ this.event.remove({
1816
+ reason: "defeated",
1817
+ data: {
1818
+ animation: dieAnimation,
1819
+ },
1820
+ transition: dieAnimation
1821
+ ? {
1822
+ animation: dieAnimation.animationName,
1823
+ graphic: dieAnimation.graphic,
1824
+ duration: removeDelay,
1825
+ }
1826
+ : undefined,
1827
+ timeoutMs: removeDelay,
1828
+ });
1829
+ };
1543
1830
 
1544
- // Call onDefeated hook before cleanup
1831
+ if (this.autoAwardRewards) {
1832
+ reward.giveTo(attacker);
1833
+ }
1834
+
1835
+ const context: BattleAiDefeatedContext = {
1836
+ event: this.event,
1837
+ attacker,
1838
+ reward,
1839
+ remove,
1840
+ };
1841
+
1842
+ // Call onDefeated hook before cleanup. One-argument callbacks receive the
1843
+ // newer context object; two-argument callbacks keep the legacy signature.
1545
1844
  if (this.onDefeatedCallback) {
1546
- this.onDefeatedCallback(this.event, attacker);
1845
+ if (this.onDefeatedCallback.length >= 2) {
1846
+ (
1847
+ this.onDefeatedCallback as (
1848
+ event: RpgEvent,
1849
+ attacker?: RpgPlayer
1850
+ ) => void
1851
+ )(this.event, attacker);
1852
+ } else {
1853
+ (this.onDefeatedCallback as (context: BattleAiDefeatedContext) => void)(
1854
+ context
1855
+ );
1856
+ }
1547
1857
  }
1548
-
1858
+
1549
1859
  this.destroy();
1550
- if (removeDelay > 0) {
1551
- this.schedule(() => this.event.remove(), removeDelay);
1552
- } else {
1553
- this.event.remove();
1554
- }
1860
+ remove();
1555
1861
  }
1556
1862
 
1557
1863
  /**