@rpgjs/action-battle 5.0.0-beta.6 → 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.
package/README.md CHANGED
@@ -260,7 +260,7 @@ new BattleAi(event, {
260
260
  groupBehavior: true,
261
261
 
262
262
  // Callback when AI is defeated
263
- onDefeated: (event, attacker) => {
263
+ onDefeated: ({ event, attacker }) => {
264
264
  const name = attacker?.name?.() ?? "Unknown";
265
265
  console.log(`${event.name()} was defeated by ${name}!`);
266
266
  }
@@ -839,8 +839,9 @@ new BattleAi(this, {
839
839
  });
840
840
  ```
841
841
 
842
- `waitEnd: true` delays event removal for defeated AI with the default delay used
843
- by action-battle. Use `delayMs` when you need an exact duration.
842
+ `waitEnd: true` uses the default defeated transition timeout. Use `delayMs`
843
+ when you need an exact duration. The visual transition itself is handled by the
844
+ client `sprite.onBeforeRemove` hook.
844
845
 
845
846
  ## Knockback System
846
847
 
@@ -992,25 +993,57 @@ console.log(`Player knockback force: ${force}`);
992
993
 
993
994
  ## onDefeated Hook
994
995
 
995
- The `onDefeated` callback is triggered when an AI enemy is killed. It receives the defeated event and the player who landed the killing blow (if available). Use it to:
996
+ The `onDefeated` callback is triggered when an AI enemy is killed. The simplest
997
+ reward flow is configured directly on `BattleAi`; the reward is given to the
998
+ player who landed the killing blow.
999
+
1000
+ ```typescript
1001
+ new BattleAi(this, {
1002
+ enemyType: EnemyType.Aggressive,
1003
+ animations: {
1004
+ die: {
1005
+ animationName: "die",
1006
+ graphic: "goblin_die",
1007
+ repeat: 1,
1008
+ delayMs: 700
1009
+ }
1010
+ },
1011
+ rewards: {
1012
+ exp: 50,
1013
+ gold: 25,
1014
+ items: [{ itemId: "health_potion", amount: 1, chance: 30 }],
1015
+ showNotification: true
1016
+ }
1017
+ });
1018
+ ```
1019
+
1020
+ On defeat, `BattleAi` stops the AI, awards configured rewards once, and calls
1021
+ `event.remove({ reason: "defeated", transition })`. The client can use
1022
+ `sprite.onBeforeRemove` to play the `die` transition before the sprite
1023
+ disappears.
1024
+
1025
+ `onDefeated` receives a context object in new code:
996
1026
  - Award experience, gold, or items to the player
997
1027
  - Spawn loot drops
998
1028
  - Trigger events or cutscenes
999
1029
  - Update quest progress
1000
- - Play death animations or sounds
1030
+ - Play death sounds
1001
1031
 
1002
1032
  ### Basic Usage
1003
1033
 
1004
1034
  ```typescript
1005
1035
  new BattleAi(this, {
1006
1036
  enemyType: EnemyType.Aggressive,
1007
- onDefeated: (event, attacker) => {
1037
+ onDefeated: ({ event, attacker }) => {
1008
1038
  const name = attacker?.name?.() ?? "Unknown";
1009
1039
  console.log(`${event.name()} was defeated by ${name}!`);
1010
1040
  }
1011
1041
  });
1012
1042
  ```
1013
1043
 
1044
+ The legacy `(event, attacker)` callback signature is still supported for
1045
+ two-argument callbacks.
1046
+
1014
1047
  ### Award Rewards on Kill
1015
1048
 
1016
1049
  ```typescript
@@ -1025,19 +1058,10 @@ function Goblin() {
1025
1058
 
1026
1059
  new BattleAi(this, {
1027
1060
  enemyType: EnemyType.Aggressive,
1028
- onDefeated: (event, attacker) => {
1029
- if (!attacker) return;
1030
-
1031
- // Award gold
1032
- attacker.gold += 25;
1033
-
1034
- // Award experience
1035
- attacker.exp += 50;
1036
-
1037
- // Random loot drop
1038
- if (Math.random() < 0.3) {
1039
- attacker.addItem(HealthPotion);
1040
- }
1061
+ rewards: {
1062
+ gold: 25,
1063
+ exp: 50,
1064
+ items: [{ item: HealthPotion, amount: 1, chance: 30 }]
1041
1065
  }
1042
1066
  });
1043
1067
  }
@@ -1049,7 +1073,7 @@ function Goblin() {
1049
1073
 
1050
1074
  ```typescript
1051
1075
  new BattleAi(this, {
1052
- onDefeated: (event, attacker) => {
1076
+ onDefeated: ({ event }) => {
1053
1077
  const map = event.getCurrentMap();
1054
1078
  if (!map) return;
1055
1079
 
@@ -1069,7 +1093,7 @@ new BattleAi(this, {
1069
1093
  let killCount = 0;
1070
1094
 
1071
1095
  new BattleAi(this, {
1072
- onDefeated: (event, attacker) => {
1096
+ onDefeated: () => {
1073
1097
  killCount++;
1074
1098
 
1075
1099
  // Check quest progress
@@ -1093,7 +1117,7 @@ function DragonBoss() {
1093
1117
 
1094
1118
  new BattleAi(this, {
1095
1119
  enemyType: EnemyType.Tank,
1096
- onDefeated: (event, attacker) => {
1120
+ onDefeated: ({ event }) => {
1097
1121
  const map = event.getCurrentMap();
1098
1122
 
1099
1123
  // Announce victory
@@ -5,7 +5,31 @@ import { NormalizedActionBattleHitReactionProfile, ActionBattleAnimationOptions
5
5
  type RpgEventWithBattleAi = RpgEvent & {
6
6
  battleAi?: BattleAi;
7
7
  };
8
- export interface BattleAiOptions {
8
+ export interface BattleAiRewardItem {
9
+ item?: any;
10
+ itemId?: string;
11
+ amount?: number;
12
+ chance?: number;
13
+ }
14
+ export interface BattleAiRewards {
15
+ exp?: number;
16
+ gold?: number;
17
+ items?: Array<BattleAiRewardItem | string>;
18
+ showNotification?: boolean;
19
+ }
20
+ export interface BattleAiDefeatReward {
21
+ readonly awarded: boolean;
22
+ giveTo(player?: RpgPlayer | null): void;
23
+ }
24
+ export interface BattleAiDefeatedContext {
25
+ event: RpgEvent;
26
+ attacker?: RpgPlayer;
27
+ reward: BattleAiDefeatReward;
28
+ remove: () => void;
29
+ }
30
+ export type BattleAiDefeatedCallback = (context: BattleAiDefeatedContext) => void;
31
+ export type BattleAiLegacyDefeatedCallback = (event: RpgEvent, attacker?: RpgPlayer) => void;
32
+ export interface BattleAiBaseOptions {
9
33
  enemyType?: EnemyType;
10
34
  attackCooldown?: number;
11
35
  visionRange?: number;
@@ -35,8 +59,16 @@ export interface BattleAiOptions {
35
59
  };
36
60
  behaviorKey?: string;
37
61
  animations?: ActionBattleAnimationOptions;
62
+ rewards?: BattleAiRewards;
63
+ autoAwardRewards?: boolean;
64
+ }
65
+ export interface BattleAiOptions extends BattleAiBaseOptions {
38
66
  /** Callback called when the AI is defeated */
39
- onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
67
+ onDefeated?: BattleAiDefeatedCallback;
68
+ }
69
+ export interface BattleAiLegacyOptions extends BattleAiBaseOptions {
70
+ /** @deprecated Use the context callback signature instead. */
71
+ onDefeated?: BattleAiLegacyDefeatedCallback;
40
72
  }
41
73
  /**
42
74
  * Hit result data returned after applying damage
@@ -272,6 +304,9 @@ export declare class BattleAi {
272
304
  private damageCheckInterval;
273
305
  private isMovingToTarget;
274
306
  private onDefeatedCallback?;
307
+ private rewards?;
308
+ private autoAwardRewards;
309
+ private defeated;
275
310
  private lastFacingDirection;
276
311
  private behaviorScore;
277
312
  private behaviorMode;
@@ -315,6 +350,7 @@ export declare class BattleAi {
315
350
  * ```
316
351
  */
317
352
  constructor(event: RpgEventWithBattleAi, options?: BattleAiOptions);
353
+ constructor(event: RpgEventWithBattleAi, options?: BattleAiLegacyOptions);
318
354
  /**
319
355
  * Apply enemy type-specific behavior modifiers
320
356
  *
@@ -1,12 +1,65 @@
1
1
  import { isActionBattleEntityInvincible, setActionBattleInvincibility } from "./index2.js";
2
2
  import { getActionBattleOptions } from "./index4.js";
3
- import { getActionBattleAnimationRemovalDelay, playActionBattleAnimation } from "./index10.js";
3
+ import { getActionBattleAnimationRemovalDelay, playActionBattleAnimation, resolveActionBattleAnimation } from "./index10.js";
4
4
  import { resolveActionBattleHitboxSpeed, scheduleActionBattleStartup } from "./index11.js";
5
5
  import { getActionBattleSystems } from "./index14.js";
6
6
  import { normalizeActionBattleEnemyAttackProfiles } from "./index15.js";
7
7
  //#region src/ai.server.ts
8
8
  var MAXHP = null;
9
9
  var RpgPlayer = null;
10
+ var normalizeRewardItem = (item) => {
11
+ if (typeof item === "string") return {
12
+ itemId: item,
13
+ amount: 1,
14
+ chance: 100
15
+ };
16
+ return {
17
+ ...item,
18
+ amount: item.amount ?? 1,
19
+ chance: item.chance ?? 100
20
+ };
21
+ };
22
+ var getRewardItemRef = (item) => item.item ?? item.itemId;
23
+ var getPlayerMap = (player) => {
24
+ return typeof player.getCurrentMap === "function" ? player.getCurrentMap() : void 0;
25
+ };
26
+ var getRewardItemName = (inventoryItem, itemRef) => {
27
+ if (inventoryItem && typeof inventoryItem.name === "function") return inventoryItem.name();
28
+ if (inventoryItem?.name) return inventoryItem.name;
29
+ if (typeof itemRef === "string") return itemRef;
30
+ if (itemRef?.name) return itemRef.name;
31
+ if (itemRef?.id) return itemRef.id;
32
+ return "item";
33
+ };
34
+ var createDefeatReward = (rewards) => {
35
+ let awarded = false;
36
+ return {
37
+ get awarded() {
38
+ return awarded;
39
+ },
40
+ giveTo(player) {
41
+ if (!player || awarded || !rewards) return;
42
+ awarded = true;
43
+ const exp = rewards.exp ?? 0;
44
+ const gold = rewards.gold ?? 0;
45
+ if (exp > 0) player.exp += exp;
46
+ if (gold > 0) player.gold += gold;
47
+ if (rewards.showNotification && (exp > 0 || gold > 0)) player.showNotification(`You won ${exp} experience and ${gold} gold`);
48
+ for (const rawItem of rewards.items ?? []) {
49
+ const item = normalizeRewardItem(rawItem);
50
+ const itemRef = getRewardItemRef(item);
51
+ if (!itemRef) continue;
52
+ if (Math.random() * 100 >= (item.chance ?? 100)) continue;
53
+ const amount = item.amount ?? 1;
54
+ const inventoryItem = player.addItem(itemRef, amount);
55
+ if (rewards.showNotification) {
56
+ const itemData = typeof itemRef === "string" ? getPlayerMap(player)?.database?.()?.[itemRef] : void 0;
57
+ player.showNotification(`You won ${amount} ${getRewardItemName(inventoryItem, itemRef)}`, { icon: itemData?.icon });
58
+ }
59
+ }
60
+ }
61
+ };
62
+ };
10
63
  /**
11
64
  * AI Debug Logger
12
65
  *
@@ -178,6 +231,9 @@ var BattleAi = class {
178
231
  damageCheckInterval = 2e3;
179
232
  isMovingToTarget = false;
180
233
  onDefeatedCallback;
234
+ rewards;
235
+ autoAwardRewards = true;
236
+ defeated = false;
181
237
  lastFacingDirection = null;
182
238
  behaviorScore = 50;
183
239
  behaviorMode = "tactical";
@@ -196,30 +252,6 @@ var BattleAi = class {
196
252
  poise = 0;
197
253
  hitstunMs = 150;
198
254
  invincibilityMs = 250;
199
- /**
200
- * Create a new Battle AI Controller
201
- *
202
- * The AI controls behavior only. Stats should be set on the event
203
- * using standard RPGJS methods (hp, param, learnSkill, etc.)
204
- *
205
- * @param event - The event to control
206
- * @param options - AI behavior configuration
207
- *
208
- * @example
209
- * ```ts
210
- * // In your event's onInit
211
- * this.hp = 100;
212
- * this.param[ATK] = 20;
213
- * this.learnSkill(FireBall);
214
- *
215
- * new BattleAi(this, {
216
- * enemyType: EnemyType.Ranged,
217
- * attackSkill: FireBall,
218
- * visionRange: 200,
219
- * fleeThreshold: 0.2
220
- * });
221
- * ```
222
- */
223
255
  constructor(event, options = {}) {
224
256
  event.battleAi = this;
225
257
  this.event = event;
@@ -241,6 +273,8 @@ var BattleAi = class {
241
273
  this.patrolWaypoints = options.patrolWaypoints || [];
242
274
  this.currentPatrolIndex = 0;
243
275
  this.onDefeatedCallback = options.onDefeated;
276
+ this.rewards = options.rewards;
277
+ this.autoAwardRewards = options.autoAwardRewards ?? true;
244
278
  if (options.behavior) {
245
279
  this.behaviorEnabled = true;
246
280
  if (options.behavior.baseScore !== void 0) this.behaviorScore = options.behavior.baseScore;
@@ -1080,6 +1114,7 @@ var BattleAi = class {
1080
1114
  * The actual damage is applied externally via RPGJS API.
1081
1115
  */
1082
1116
  takeDamage(attacker) {
1117
+ if (this.defeated) return true;
1083
1118
  const raw = this.event.applyDamage(attacker);
1084
1119
  return this.handleDamage(attacker, {
1085
1120
  damage: raw.damage ?? 0,
@@ -1088,6 +1123,7 @@ var BattleAi = class {
1088
1123
  });
1089
1124
  }
1090
1125
  handleDamage(attacker, damageResult) {
1126
+ if (this.defeated) return true;
1091
1127
  const damage = damageResult.damage;
1092
1128
  this.debugLog("damage", `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || "?"})`);
1093
1129
  this.event.flash({
@@ -1124,11 +1160,37 @@ var BattleAi = class {
1124
1160
  * and removes the event from the map.
1125
1161
  */
1126
1162
  kill(attacker) {
1127
- const removeDelay = getActionBattleAnimationRemovalDelay(playActionBattleAnimation("die", this.event, this.animations, { attacker }));
1128
- if (this.onDefeatedCallback) this.onDefeatedCallback(this.event, attacker);
1163
+ if (this.defeated) return;
1164
+ this.defeated = true;
1165
+ const dieAnimation = resolveActionBattleAnimation("die", this.event, this.animations, { attacker });
1166
+ const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
1167
+ const reward = createDefeatReward(this.rewards);
1168
+ let removed = false;
1169
+ const remove = () => {
1170
+ if (removed) return;
1171
+ removed = true;
1172
+ this.event.remove({
1173
+ reason: "defeated",
1174
+ data: { animation: dieAnimation },
1175
+ transition: dieAnimation ? {
1176
+ animation: dieAnimation.animationName,
1177
+ graphic: dieAnimation.graphic,
1178
+ duration: removeDelay
1179
+ } : void 0,
1180
+ timeoutMs: removeDelay
1181
+ });
1182
+ };
1183
+ if (this.autoAwardRewards) reward.giveTo(attacker);
1184
+ const context = {
1185
+ event: this.event,
1186
+ attacker,
1187
+ reward,
1188
+ remove
1189
+ };
1190
+ if (this.onDefeatedCallback) if (this.onDefeatedCallback.length >= 2) this.onDefeatedCallback(this.event, attacker);
1191
+ else this.onDefeatedCallback(context);
1129
1192
  this.destroy();
1130
- if (removeDelay > 0) this.schedule(() => this.event.remove(), removeDelay);
1131
- else this.event.remove();
1193
+ remove();
1132
1194
  }
1133
1195
  /**
1134
1196
  * Get distance between entities
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ActionBattleOptions } from './types';
2
2
  export { BattleAi, AiState, EnemyType, AttackPattern, AiDebug, DEFAULT_KNOCKBACK } from './ai.server';
3
- export type { HitResult, ApplyHitHooks, BattleAiOptions } from './ai.server';
3
+ export type { HitResult, ApplyHitHooks, BattleAiOptions, BattleAiDefeatedCallback, BattleAiDefeatedContext, BattleAiDefeatReward, BattleAiLegacyDefeatedCallback, BattleAiLegacyOptions, BattleAiRewardItem, BattleAiRewards, } from './ai.server';
4
4
  export type { ActionBattleAnimationContext, ActionBattleAnimationEntity, ActionBattleAnimationKey, ActionBattleAnimationOptions, ActionBattleAnimationResolver, ActionBattleAnimationResult, ActionBattleOptions, ActionBattleActionBarData, ActionBattleActionBarItem, ActionBattleActionBarSkill, ActionBattleSkillTargeting, ActionBattleSkillTargetingResolver, ActionBattleAttackOptions, ActionBattleUiOptions, ActionBattleUiActionBarOptions, ActionBattleUiTargetingOptions, ActionBattleAttackDirection, ActionBattleAttackHitboxConfig, ActionBattleAttackHitboxMap, ActionBattleAttackHitPolicy, ActionBattleAttackProfile, ActionBattleDebugOptions, ActionBattleHitReactionProfile, NormalizedActionBattleHitReactionProfile, NormalizedActionBattleAttackProfile, ActionBattleCombatOptions, ActionBattleSystemOptions, ActionBattleAiSystemOptions, } from './types';
5
5
  export type { ActionBattleAiBehavior, ActionBattleAiContext, ActionBattleAiDecision, ActionBattleAttackContext, ActionBattleCombatSystem, ActionBattleDamageContext, ActionBattleDamageResult, ActionBattleDirection, ActionBattleEntity, ActionBattleHitContext, ActionBattleHitHooks, ActionBattleHitResult, ActionBattleHitbox, ActionBattleKnockbackContext, ActionBattleKnockbackResult, ActionBattleSystems, } from './core/contracts';
6
6
  export { DEFAULT_ACTION_BATTLE_ATTACK_PROFILE, normalizeActionBattleAttackProfile, type ActionBattleAttackProfileFallbacks, } from './core/attack-profile';
@@ -1,4 +1,4 @@
1
- import { getActionBattleAnimationRemovalDelay, playActionBattleAnimation } from "./index2.js";
1
+ import { getActionBattleAnimationRemovalDelay, playActionBattleAnimation, resolveActionBattleAnimation } from "./index2.js";
2
2
  import { isActionBattleEntityInvincible, setActionBattleInvincibility } from "./index3.js";
3
3
  import { getActionBattleOptions } from "./index5.js";
4
4
  import { getActionBattleSystems } from "./index7.js";
@@ -6,6 +6,59 @@ import { normalizeActionBattleEnemyAttackProfiles } from "./index8.js";
6
6
  import { resolveActionBattleHitboxSpeed, scheduleActionBattleStartup } from "./index9.js";
7
7
  import { MAXHP, RpgPlayer } from "@rpgjs/server";
8
8
  //#region src/ai.server.ts
9
+ var normalizeRewardItem = (item) => {
10
+ if (typeof item === "string") return {
11
+ itemId: item,
12
+ amount: 1,
13
+ chance: 100
14
+ };
15
+ return {
16
+ ...item,
17
+ amount: item.amount ?? 1,
18
+ chance: item.chance ?? 100
19
+ };
20
+ };
21
+ var getRewardItemRef = (item) => item.item ?? item.itemId;
22
+ var getPlayerMap = (player) => {
23
+ return typeof player.getCurrentMap === "function" ? player.getCurrentMap() : void 0;
24
+ };
25
+ var getRewardItemName = (inventoryItem, itemRef) => {
26
+ if (inventoryItem && typeof inventoryItem.name === "function") return inventoryItem.name();
27
+ if (inventoryItem?.name) return inventoryItem.name;
28
+ if (typeof itemRef === "string") return itemRef;
29
+ if (itemRef?.name) return itemRef.name;
30
+ if (itemRef?.id) return itemRef.id;
31
+ return "item";
32
+ };
33
+ var createDefeatReward = (rewards) => {
34
+ let awarded = false;
35
+ return {
36
+ get awarded() {
37
+ return awarded;
38
+ },
39
+ giveTo(player) {
40
+ if (!player || awarded || !rewards) return;
41
+ awarded = true;
42
+ const exp = rewards.exp ?? 0;
43
+ const gold = rewards.gold ?? 0;
44
+ if (exp > 0) player.exp += exp;
45
+ if (gold > 0) player.gold += gold;
46
+ if (rewards.showNotification && (exp > 0 || gold > 0)) player.showNotification(`You won ${exp} experience and ${gold} gold`);
47
+ for (const rawItem of rewards.items ?? []) {
48
+ const item = normalizeRewardItem(rawItem);
49
+ const itemRef = getRewardItemRef(item);
50
+ if (!itemRef) continue;
51
+ if (Math.random() * 100 >= (item.chance ?? 100)) continue;
52
+ const amount = item.amount ?? 1;
53
+ const inventoryItem = player.addItem(itemRef, amount);
54
+ if (rewards.showNotification) {
55
+ const itemData = typeof itemRef === "string" ? getPlayerMap(player)?.database?.()?.[itemRef] : void 0;
56
+ player.showNotification(`You won ${amount} ${getRewardItemName(inventoryItem, itemRef)}`, { icon: itemData?.icon });
57
+ }
58
+ }
59
+ }
60
+ };
61
+ };
9
62
  /**
10
63
  * AI Debug Logger
11
64
  *
@@ -177,6 +230,9 @@ var BattleAi = class {
177
230
  damageCheckInterval = 2e3;
178
231
  isMovingToTarget = false;
179
232
  onDefeatedCallback;
233
+ rewards;
234
+ autoAwardRewards = true;
235
+ defeated = false;
180
236
  lastFacingDirection = null;
181
237
  behaviorScore = 50;
182
238
  behaviorMode = "tactical";
@@ -195,30 +251,6 @@ var BattleAi = class {
195
251
  poise = 0;
196
252
  hitstunMs = 150;
197
253
  invincibilityMs = 250;
198
- /**
199
- * Create a new Battle AI Controller
200
- *
201
- * The AI controls behavior only. Stats should be set on the event
202
- * using standard RPGJS methods (hp, param, learnSkill, etc.)
203
- *
204
- * @param event - The event to control
205
- * @param options - AI behavior configuration
206
- *
207
- * @example
208
- * ```ts
209
- * // In your event's onInit
210
- * this.hp = 100;
211
- * this.param[ATK] = 20;
212
- * this.learnSkill(FireBall);
213
- *
214
- * new BattleAi(this, {
215
- * enemyType: EnemyType.Ranged,
216
- * attackSkill: FireBall,
217
- * visionRange: 200,
218
- * fleeThreshold: 0.2
219
- * });
220
- * ```
221
- */
222
254
  constructor(event, options = {}) {
223
255
  event.battleAi = this;
224
256
  this.event = event;
@@ -240,6 +272,8 @@ var BattleAi = class {
240
272
  this.patrolWaypoints = options.patrolWaypoints || [];
241
273
  this.currentPatrolIndex = 0;
242
274
  this.onDefeatedCallback = options.onDefeated;
275
+ this.rewards = options.rewards;
276
+ this.autoAwardRewards = options.autoAwardRewards ?? true;
243
277
  if (options.behavior) {
244
278
  this.behaviorEnabled = true;
245
279
  if (options.behavior.baseScore !== void 0) this.behaviorScore = options.behavior.baseScore;
@@ -1079,6 +1113,7 @@ var BattleAi = class {
1079
1113
  * The actual damage is applied externally via RPGJS API.
1080
1114
  */
1081
1115
  takeDamage(attacker) {
1116
+ if (this.defeated) return true;
1082
1117
  const raw = this.event.applyDamage(attacker);
1083
1118
  return this.handleDamage(attacker, {
1084
1119
  damage: raw.damage ?? 0,
@@ -1087,6 +1122,7 @@ var BattleAi = class {
1087
1122
  });
1088
1123
  }
1089
1124
  handleDamage(attacker, damageResult) {
1125
+ if (this.defeated) return true;
1090
1126
  const damage = damageResult.damage;
1091
1127
  this.debugLog("damage", `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || "?"})`);
1092
1128
  this.event.flash({
@@ -1123,11 +1159,37 @@ var BattleAi = class {
1123
1159
  * and removes the event from the map.
1124
1160
  */
1125
1161
  kill(attacker) {
1126
- const removeDelay = getActionBattleAnimationRemovalDelay(playActionBattleAnimation("die", this.event, this.animations, { attacker }));
1127
- if (this.onDefeatedCallback) this.onDefeatedCallback(this.event, attacker);
1162
+ if (this.defeated) return;
1163
+ this.defeated = true;
1164
+ const dieAnimation = resolveActionBattleAnimation("die", this.event, this.animations, { attacker });
1165
+ const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
1166
+ const reward = createDefeatReward(this.rewards);
1167
+ let removed = false;
1168
+ const remove = () => {
1169
+ if (removed) return;
1170
+ removed = true;
1171
+ this.event.remove({
1172
+ reason: "defeated",
1173
+ data: { animation: dieAnimation },
1174
+ transition: dieAnimation ? {
1175
+ animation: dieAnimation.animationName,
1176
+ graphic: dieAnimation.graphic,
1177
+ duration: removeDelay
1178
+ } : void 0,
1179
+ timeoutMs: removeDelay
1180
+ });
1181
+ };
1182
+ if (this.autoAwardRewards) reward.giveTo(attacker);
1183
+ const context = {
1184
+ event: this.event,
1185
+ attacker,
1186
+ reward,
1187
+ remove
1188
+ };
1189
+ if (this.onDefeatedCallback) if (this.onDefeatedCallback.length >= 2) this.onDefeatedCallback(this.event, attacker);
1190
+ else this.onDefeatedCallback(context);
1128
1191
  this.destroy();
1129
- if (removeDelay > 0) this.schedule(() => this.event.remove(), removeDelay);
1130
- else this.event.remove();
1192
+ remove();
1131
1193
  }
1132
1194
  /**
1133
1195
  * Get distance between entities
@@ -58,4 +58,4 @@ function getActionBattleAnimationRemovalDelay(animation) {
58
58
  return animation.waitEnd ? 500 : 0;
59
59
  }
60
60
  //#endregion
61
- export { getActionBattleAnimationRemovalDelay, playActionBattleAnimation };
61
+ export { getActionBattleAnimationRemovalDelay, playActionBattleAnimation, resolveActionBattleAnimation };
package/dist/server.d.ts CHANGED
@@ -103,4 +103,4 @@ export { DEFAULT_ACTION_BATTLE_HIT_REACTION, isActionBattleEntityInvincible, nor
103
103
  export { DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES, normalizeActionBattleEnemyAttackProfiles, type ActionBattleEnemyAttackProfileKey, type ActionBattleEnemyAttackProfileMap, type NormalizedActionBattleEnemyAttackProfileMap, } from './core/enemy-attack-profiles';
104
104
  export { resolveActionBattleWeaponAttackProfile } from './core/equipment';
105
105
  export { AiDebug, AiState, AttackPattern, BattleAi, DEFAULT_KNOCKBACK, EnemyType, } from './ai.server';
106
- export type { ApplyHitHooks, BattleAiOptions, HitResult } from './ai.server';
106
+ export type { ApplyHitHooks, BattleAiDefeatedCallback, BattleAiDefeatedContext, BattleAiDefeatReward, BattleAiLegacyDefeatedCallback, BattleAiLegacyOptions, BattleAiOptions, BattleAiRewardItem, BattleAiRewards, HitResult, } from './ai.server';
@@ -18,10 +18,10 @@ export interface ActionBattleTargetingState {
18
18
  };
19
19
  aoeMask: string[] | string;
20
20
  }
21
- export declare const actionBattleUiOptions: import('canvasengine').WritableObjectSignal<import('..').ActionBattleUiOptions>;
22
- export declare const actionBattleSkillOptions: import('canvasengine').WritableObjectSignal<import('../types').ActionBattleSkillOptions>;
23
- export declare const actionBattleTargetingState: import('canvasengine').WritableObjectSignal<ActionBattleTargetingState>;
24
- export declare const actionBattleAttackPreviewState: import('canvasengine').WritableObjectSignal<ActionBattleAttackPreviewState>;
21
+ export declare const actionBattleUiOptions: any;
22
+ export declare const actionBattleSkillOptions: any;
23
+ export declare const actionBattleTargetingState: any;
24
+ export declare const actionBattleAttackPreviewState: any;
25
25
  export declare const setActionBattleOptions: (options?: ActionBattleOptions) => void;
26
26
  export declare const startTargeting: (skill: ActionBattleActionBarSkill) => void;
27
27
  export declare const stopTargeting: () => void;
@@ -31,5 +31,5 @@ export declare const startAttackPreview: (options: {
31
31
  durationMs?: number;
32
32
  color?: number;
33
33
  accentColor?: number;
34
- }) => number;
34
+ }) => any;
35
35
  export declare const stopAttackPreview: (id?: number) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgjs/action-battle",
3
- "version": "5.0.0-beta.6",
3
+ "version": "5.0.0-beta.7",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "exports": {
@@ -23,10 +23,10 @@
23
23
  "description": "RPGJS is a framework for creating RPG/MMORPG games",
24
24
  "peerDependencies": {
25
25
  "@canvasengine/presets": "*",
26
- "@rpgjs/client": "5.0.0-beta.6",
27
- "@rpgjs/common": "5.0.0-beta.6",
28
- "@rpgjs/server": "5.0.0-beta.6",
29
- "@rpgjs/vite": "5.0.0-beta.6",
26
+ "@rpgjs/client": "5.0.0-beta.7",
27
+ "@rpgjs/common": "5.0.0-beta.7",
28
+ "@rpgjs/server": "5.0.0-beta.7",
29
+ "@rpgjs/vite": "5.0.0-beta.7",
30
30
  "canvasengine": "*"
31
31
  },
32
32
  "publishConfig": {
@@ -0,0 +1,120 @@
1
+ import { MAXHP } from "@rpgjs/server";
2
+ import { afterEach, describe, expect, test, vi } from "vitest";
3
+ import { BattleAi } from "./ai.server";
4
+
5
+ const createEvent = () => ({
6
+ id: "monster-1",
7
+ hp: 0,
8
+ param: {
9
+ [MAXHP]: 10,
10
+ },
11
+ attachShape: vi.fn(),
12
+ flash: vi.fn(),
13
+ showHit: vi.fn(),
14
+ setGraphicAnimation: vi.fn(),
15
+ stopMoveTo: vi.fn(),
16
+ getCurrentMap: vi.fn(() => ({})),
17
+ remove: vi.fn(),
18
+ x: vi.fn(() => 0),
19
+ y: vi.fn(() => 0),
20
+ direction: vi.fn(() => "down"),
21
+ });
22
+
23
+ const createPlayer = () => ({
24
+ id: "player-1",
25
+ exp: 0,
26
+ gold: 0,
27
+ addItem: vi.fn(() => ({ name: () => "Potion" })),
28
+ showNotification: vi.fn(),
29
+ getCurrentMap: vi.fn(() => ({
30
+ database: () => ({
31
+ potion: { icon: "potion-icon" },
32
+ }),
33
+ })),
34
+ });
35
+
36
+ describe("BattleAi defeat flow", () => {
37
+ afterEach(() => {
38
+ vi.useRealTimers();
39
+ vi.restoreAllMocks();
40
+ });
41
+
42
+ test("awards the attacker and requests a defeated remove transition", () => {
43
+ const event = createEvent();
44
+ const attacker = createPlayer();
45
+ const ai = new BattleAi(event as any, {
46
+ animations: {
47
+ die: {
48
+ animationName: "die",
49
+ repeat: 1,
50
+ delayMs: 700,
51
+ },
52
+ },
53
+ rewards: {
54
+ exp: 25,
55
+ gold: 7,
56
+ items: [{ itemId: "potion", amount: 2, chance: 100 }],
57
+ showNotification: true,
58
+ },
59
+ });
60
+
61
+ expect(ai.handleDamage(attacker as any, { damage: 10, defeated: true })).toBe(true);
62
+
63
+ expect(attacker.exp).toBe(25);
64
+ expect(attacker.gold).toBe(7);
65
+ expect(attacker.addItem).toHaveBeenCalledWith("potion", 2);
66
+ expect(event.setGraphicAnimation).not.toHaveBeenCalledWith("die", 1);
67
+ expect(event.remove).toHaveBeenCalledWith({
68
+ reason: "defeated",
69
+ data: {
70
+ animation: expect.objectContaining({
71
+ animationName: "die",
72
+ delayMs: 700,
73
+ }),
74
+ },
75
+ transition: {
76
+ animation: "die",
77
+ graphic: undefined,
78
+ duration: 700,
79
+ },
80
+ timeoutMs: 700,
81
+ });
82
+ });
83
+
84
+ test("supports the context onDefeated callback and manual reward control", () => {
85
+ const event = createEvent();
86
+ const attacker = createPlayer();
87
+ const onDefeated = vi.fn(({ reward }) => {
88
+ expect(reward.awarded).toBe(false);
89
+ reward.giveTo(attacker as any);
90
+ expect(reward.awarded).toBe(true);
91
+ });
92
+ const ai = new BattleAi(event as any, {
93
+ autoAwardRewards: false,
94
+ rewards: {
95
+ exp: 10,
96
+ },
97
+ onDefeated,
98
+ });
99
+
100
+ ai.handleDamage(attacker as any, { damage: 10, defeated: true });
101
+
102
+ expect(onDefeated).toHaveBeenCalledWith(
103
+ expect.objectContaining({
104
+ event,
105
+ attacker,
106
+ reward: expect.any(Object),
107
+ remove: expect.any(Function),
108
+ })
109
+ );
110
+ expect(attacker.exp).toBe(10);
111
+ expect(event.remove).toHaveBeenCalledWith({
112
+ reason: "defeated",
113
+ data: {
114
+ animation: null,
115
+ },
116
+ transition: undefined,
117
+ timeoutMs: 0,
118
+ });
119
+ });
120
+ });
package/src/ai.server.ts CHANGED
@@ -2,6 +2,7 @@ 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";
@@ -29,7 +30,42 @@ type RpgEventWithBattleAi = RpgEvent & {
29
30
  battleAi?: BattleAi;
30
31
  };
31
32
 
32
- 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 {
33
69
  enemyType?: EnemyType;
34
70
  attackCooldown?: number;
35
71
  visionRange?: number;
@@ -56,8 +92,18 @@ export interface BattleAiOptions {
56
92
  };
57
93
  behaviorKey?: string;
58
94
  animations?: ActionBattleAnimationOptions;
95
+ rewards?: BattleAiRewards;
96
+ autoAwardRewards?: boolean;
97
+ }
98
+
99
+ export interface BattleAiOptions extends BattleAiBaseOptions {
59
100
  /** Callback called when the AI is defeated */
60
- 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;
61
107
  }
62
108
 
63
109
  /**
@@ -94,6 +140,84 @@ export interface HitResult {
94
140
  target: RpgPlayer | RpgEvent;
95
141
  }
96
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
+
97
221
  /**
98
222
  * Hook options for customizing hit behavior
99
223
  *
@@ -340,7 +464,12 @@ export class BattleAi {
340
464
  private isMovingToTarget: boolean = false;
341
465
 
342
466
  // Callback when AI is defeated
343
- 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;
344
473
 
345
474
  // Direction hysteresis to prevent animation flickering
346
475
  private lastFacingDirection: string | null = null;
@@ -390,9 +519,11 @@ export class BattleAi {
390
519
  * });
391
520
  * ```
392
521
  */
522
+ constructor(event: RpgEventWithBattleAi, options?: BattleAiOptions);
523
+ constructor(event: RpgEventWithBattleAi, options?: BattleAiLegacyOptions);
393
524
  constructor(
394
525
  event: RpgEventWithBattleAi,
395
- options: BattleAiOptions = {}
526
+ options: BattleAiOptions | BattleAiLegacyOptions = {}
396
527
  ) {
397
528
  event.battleAi = this;
398
529
  this.event = event;
@@ -428,6 +559,8 @@ export class BattleAi {
428
559
 
429
560
  // Initialize defeat callback
430
561
  this.onDefeatedCallback = options.onDefeated;
562
+ this.rewards = options.rewards;
563
+ this.autoAwardRewards = options.autoAwardRewards ?? true;
431
564
 
432
565
  // Behavior gauge settings
433
566
  if (options.behavior) {
@@ -1593,6 +1726,7 @@ export class BattleAi {
1593
1726
  * The actual damage is applied externally via RPGJS API.
1594
1727
  */
1595
1728
  takeDamage(attacker: RpgPlayer): boolean {
1729
+ if (this.defeated) return true;
1596
1730
  // Apply damage using RPGJS system
1597
1731
  const raw = this.event.applyDamage(attacker);
1598
1732
  return this.handleDamage(attacker, {
@@ -1608,6 +1742,7 @@ export class BattleAi {
1608
1742
  reaction?: NormalizedActionBattleHitReactionProfile;
1609
1743
  }
1610
1744
  ): boolean {
1745
+ if (this.defeated) return true;
1611
1746
  const damage = damageResult.damage;
1612
1747
  this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
1613
1748
 
@@ -1660,7 +1795,10 @@ export class BattleAi {
1660
1795
  * and removes the event from the map.
1661
1796
  */
1662
1797
  private kill(attacker?: RpgPlayer) {
1663
- const dieAnimation = playActionBattleAnimation(
1798
+ if (this.defeated) return;
1799
+ this.defeated = true;
1800
+
1801
+ const dieAnimation = resolveActionBattleAnimation(
1664
1802
  "die",
1665
1803
  this.event,
1666
1804
  this.animations,
@@ -1669,18 +1807,57 @@ export class BattleAi {
1669
1807
  }
1670
1808
  );
1671
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
+ };
1672
1830
 
1673
- // 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.
1674
1844
  if (this.onDefeatedCallback) {
1675
- 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
+ }
1676
1857
  }
1677
-
1858
+
1678
1859
  this.destroy();
1679
- if (removeDelay > 0) {
1680
- this.schedule(() => this.event.remove(), removeDelay);
1681
- } else {
1682
- this.event.remove();
1683
- }
1860
+ remove();
1684
1861
  }
1685
1862
 
1686
1863
  /**
package/src/index.ts CHANGED
@@ -7,7 +7,18 @@ import type { ActionBattleOptions } from "./types";
7
7
  export { BattleAi, AiState, EnemyType, AttackPattern, AiDebug, DEFAULT_KNOCKBACK } from "./ai.server";
8
8
 
9
9
  // Types exports
10
- export type { HitResult, ApplyHitHooks, BattleAiOptions } from "./ai.server";
10
+ export type {
11
+ HitResult,
12
+ ApplyHitHooks,
13
+ BattleAiOptions,
14
+ BattleAiDefeatedCallback,
15
+ BattleAiDefeatedContext,
16
+ BattleAiDefeatReward,
17
+ BattleAiLegacyDefeatedCallback,
18
+ BattleAiLegacyOptions,
19
+ BattleAiRewardItem,
20
+ BattleAiRewards,
21
+ } from "./ai.server";
11
22
  export type {
12
23
  ActionBattleAnimationContext,
13
24
  ActionBattleAnimationEntity,
package/src/server.ts CHANGED
@@ -817,4 +817,15 @@ export {
817
817
  DEFAULT_KNOCKBACK,
818
818
  EnemyType,
819
819
  } from "./ai.server";
820
- export type { ApplyHitHooks, BattleAiOptions, HitResult } from "./ai.server";
820
+ export type {
821
+ ApplyHitHooks,
822
+ BattleAiDefeatedCallback,
823
+ BattleAiDefeatedContext,
824
+ BattleAiDefeatReward,
825
+ BattleAiLegacyDefeatedCallback,
826
+ BattleAiLegacyOptions,
827
+ BattleAiOptions,
828
+ BattleAiRewardItem,
829
+ BattleAiRewards,
830
+ HitResult,
831
+ } from "./ai.server";