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

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/LICENSE ADDED
@@ -0,0 +1,19 @@
1
+ Copyright (C) 2026 by Samuel Ronce
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in
11
+ all copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19
+ THE SOFTWARE.
package/README.md CHANGED
@@ -496,6 +496,100 @@ The module handles player attacks via the `action` input:
496
496
  // Knockback force is based on equipped weapon's knockbackForce property
497
497
  ```
498
498
 
499
+ ## Configurable Combat Animations
500
+
501
+ By default, player and AI attacks keep using the existing `attack` animation:
502
+
503
+ ```ts
504
+ player.setGraphicAnimation("attack", 1);
505
+ ```
506
+
507
+ Use `animations` when your combat sprites are stored in separate graphics such
508
+ as `hero_attack`, `hero_hurt`, or `hero_die`.
509
+
510
+ ```ts
511
+ import { provideActionBattle } from "@rpgjs/action-battle/server";
512
+
513
+ export default provideActionBattle({
514
+ animations: {
515
+ attack: "attack",
516
+ hurt: "hurt",
517
+ die: {
518
+ animationName: "die",
519
+ repeat: 1,
520
+ delayMs: 500
521
+ },
522
+ castSkill: "skill"
523
+ }
524
+ });
525
+ ```
526
+
527
+ For data-driven spritesheets, use resolver functions:
528
+
529
+ ```ts
530
+ provideActionBattle({
531
+ animations: {
532
+ attack: (entity) => ({
533
+ animationName: "walk",
534
+ graphic: entity.combatAnimations?.attack,
535
+ repeat: 1
536
+ }),
537
+ hurt: (entity) => ({
538
+ animationName: "walk",
539
+ graphic: entity.combatAnimations?.hurt,
540
+ repeat: 1
541
+ }),
542
+ die: (entity) => ({
543
+ animationName: "walk",
544
+ graphic: entity.combatAnimations?.die,
545
+ repeat: 1,
546
+ waitEnd: true
547
+ }),
548
+ castSkill: (entity, context) => ({
549
+ animationName: "walk",
550
+ graphic: entity.combatAnimations?.castSkill,
551
+ repeat: 1
552
+ })
553
+ }
554
+ });
555
+ ```
556
+
557
+ When `graphic` is provided, action-battle calls:
558
+
559
+ ```ts
560
+ entity.setGraphicAnimation(animationName, graphic, repeat);
561
+ ```
562
+
563
+ Otherwise it calls:
564
+
565
+ ```ts
566
+ entity.setGraphicAnimation(animationName, repeat);
567
+ ```
568
+
569
+ Return `null` or `undefined` from a resolver to skip the animation. `BattleAi`
570
+ can also override the global configuration per enemy:
571
+
572
+ ```ts
573
+ new BattleAi(this, {
574
+ animations: {
575
+ attack: {
576
+ animationName: "walk",
577
+ graphic: "slime_attack",
578
+ repeat: 1
579
+ },
580
+ die: {
581
+ animationName: "walk",
582
+ graphic: "slime_die",
583
+ repeat: 1,
584
+ delayMs: 700
585
+ }
586
+ }
587
+ });
588
+ ```
589
+
590
+ `waitEnd: true` delays event removal for defeated AI with the default delay used
591
+ by action-battle. Use `delayMs` when you need an exact duration.
592
+
499
593
  ## Knockback System
500
594
 
501
595
  Knockback force is determined by the equipped weapon's `knockbackForce` property.
@@ -1,7 +1,36 @@
1
1
  import { RpgEvent, RpgPlayer } from '@rpgjs/server';
2
+ import { ActionBattleAnimationOptions } from './types';
2
3
  type RpgEventWithBattleAi = RpgEvent & {
3
4
  battleAi: BattleAi;
4
5
  };
6
+ export interface BattleAiOptions {
7
+ enemyType?: EnemyType;
8
+ attackCooldown?: number;
9
+ visionRange?: number;
10
+ attackRange?: number;
11
+ dodgeChance?: number;
12
+ dodgeCooldown?: number;
13
+ fleeThreshold?: number;
14
+ attackSkill?: any;
15
+ attackPatterns?: AttackPattern[];
16
+ patrolWaypoints?: Array<{
17
+ x: number;
18
+ y: number;
19
+ }>;
20
+ groupBehavior?: boolean;
21
+ moveToCooldown?: number;
22
+ retreatCooldown?: number;
23
+ behavior?: {
24
+ baseScore?: number;
25
+ updateInterval?: number;
26
+ minStateDuration?: number;
27
+ assaultThreshold?: number;
28
+ retreatThreshold?: number;
29
+ };
30
+ animations?: ActionBattleAnimationOptions;
31
+ /** Callback called when the AI is defeated */
32
+ onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
33
+ }
5
34
  /**
6
35
  * Hit result data returned after applying damage
7
36
  *
@@ -221,6 +250,7 @@ export declare class BattleAi {
221
250
  private fleeThreshold;
222
251
  private attackSkill;
223
252
  private attackPatterns;
253
+ private animations?;
224
254
  private comboCount;
225
255
  private comboMax;
226
256
  private chargingAttack;
@@ -271,33 +301,7 @@ export declare class BattleAi {
271
301
  * });
272
302
  * ```
273
303
  */
274
- constructor(event: RpgEventWithBattleAi, options?: {
275
- enemyType?: EnemyType;
276
- attackCooldown?: number;
277
- visionRange?: number;
278
- attackRange?: number;
279
- dodgeChance?: number;
280
- dodgeCooldown?: number;
281
- fleeThreshold?: number;
282
- attackSkill?: any;
283
- attackPatterns?: AttackPattern[];
284
- patrolWaypoints?: Array<{
285
- x: number;
286
- y: number;
287
- }>;
288
- groupBehavior?: boolean;
289
- moveToCooldown?: number;
290
- retreatCooldown?: number;
291
- behavior?: {
292
- baseScore?: number;
293
- updateInterval?: number;
294
- minStateDuration?: number;
295
- assaultThreshold?: number;
296
- retreatThreshold?: number;
297
- };
298
- /** Callback called when the AI is defeated */
299
- onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
300
- });
304
+ constructor(event: RpgEventWithBattleAi, options?: BattleAiOptions);
301
305
  /**
302
306
  * Apply enemy type-specific behavior modifiers
303
307
  *
@@ -0,0 +1,16 @@
1
+ import { ActionBattleAnimationContext, ActionBattleAnimationEntity, ActionBattleAnimationKey, ActionBattleAnimationOptions } from './types';
2
+ export declare const DEFAULT_DIE_ANIMATION_DELAY_MS = 500;
3
+ export interface ResolvedActionBattleAnimation {
4
+ animationName: string;
5
+ graphic?: string | string[];
6
+ repeat: number;
7
+ waitEnd: boolean;
8
+ delayMs?: number;
9
+ }
10
+ export interface ActionBattleAnimationDefaults {
11
+ animationName?: string;
12
+ repeat?: number;
13
+ }
14
+ export declare function resolveActionBattleAnimation(key: ActionBattleAnimationKey, entity: ActionBattleAnimationEntity, animations?: ActionBattleAnimationOptions, context?: ActionBattleAnimationContext, defaults?: ActionBattleAnimationDefaults): ResolvedActionBattleAnimation | null;
15
+ export declare function playActionBattleAnimation(key: ActionBattleAnimationKey, entity: ActionBattleAnimationEntity, animations?: ActionBattleAnimationOptions, context?: ActionBattleAnimationContext, defaults?: ActionBattleAnimationDefaults): ResolvedActionBattleAnimation | null;
16
+ export declare function getActionBattleAnimationRemovalDelay(animation: ResolvedActionBattleAnimation | null): number;
@@ -0,0 +1,30 @@
1
+ const normalizeMaskRows = (mask) => {
2
+ if (!mask) return ["#"];
3
+ if (Array.isArray(mask)) return mask;
4
+ return mask.trim().split("\n").map((row) => row.replace(/\r/g, ""));
5
+ };
6
+ const parseAoeMask = (mask) => {
7
+ const rows = normalizeMaskRows(mask);
8
+ const height = rows.length;
9
+ const width = rows.reduce((max, row) => Math.max(max, row.length), 0);
10
+ const centerX = Math.floor(width / 2);
11
+ const centerY = Math.floor(height / 2);
12
+ const cells = [];
13
+ rows.forEach((row, y) => {
14
+ for (let x = 0; x < row.length; x++) {
15
+ const char = row[x];
16
+ if (char && char !== "." && char !== " ") {
17
+ cells.push({ dx: x - centerX, dy: y - centerY });
18
+ }
19
+ }
20
+ });
21
+ if (cells.length === 0) {
22
+ cells.push({ dx: 0, dy: 0 });
23
+ }
24
+ return { width, height, centerX, centerY, cells };
25
+ };
26
+ const manhattanDistance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
27
+ export {
28
+ manhattanDistance,
29
+ parseAoeMask
30
+ };
@@ -1,3 +1,5 @@
1
+ import { playActionBattleAnimation, getActionBattleAnimationRemovalDelay } from "./index9.js";
2
+ import { getActionBattleOptions } from "./index8.js";
1
3
  const MAXHP = null;
2
4
  const RpgPlayer = null;
3
5
  const AiDebug = {
@@ -118,6 +120,10 @@ class BattleAi {
118
120
  this.enemyType = options.enemyType || "aggressive";
119
121
  this.applyEnemyTypeBehavior(options);
120
122
  this.attackSkill = options.attackSkill || null;
123
+ this.animations = {
124
+ ...getActionBattleOptions().animations,
125
+ ...options.animations
126
+ };
121
127
  this.attackPatterns = options.attackPatterns || [
122
128
  "melee",
123
129
  "combo",
@@ -643,9 +649,15 @@ class BattleAi {
643
649
  performMeleeAttack() {
644
650
  if (!this.target) return;
645
651
  this.faceTarget();
646
- this.event.setGraphicAnimation("attack", 1);
652
+ playActionBattleAnimation("attack", this.event, this.animations, {
653
+ target: this.target
654
+ });
647
655
  if (this.attackSkill) {
648
656
  try {
657
+ playActionBattleAnimation("castSkill", this.event, this.animations, {
658
+ skill: this.attackSkill,
659
+ target: this.target
660
+ });
649
661
  this.event.useSkill(this.attackSkill, this.target);
650
662
  } catch (e) {
651
663
  this.performBasicHitbox();
@@ -810,7 +822,15 @@ class BattleAi {
810
822
  if (!this.target) return;
811
823
  this.chargingAttack = true;
812
824
  this.faceTarget();
813
- this.event.setGraphicAnimation("attack", 2);
825
+ playActionBattleAnimation(
826
+ "attack",
827
+ this.event,
828
+ this.animations,
829
+ {
830
+ target: this.target
831
+ },
832
+ { repeat: 2 }
833
+ );
814
834
  setTimeout(() => {
815
835
  if (!this.target || this.state !== "combat") {
816
836
  this.chargingAttack = false;
@@ -818,6 +838,10 @@ class BattleAi {
818
838
  }
819
839
  if (this.attackSkill) {
820
840
  try {
841
+ playActionBattleAnimation("castSkill", this.event, this.animations, {
842
+ skill: this.attackSkill,
843
+ target: this.target
844
+ });
821
845
  this.event.useSkill(this.attackSkill, this.target);
822
846
  } catch (e) {
823
847
  this.performBasicHitbox();
@@ -832,7 +856,9 @@ class BattleAi {
832
856
  * Perform zone attack (360 degrees)
833
857
  */
834
858
  performZoneAttack() {
835
- this.event.setGraphicAnimation("attack", 1);
859
+ playActionBattleAnimation("attack", this.event, this.animations, {
860
+ target: this.target ?? void 0
861
+ });
836
862
  const eventX = this.event.x();
837
863
  const eventY = this.event.y();
838
864
  const radius = 50;
@@ -1110,6 +1136,9 @@ class BattleAi {
1110
1136
  cycles: 1
1111
1137
  });
1112
1138
  this.event.showHit(`-${damage}`);
1139
+ playActionBattleAnimation("hurt", this.event, this.animations, {
1140
+ attacker
1141
+ });
1113
1142
  this.recentDamageTaken += damage;
1114
1143
  if (this.state !== "stunned" && this.state !== "flee") {
1115
1144
  this.debugLog("damage", "Stunned from damage");
@@ -1134,11 +1163,24 @@ class BattleAi {
1134
1163
  * and removes the event from the map.
1135
1164
  */
1136
1165
  kill(attacker) {
1166
+ const dieAnimation = playActionBattleAnimation(
1167
+ "die",
1168
+ this.event,
1169
+ this.animations,
1170
+ {
1171
+ attacker
1172
+ }
1173
+ );
1174
+ const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
1137
1175
  if (this.onDefeatedCallback) {
1138
1176
  this.onDefeatedCallback(this.event, attacker);
1139
1177
  }
1140
1178
  this.destroy();
1141
- this.event.remove();
1179
+ if (removeDelay > 0) {
1180
+ setTimeout(() => this.event.remove(), removeDelay);
1181
+ } else {
1182
+ this.event.remove();
1183
+ }
1142
1184
  }
1143
1185
  /**
1144
1186
  * Get distance between entities
@@ -1,6 +1,7 @@
1
1
  import { defineModule, Control } from "@rpgjs/common";
2
- import { normalizeActionBattleOptions } from "./index8.js";
3
- import { manhattanDistance, parseAoeMask } from "./index9.js";
2
+ import { normalizeActionBattleOptions, setActionBattleOptions } from "./index8.js";
3
+ import { manhattanDistance, parseAoeMask } from "./index10.js";
4
+ import { playActionBattleAnimation } from "./index9.js";
4
5
  const RpgEvent = null;
5
6
  const DEFAULT_KNOCKBACK = null;
6
7
  const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
@@ -188,13 +189,20 @@ const getEntityTile = (entity, tileSize) => {
188
189
  return { x, y };
189
190
  };
190
191
  const handleActionBattleSkillUse = (player, skillId, target, options) => {
192
+ const skillData = resolveSkillData(player, skillId);
191
193
  const map = player.getCurrentMap();
192
194
  if (!map) {
195
+ playActionBattleAnimation("castSkill", player, options.animations, {
196
+ skill: skillData
197
+ });
193
198
  player.useSkill(skillId);
194
199
  return;
195
200
  }
196
201
  const targeting = resolveSkillTargeting(player, skillId, options);
197
202
  if (!targeting || !target) {
203
+ playActionBattleAnimation("castSkill", player, options.animations, {
204
+ skill: skillData
205
+ });
198
206
  player.useSkill(skillId);
199
207
  return;
200
208
  }
@@ -235,10 +243,15 @@ const handleActionBattleSkillUse = (player, skillId, target, options) => {
235
243
  if (!options.targeting?.allowEmptyTarget && targets.length === 0) {
236
244
  return;
237
245
  }
246
+ playActionBattleAnimation("castSkill", player, options.animations, {
247
+ skill: skillData,
248
+ target: targets[0]
249
+ });
238
250
  player.useSkill(skillId, targets);
239
251
  };
240
252
  const createActionBattleServer = (rawOptions = {}) => {
241
253
  const options = normalizeActionBattleOptions(rawOptions);
254
+ setActionBattleOptions(options);
242
255
  return defineModule({
243
256
  player: {
244
257
  /**
@@ -254,7 +267,7 @@ const createActionBattleServer = (rawOptions = {}) => {
254
267
  */
255
268
  onInput(player, input) {
256
269
  if (input.action == Control.Action) {
257
- player.setGraphicAnimation("attack", 1);
270
+ playActionBattleAnimation("attack", player, options.animations);
258
271
  const playerX = player.x();
259
272
  const playerY = player.y();
260
273
  const direction = player.getDirection();
@@ -1,7 +1,7 @@
1
1
  import { useProps, useDefineProps, computed, h, Container, cond, Graphics } from "canvasengine";
2
2
  import { inject, RpgClientEngine } from "@rpgjs/client";
3
3
  import { actionBattleUiOptions, actionBattleTargetingState } from "./index7.js";
4
- import { parseAoeMask } from "./index9.js";
4
+ import { parseAoeMask } from "./index10.js";
5
5
  function component($$props) {
6
6
  useProps($$props);
7
7
  const defineProps = useDefineProps($$props);
@@ -21,8 +21,10 @@ const DEFAULT_ACTION_BATTLE_OPTIONS = {
21
21
  targeting: {
22
22
  affects: "events",
23
23
  allowEmptyTarget: true
24
- }
24
+ },
25
+ animations: {}
25
26
  };
27
+ let currentActionBattleOptions = DEFAULT_ACTION_BATTLE_OPTIONS;
26
28
  function normalizeActionBattleOptions(options = {}) {
27
29
  return {
28
30
  ui: {
@@ -46,10 +48,22 @@ function normalizeActionBattleOptions(options = {}) {
46
48
  targeting: {
47
49
  ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
48
50
  ...options.targeting
51
+ },
52
+ animations: {
53
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
54
+ ...options.animations
49
55
  }
50
56
  };
51
57
  }
58
+ function setActionBattleOptions(options) {
59
+ currentActionBattleOptions = options;
60
+ }
61
+ function getActionBattleOptions() {
62
+ return currentActionBattleOptions;
63
+ }
52
64
  export {
53
65
  DEFAULT_ACTION_BATTLE_OPTIONS,
54
- normalizeActionBattleOptions
66
+ getActionBattleOptions,
67
+ normalizeActionBattleOptions,
68
+ setActionBattleOptions
55
69
  };
@@ -1,30 +1,64 @@
1
- const normalizeMaskRows = (mask) => {
2
- if (!mask) return ["#"];
3
- if (Array.isArray(mask)) return mask;
4
- return mask.trim().split("\n").map((row) => row.replace(/\r/g, ""));
1
+ const DEFAULT_DIE_ANIMATION_DELAY_MS = 500;
2
+ const DEFAULT_ANIMATION_BY_KEY = {
3
+ attack: "attack",
4
+ hurt: "hurt",
5
+ die: "die",
6
+ castSkill: "skill"
5
7
  };
6
- const parseAoeMask = (mask) => {
7
- const rows = normalizeMaskRows(mask);
8
- const height = rows.length;
9
- const width = rows.reduce((max, row) => Math.max(max, row.length), 0);
10
- const centerX = Math.floor(width / 2);
11
- const centerY = Math.floor(height / 2);
12
- const cells = [];
13
- rows.forEach((row, y) => {
14
- for (let x = 0; x < row.length; x++) {
15
- const char = row[x];
16
- if (char && char !== "." && char !== " ") {
17
- cells.push({ dx: x - centerX, dy: y - centerY });
18
- }
19
- }
20
- });
21
- if (cells.length === 0) {
22
- cells.push({ dx: 0, dy: 0 });
8
+ function resolveActionBattleAnimation(key, entity, animations, context, defaults = {}) {
9
+ const defaultAnimationName = defaults.animationName ?? DEFAULT_ANIMATION_BY_KEY[key];
10
+ const defaultRepeat = defaults.repeat ?? 1;
11
+ const hasConfiguredAnimation = animations ? Object.prototype.hasOwnProperty.call(animations, key) : false;
12
+ if (!hasConfiguredAnimation && key !== "attack") {
13
+ return null;
23
14
  }
24
- return { width, height, centerX, centerY, cells };
25
- };
26
- const manhattanDistance = (a, b) => Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
15
+ const configured = hasConfiguredAnimation ? animations?.[key] : defaultAnimationName;
16
+ const result = typeof configured === "function" ? configured(entity, context) : configured;
17
+ if (result == null) return null;
18
+ if (typeof result === "string") {
19
+ return {
20
+ animationName: result,
21
+ repeat: defaultRepeat,
22
+ waitEnd: false
23
+ };
24
+ }
25
+ const animationName = result.animationName ?? defaultAnimationName;
26
+ return {
27
+ animationName,
28
+ graphic: result.graphic,
29
+ repeat: result.repeat ?? defaultRepeat,
30
+ waitEnd: result.waitEnd ?? false,
31
+ delayMs: result.delayMs
32
+ };
33
+ }
34
+ function playActionBattleAnimation(key, entity, animations, context, defaults = {}) {
35
+ const animation = resolveActionBattleAnimation(
36
+ key,
37
+ entity,
38
+ animations,
39
+ context,
40
+ defaults
41
+ );
42
+ if (!animation) return null;
43
+ if (animation.graphic !== void 0) {
44
+ entity.setGraphicAnimation(
45
+ animation.animationName,
46
+ animation.graphic,
47
+ animation.repeat
48
+ );
49
+ } else {
50
+ entity.setGraphicAnimation(animation.animationName, animation.repeat);
51
+ }
52
+ return animation;
53
+ }
54
+ function getActionBattleAnimationRemovalDelay(animation) {
55
+ if (!animation) return 0;
56
+ if (animation.delayMs !== void 0) return animation.delayMs;
57
+ return animation.waitEnd ? DEFAULT_DIE_ANIMATION_DELAY_MS : 0;
58
+ }
27
59
  export {
28
- manhattanDistance,
29
- parseAoeMask
60
+ DEFAULT_DIE_ANIMATION_DELAY_MS,
61
+ getActionBattleAnimationRemovalDelay,
62
+ playActionBattleAnimation,
63
+ resolveActionBattleAnimation
30
64
  };
package/dist/config.d.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  import { ActionBattleOptions } from './types';
2
2
  export declare const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions;
3
3
  export declare function normalizeActionBattleOptions(options?: ActionBattleOptions): ActionBattleOptions;
4
+ export declare function setActionBattleOptions(options: ActionBattleOptions): void;
5
+ export declare function getActionBattleOptions(): ActionBattleOptions;
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { ActionBattleOptions } from './types';
2
2
  export { BattleAi, AiState, EnemyType, AttackPattern, AiDebug, DEFAULT_KNOCKBACK } from './ai.server';
3
- export type { HitResult, ApplyHitHooks } from './ai.server';
4
- export type { ActionBattleOptions, ActionBattleActionBarData, ActionBattleActionBarItem, ActionBattleActionBarSkill, ActionBattleSkillTargeting, ActionBattleSkillTargetingResolver, ActionBattleUiOptions, ActionBattleUiActionBarOptions, ActionBattleUiTargetingOptions, } from './types';
3
+ export type { HitResult, ApplyHitHooks, BattleAiOptions } from './ai.server';
4
+ export type { ActionBattleAnimationContext, ActionBattleAnimationEntity, ActionBattleAnimationKey, ActionBattleAnimationOptions, ActionBattleAnimationResolver, ActionBattleAnimationResult, ActionBattleOptions, ActionBattleActionBarData, ActionBattleActionBarItem, ActionBattleActionBarSkill, ActionBattleSkillTargeting, ActionBattleSkillTargetingResolver, ActionBattleUiOptions, ActionBattleUiActionBarOptions, ActionBattleUiTargetingOptions, } from './types';
5
5
  export { DEFAULT_PLAYER_ATTACK_HITBOXES, getPlayerWeaponKnockbackForce, applyPlayerHitToEvent, ACTION_BATTLE_ACTION_BAR_GUI_ID, openActionBattleActionBar, updateActionBattleActionBar, createActionBattleServer, } from './server';
6
6
  export declare function provideActionBattle(options?: ActionBattleOptions): any;
7
7
  declare const _default: {
@@ -1,8 +1,9 @@
1
1
  import { RpgEvent } from "@rpgjs/server";
2
2
  import { defineModule, Control } from "@rpgjs/common";
3
3
  import { DEFAULT_KNOCKBACK } from "./index3.js";
4
- import { normalizeActionBattleOptions } from "./index4.js";
4
+ import { normalizeActionBattleOptions, setActionBattleOptions } from "./index4.js";
5
5
  import { manhattanDistance, parseAoeMask } from "./index5.js";
6
+ import { playActionBattleAnimation } from "./index6.js";
6
7
  const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
7
8
  const DEFAULT_PLAYER_ATTACK_HITBOXES = {
8
9
  up: { offsetX: -16, offsetY: -48, width: 32, height: 32 },
@@ -188,13 +189,20 @@ const getEntityTile = (entity, tileSize) => {
188
189
  return { x, y };
189
190
  };
190
191
  const handleActionBattleSkillUse = (player, skillId, target, options) => {
192
+ const skillData = resolveSkillData(player, skillId);
191
193
  const map = player.getCurrentMap();
192
194
  if (!map) {
195
+ playActionBattleAnimation("castSkill", player, options.animations, {
196
+ skill: skillData
197
+ });
193
198
  player.useSkill(skillId);
194
199
  return;
195
200
  }
196
201
  const targeting = resolveSkillTargeting(player, skillId, options);
197
202
  if (!targeting || !target) {
203
+ playActionBattleAnimation("castSkill", player, options.animations, {
204
+ skill: skillData
205
+ });
198
206
  player.useSkill(skillId);
199
207
  return;
200
208
  }
@@ -235,10 +243,15 @@ const handleActionBattleSkillUse = (player, skillId, target, options) => {
235
243
  if (!options.targeting?.allowEmptyTarget && targets.length === 0) {
236
244
  return;
237
245
  }
246
+ playActionBattleAnimation("castSkill", player, options.animations, {
247
+ skill: skillData,
248
+ target: targets[0]
249
+ });
238
250
  player.useSkill(skillId, targets);
239
251
  };
240
252
  const createActionBattleServer = (rawOptions = {}) => {
241
253
  const options = normalizeActionBattleOptions(rawOptions);
254
+ setActionBattleOptions(options);
242
255
  return defineModule({
243
256
  player: {
244
257
  /**
@@ -254,7 +267,7 @@ const createActionBattleServer = (rawOptions = {}) => {
254
267
  */
255
268
  onInput(player, input) {
256
269
  if (input.action == Control.Action) {
257
- player.setGraphicAnimation("attack", 1);
270
+ playActionBattleAnimation("attack", player, options.animations);
258
271
  const playerX = player.x();
259
272
  const playerY = player.y();
260
273
  const direction = player.getDirection();
@@ -1,4 +1,6 @@
1
1
  import { MAXHP, RpgPlayer } from "@rpgjs/server";
2
+ import { playActionBattleAnimation, getActionBattleAnimationRemovalDelay } from "./index6.js";
3
+ import { getActionBattleOptions } from "./index4.js";
2
4
  const AiDebug = {
3
5
  /** Enable/disable all AI debug logs */
4
6
  enabled: typeof process !== "undefined" && process.env?.RPGJS_DEBUG_AI === "1" || false,
@@ -117,6 +119,10 @@ class BattleAi {
117
119
  this.enemyType = options.enemyType || "aggressive";
118
120
  this.applyEnemyTypeBehavior(options);
119
121
  this.attackSkill = options.attackSkill || null;
122
+ this.animations = {
123
+ ...getActionBattleOptions().animations,
124
+ ...options.animations
125
+ };
120
126
  this.attackPatterns = options.attackPatterns || [
121
127
  "melee",
122
128
  "combo",
@@ -642,9 +648,15 @@ class BattleAi {
642
648
  performMeleeAttack() {
643
649
  if (!this.target) return;
644
650
  this.faceTarget();
645
- this.event.setGraphicAnimation("attack", 1);
651
+ playActionBattleAnimation("attack", this.event, this.animations, {
652
+ target: this.target
653
+ });
646
654
  if (this.attackSkill) {
647
655
  try {
656
+ playActionBattleAnimation("castSkill", this.event, this.animations, {
657
+ skill: this.attackSkill,
658
+ target: this.target
659
+ });
648
660
  this.event.useSkill(this.attackSkill, this.target);
649
661
  } catch (e) {
650
662
  this.performBasicHitbox();
@@ -809,7 +821,15 @@ class BattleAi {
809
821
  if (!this.target) return;
810
822
  this.chargingAttack = true;
811
823
  this.faceTarget();
812
- this.event.setGraphicAnimation("attack", 2);
824
+ playActionBattleAnimation(
825
+ "attack",
826
+ this.event,
827
+ this.animations,
828
+ {
829
+ target: this.target
830
+ },
831
+ { repeat: 2 }
832
+ );
813
833
  setTimeout(() => {
814
834
  if (!this.target || this.state !== "combat") {
815
835
  this.chargingAttack = false;
@@ -817,6 +837,10 @@ class BattleAi {
817
837
  }
818
838
  if (this.attackSkill) {
819
839
  try {
840
+ playActionBattleAnimation("castSkill", this.event, this.animations, {
841
+ skill: this.attackSkill,
842
+ target: this.target
843
+ });
820
844
  this.event.useSkill(this.attackSkill, this.target);
821
845
  } catch (e) {
822
846
  this.performBasicHitbox();
@@ -831,7 +855,9 @@ class BattleAi {
831
855
  * Perform zone attack (360 degrees)
832
856
  */
833
857
  performZoneAttack() {
834
- this.event.setGraphicAnimation("attack", 1);
858
+ playActionBattleAnimation("attack", this.event, this.animations, {
859
+ target: this.target ?? void 0
860
+ });
835
861
  const eventX = this.event.x();
836
862
  const eventY = this.event.y();
837
863
  const radius = 50;
@@ -1109,6 +1135,9 @@ class BattleAi {
1109
1135
  cycles: 1
1110
1136
  });
1111
1137
  this.event.showHit(`-${damage}`);
1138
+ playActionBattleAnimation("hurt", this.event, this.animations, {
1139
+ attacker
1140
+ });
1112
1141
  this.recentDamageTaken += damage;
1113
1142
  if (this.state !== "stunned" && this.state !== "flee") {
1114
1143
  this.debugLog("damage", "Stunned from damage");
@@ -1133,11 +1162,24 @@ class BattleAi {
1133
1162
  * and removes the event from the map.
1134
1163
  */
1135
1164
  kill(attacker) {
1165
+ const dieAnimation = playActionBattleAnimation(
1166
+ "die",
1167
+ this.event,
1168
+ this.animations,
1169
+ {
1170
+ attacker
1171
+ }
1172
+ );
1173
+ const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
1136
1174
  if (this.onDefeatedCallback) {
1137
1175
  this.onDefeatedCallback(this.event, attacker);
1138
1176
  }
1139
1177
  this.destroy();
1140
- this.event.remove();
1178
+ if (removeDelay > 0) {
1179
+ setTimeout(() => this.event.remove(), removeDelay);
1180
+ } else {
1181
+ this.event.remove();
1182
+ }
1141
1183
  }
1142
1184
  /**
1143
1185
  * Get distance between entities
@@ -21,8 +21,10 @@ const DEFAULT_ACTION_BATTLE_OPTIONS = {
21
21
  targeting: {
22
22
  affects: "events",
23
23
  allowEmptyTarget: true
24
- }
24
+ },
25
+ animations: {}
25
26
  };
27
+ let currentActionBattleOptions = DEFAULT_ACTION_BATTLE_OPTIONS;
26
28
  function normalizeActionBattleOptions(options = {}) {
27
29
  return {
28
30
  ui: {
@@ -46,10 +48,22 @@ function normalizeActionBattleOptions(options = {}) {
46
48
  targeting: {
47
49
  ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
48
50
  ...options.targeting
51
+ },
52
+ animations: {
53
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
54
+ ...options.animations
49
55
  }
50
56
  };
51
57
  }
58
+ function setActionBattleOptions(options) {
59
+ currentActionBattleOptions = options;
60
+ }
61
+ function getActionBattleOptions() {
62
+ return currentActionBattleOptions;
63
+ }
52
64
  export {
53
65
  DEFAULT_ACTION_BATTLE_OPTIONS,
54
- normalizeActionBattleOptions
66
+ getActionBattleOptions,
67
+ normalizeActionBattleOptions,
68
+ setActionBattleOptions
55
69
  };
@@ -0,0 +1,64 @@
1
+ const DEFAULT_DIE_ANIMATION_DELAY_MS = 500;
2
+ const DEFAULT_ANIMATION_BY_KEY = {
3
+ attack: "attack",
4
+ hurt: "hurt",
5
+ die: "die",
6
+ castSkill: "skill"
7
+ };
8
+ function resolveActionBattleAnimation(key, entity, animations, context, defaults = {}) {
9
+ const defaultAnimationName = defaults.animationName ?? DEFAULT_ANIMATION_BY_KEY[key];
10
+ const defaultRepeat = defaults.repeat ?? 1;
11
+ const hasConfiguredAnimation = animations ? Object.prototype.hasOwnProperty.call(animations, key) : false;
12
+ if (!hasConfiguredAnimation && key !== "attack") {
13
+ return null;
14
+ }
15
+ const configured = hasConfiguredAnimation ? animations?.[key] : defaultAnimationName;
16
+ const result = typeof configured === "function" ? configured(entity, context) : configured;
17
+ if (result == null) return null;
18
+ if (typeof result === "string") {
19
+ return {
20
+ animationName: result,
21
+ repeat: defaultRepeat,
22
+ waitEnd: false
23
+ };
24
+ }
25
+ const animationName = result.animationName ?? defaultAnimationName;
26
+ return {
27
+ animationName,
28
+ graphic: result.graphic,
29
+ repeat: result.repeat ?? defaultRepeat,
30
+ waitEnd: result.waitEnd ?? false,
31
+ delayMs: result.delayMs
32
+ };
33
+ }
34
+ function playActionBattleAnimation(key, entity, animations, context, defaults = {}) {
35
+ const animation = resolveActionBattleAnimation(
36
+ key,
37
+ entity,
38
+ animations,
39
+ context,
40
+ defaults
41
+ );
42
+ if (!animation) return null;
43
+ if (animation.graphic !== void 0) {
44
+ entity.setGraphicAnimation(
45
+ animation.animationName,
46
+ animation.graphic,
47
+ animation.repeat
48
+ );
49
+ } else {
50
+ entity.setGraphicAnimation(animation.animationName, animation.repeat);
51
+ }
52
+ return animation;
53
+ }
54
+ function getActionBattleAnimationRemovalDelay(animation) {
55
+ if (!animation) return 0;
56
+ if (animation.delayMs !== void 0) return animation.delayMs;
57
+ return animation.waitEnd ? DEFAULT_DIE_ANIMATION_DELAY_MS : 0;
58
+ }
59
+ export {
60
+ DEFAULT_DIE_ANIMATION_DELAY_MS,
61
+ getActionBattleAnimationRemovalDelay,
62
+ playActionBattleAnimation,
63
+ resolveActionBattleAnimation
64
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgjs/action-battle",
3
- "version": "5.0.0-beta.1",
3
+ "version": "5.0.0-beta.2",
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.1",
27
- "@rpgjs/common": "5.0.0-beta.1",
28
- "@rpgjs/server": "5.0.0-beta.1",
29
- "@rpgjs/vite": "5.0.0-beta.1",
26
+ "@rpgjs/client": "5.0.0-beta.2",
27
+ "@rpgjs/common": "5.0.0-beta.2",
28
+ "@rpgjs/server": "5.0.0-beta.2",
29
+ "@rpgjs/vite": "5.0.0-beta.2",
30
30
  "canvasengine": "*"
31
31
  },
32
32
  "publishConfig": {
package/src/ai.server.ts CHANGED
@@ -1,9 +1,41 @@
1
1
  import { MAXHP, RpgEvent, RpgPlayer } from "@rpgjs/server";
2
+ import {
3
+ getActionBattleAnimationRemovalDelay,
4
+ playActionBattleAnimation,
5
+ } from "./animations";
6
+ import { getActionBattleOptions } from "./config";
7
+ import type { ActionBattleAnimationOptions } from "./types";
2
8
 
3
9
  type RpgEventWithBattleAi = RpgEvent & {
4
10
  battleAi: BattleAi;
5
11
  };
6
12
 
13
+ export interface BattleAiOptions {
14
+ enemyType?: EnemyType;
15
+ attackCooldown?: number;
16
+ visionRange?: number;
17
+ attackRange?: number;
18
+ dodgeChance?: number;
19
+ dodgeCooldown?: number;
20
+ fleeThreshold?: number;
21
+ attackSkill?: any;
22
+ attackPatterns?: AttackPattern[];
23
+ patrolWaypoints?: Array<{ x: number; y: number }>;
24
+ groupBehavior?: boolean;
25
+ moveToCooldown?: number;
26
+ retreatCooldown?: number;
27
+ behavior?: {
28
+ baseScore?: number;
29
+ updateInterval?: number;
30
+ minStateDuration?: number;
31
+ assaultThreshold?: number;
32
+ retreatThreshold?: number;
33
+ };
34
+ animations?: ActionBattleAnimationOptions;
35
+ /** Callback called when the AI is defeated */
36
+ onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
37
+ }
38
+
7
39
  /**
8
40
  * Hit result data returned after applying damage
9
41
  *
@@ -258,6 +290,7 @@ export class BattleAi {
258
290
  // Attack configuration
259
291
  private attackSkill: any | null; // Skill to use for attacks
260
292
  private attackPatterns: AttackPattern[];
293
+ private animations?: ActionBattleAnimationOptions;
261
294
  private comboCount: number = 0;
262
295
  private comboMax: number = 3;
263
296
  private chargingAttack: boolean = false;
@@ -327,30 +360,7 @@ export class BattleAi {
327
360
  */
328
361
  constructor(
329
362
  event: RpgEventWithBattleAi,
330
- options: {
331
- enemyType?: EnemyType;
332
- attackCooldown?: number;
333
- visionRange?: number;
334
- attackRange?: number;
335
- dodgeChance?: number;
336
- dodgeCooldown?: number;
337
- fleeThreshold?: number;
338
- attackSkill?: any;
339
- attackPatterns?: AttackPattern[];
340
- patrolWaypoints?: Array<{ x: number; y: number }>;
341
- groupBehavior?: boolean;
342
- moveToCooldown?: number;
343
- retreatCooldown?: number;
344
- behavior?: {
345
- baseScore?: number;
346
- updateInterval?: number;
347
- minStateDuration?: number;
348
- assaultThreshold?: number;
349
- retreatThreshold?: number;
350
- };
351
- /** Callback called when the AI is defeated */
352
- onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
353
- } = {}
363
+ options: BattleAiOptions = {}
354
364
  ) {
355
365
  event.battleAi = this;
356
366
  this.event = event;
@@ -361,6 +371,10 @@ export class BattleAi {
361
371
 
362
372
  // Store attack skill reference
363
373
  this.attackSkill = options.attackSkill || null;
374
+ this.animations = {
375
+ ...getActionBattleOptions().animations,
376
+ ...options.animations,
377
+ };
364
378
 
365
379
  // Initialize attack patterns
366
380
  this.attackPatterns = options.attackPatterns || [
@@ -867,11 +881,17 @@ export class BattleAi {
867
881
  if (!this.target) return;
868
882
 
869
883
  this.faceTarget();
870
- this.event.setGraphicAnimation('attack', 1);
884
+ playActionBattleAnimation("attack", this.event, this.animations, {
885
+ target: this.target,
886
+ });
871
887
 
872
888
  // Use skill if available
873
889
  if (this.attackSkill) {
874
890
  try {
891
+ playActionBattleAnimation("castSkill", this.event, this.animations, {
892
+ skill: this.attackSkill,
893
+ target: this.target,
894
+ });
875
895
  this.event.useSkill(this.attackSkill, this.target);
876
896
  } catch (e) {
877
897
  // Skill failed (no SP, etc.) - fall back to basic attack
@@ -1066,7 +1086,15 @@ export class BattleAi {
1066
1086
 
1067
1087
  this.chargingAttack = true;
1068
1088
  this.faceTarget();
1069
- this.event.setGraphicAnimation('attack', 2);
1089
+ playActionBattleAnimation(
1090
+ "attack",
1091
+ this.event,
1092
+ this.animations,
1093
+ {
1094
+ target: this.target,
1095
+ },
1096
+ { repeat: 2 }
1097
+ );
1070
1098
 
1071
1099
  setTimeout(() => {
1072
1100
  if (!this.target || this.state !== AiState.Combat) {
@@ -1077,6 +1105,10 @@ export class BattleAi {
1077
1105
  // Charged attacks can use a stronger skill or wider hitbox
1078
1106
  if (this.attackSkill) {
1079
1107
  try {
1108
+ playActionBattleAnimation("castSkill", this.event, this.animations, {
1109
+ skill: this.attackSkill,
1110
+ target: this.target,
1111
+ });
1080
1112
  this.event.useSkill(this.attackSkill, this.target);
1081
1113
  } catch (e) {
1082
1114
  this.performBasicHitbox();
@@ -1093,7 +1125,9 @@ export class BattleAi {
1093
1125
  * Perform zone attack (360 degrees)
1094
1126
  */
1095
1127
  private performZoneAttack() {
1096
- this.event.setGraphicAnimation('attack', 1);
1128
+ playActionBattleAnimation("attack", this.event, this.animations, {
1129
+ target: this.target ?? undefined,
1130
+ });
1097
1131
 
1098
1132
  const eventX = this.event.x();
1099
1133
  const eventY = this.event.y();
@@ -1439,6 +1473,9 @@ export class BattleAi {
1439
1473
  cycles: 1
1440
1474
  });
1441
1475
  this.event.showHit(`-${damage}`);
1476
+ playActionBattleAnimation("hurt", this.event, this.animations, {
1477
+ attacker,
1478
+ });
1442
1479
 
1443
1480
  // Track damage
1444
1481
  this.recentDamageTaken += damage;
@@ -1468,13 +1505,27 @@ export class BattleAi {
1468
1505
  * and removes the event from the map.
1469
1506
  */
1470
1507
  private kill(attacker?: RpgPlayer) {
1508
+ const dieAnimation = playActionBattleAnimation(
1509
+ "die",
1510
+ this.event,
1511
+ this.animations,
1512
+ {
1513
+ attacker,
1514
+ }
1515
+ );
1516
+ const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
1517
+
1471
1518
  // Call onDefeated hook before cleanup
1472
1519
  if (this.onDefeatedCallback) {
1473
1520
  this.onDefeatedCallback(this.event, attacker);
1474
1521
  }
1475
1522
 
1476
1523
  this.destroy();
1477
- this.event.remove();
1524
+ if (removeDelay > 0) {
1525
+ setTimeout(() => this.event.remove(), removeDelay);
1526
+ } else {
1527
+ this.event.remove();
1528
+ }
1478
1529
  }
1479
1530
 
1480
1531
  /**
@@ -0,0 +1,110 @@
1
+ import type {
2
+ ActionBattleAnimationContext,
3
+ ActionBattleAnimationEntity,
4
+ ActionBattleAnimationKey,
5
+ ActionBattleAnimationOptions,
6
+ } from "./types";
7
+
8
+ export const DEFAULT_DIE_ANIMATION_DELAY_MS = 500;
9
+
10
+ export interface ResolvedActionBattleAnimation {
11
+ animationName: string;
12
+ graphic?: string | string[];
13
+ repeat: number;
14
+ waitEnd: boolean;
15
+ delayMs?: number;
16
+ }
17
+
18
+ export interface ActionBattleAnimationDefaults {
19
+ animationName?: string;
20
+ repeat?: number;
21
+ }
22
+
23
+ const DEFAULT_ANIMATION_BY_KEY: Record<ActionBattleAnimationKey, string> = {
24
+ attack: "attack",
25
+ hurt: "hurt",
26
+ die: "die",
27
+ castSkill: "skill",
28
+ };
29
+
30
+ export function resolveActionBattleAnimation(
31
+ key: ActionBattleAnimationKey,
32
+ entity: ActionBattleAnimationEntity,
33
+ animations?: ActionBattleAnimationOptions,
34
+ context?: ActionBattleAnimationContext,
35
+ defaults: ActionBattleAnimationDefaults = {}
36
+ ): ResolvedActionBattleAnimation | null {
37
+ const defaultAnimationName =
38
+ defaults.animationName ?? DEFAULT_ANIMATION_BY_KEY[key];
39
+ const defaultRepeat = defaults.repeat ?? 1;
40
+ const hasConfiguredAnimation = animations
41
+ ? Object.prototype.hasOwnProperty.call(animations, key)
42
+ : false;
43
+ if (!hasConfiguredAnimation && key !== "attack") {
44
+ return null;
45
+ }
46
+
47
+ const configured = hasConfiguredAnimation
48
+ ? animations?.[key]
49
+ : defaultAnimationName;
50
+ const result =
51
+ typeof configured === "function"
52
+ ? configured(entity, context)
53
+ : configured;
54
+
55
+ if (result == null) return null;
56
+
57
+ if (typeof result === "string") {
58
+ return {
59
+ animationName: result,
60
+ repeat: defaultRepeat,
61
+ waitEnd: false,
62
+ };
63
+ }
64
+
65
+ const animationName = result.animationName ?? defaultAnimationName;
66
+ return {
67
+ animationName,
68
+ graphic: result.graphic,
69
+ repeat: result.repeat ?? defaultRepeat,
70
+ waitEnd: result.waitEnd ?? false,
71
+ delayMs: result.delayMs,
72
+ };
73
+ }
74
+
75
+ export function playActionBattleAnimation(
76
+ key: ActionBattleAnimationKey,
77
+ entity: ActionBattleAnimationEntity,
78
+ animations?: ActionBattleAnimationOptions,
79
+ context?: ActionBattleAnimationContext,
80
+ defaults: ActionBattleAnimationDefaults = {}
81
+ ): ResolvedActionBattleAnimation | null {
82
+ const animation = resolveActionBattleAnimation(
83
+ key,
84
+ entity,
85
+ animations,
86
+ context,
87
+ defaults
88
+ );
89
+ if (!animation) return null;
90
+
91
+ if (animation.graphic !== undefined) {
92
+ entity.setGraphicAnimation(
93
+ animation.animationName,
94
+ animation.graphic,
95
+ animation.repeat
96
+ );
97
+ } else {
98
+ entity.setGraphicAnimation(animation.animationName, animation.repeat);
99
+ }
100
+
101
+ return animation;
102
+ }
103
+
104
+ export function getActionBattleAnimationRemovalDelay(
105
+ animation: ResolvedActionBattleAnimation | null
106
+ ): number {
107
+ if (!animation) return 0;
108
+ if (animation.delayMs !== undefined) return animation.delayMs;
109
+ return animation.waitEnd ? DEFAULT_DIE_ANIMATION_DELAY_MS : 0;
110
+ }
package/src/config.ts CHANGED
@@ -24,8 +24,12 @@ export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = {
24
24
  affects: "events",
25
25
  allowEmptyTarget: true,
26
26
  },
27
+ animations: {},
27
28
  };
28
29
 
30
+ let currentActionBattleOptions: ActionBattleOptions =
31
+ DEFAULT_ACTION_BATTLE_OPTIONS;
32
+
29
33
  export function normalizeActionBattleOptions(
30
34
  options: ActionBattleOptions = {}
31
35
  ): ActionBattleOptions {
@@ -52,5 +56,17 @@ export function normalizeActionBattleOptions(
52
56
  ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
53
57
  ...options.targeting,
54
58
  },
59
+ animations: {
60
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
61
+ ...options.animations,
62
+ },
55
63
  };
56
64
  }
65
+
66
+ export function setActionBattleOptions(options: ActionBattleOptions) {
67
+ currentActionBattleOptions = options;
68
+ }
69
+
70
+ export function getActionBattleOptions(): ActionBattleOptions {
71
+ return currentActionBattleOptions;
72
+ }
package/src/index.ts CHANGED
@@ -7,8 +7,14 @@ 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 } from "./ai.server";
10
+ export type { HitResult, ApplyHitHooks, BattleAiOptions } from "./ai.server";
11
11
  export type {
12
+ ActionBattleAnimationContext,
13
+ ActionBattleAnimationEntity,
14
+ ActionBattleAnimationKey,
15
+ ActionBattleAnimationOptions,
16
+ ActionBattleAnimationResolver,
17
+ ActionBattleAnimationResult,
12
18
  ActionBattleOptions,
13
19
  ActionBattleActionBarData,
14
20
  ActionBattleActionBarItem,
package/src/server.ts CHANGED
@@ -6,8 +6,9 @@ import {
6
6
  ActionBattleActionBarSkill,
7
7
  ActionBattleOptions,
8
8
  } from "./types";
9
- import { normalizeActionBattleOptions } from "./config";
9
+ import { normalizeActionBattleOptions, setActionBattleOptions } from "./config";
10
10
  import { manhattanDistance, parseAoeMask } from "./targeting";
11
+ import { playActionBattleAnimation } from "./animations";
11
12
 
12
13
  export const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
13
14
 
@@ -330,13 +331,21 @@ const handleActionBattleSkillUse = (
330
331
  target: { x: number; y: number } | undefined,
331
332
  options: ActionBattleOptions
332
333
  ) => {
334
+ const skillData = resolveSkillData(player, skillId);
335
+
333
336
  const map = player.getCurrentMap();
334
337
  if (!map) {
338
+ playActionBattleAnimation("castSkill", player, options.animations, {
339
+ skill: skillData,
340
+ });
335
341
  player.useSkill(skillId);
336
342
  return;
337
343
  }
338
344
  const targeting = resolveSkillTargeting(player, skillId, options);
339
345
  if (!targeting || !target) {
346
+ playActionBattleAnimation("castSkill", player, options.animations, {
347
+ skill: skillData,
348
+ });
340
349
  player.useSkill(skillId);
341
350
  return;
342
351
  }
@@ -383,6 +392,10 @@ const handleActionBattleSkillUse = (
383
392
  return;
384
393
  }
385
394
 
395
+ playActionBattleAnimation("castSkill", player, options.animations, {
396
+ skill: skillData,
397
+ target: targets[0],
398
+ });
386
399
  player.useSkill(skillId, targets as any);
387
400
  };
388
401
 
@@ -390,6 +403,7 @@ export const createActionBattleServer = (
390
403
  rawOptions: ActionBattleOptions = {}
391
404
  ) => {
392
405
  const options = normalizeActionBattleOptions(rawOptions);
406
+ setActionBattleOptions(options);
393
407
  return defineModule<RpgServer>({
394
408
  player: {
395
409
  /**
@@ -405,8 +419,7 @@ export const createActionBattleServer = (
405
419
  */
406
420
  onInput(player: RpgPlayer, input: any) {
407
421
  if (input.action == Control.Action) {
408
- // Trigger attack animation
409
- player.setGraphicAnimation("attack", 1);
422
+ playActionBattleAnimation("attack", player, options.animations);
410
423
 
411
424
  // Get player position
412
425
  const playerX = player.x();
package/src/types.ts CHANGED
@@ -4,6 +4,52 @@ export type ActionBattleActionBarMode = "items" | "skills" | "both";
4
4
 
5
5
  export type ActionBattleTargetingAffects = "events" | "players" | "both";
6
6
 
7
+ export type ActionBattleAnimationKey =
8
+ | "attack"
9
+ | "hurt"
10
+ | "die"
11
+ | "castSkill";
12
+
13
+ export type ActionBattleAnimationResult =
14
+ | string
15
+ | {
16
+ animationName?: string;
17
+ graphic?: string | string[];
18
+ repeat?: number;
19
+ waitEnd?: boolean;
20
+ delayMs?: number;
21
+ }
22
+ | null
23
+ | undefined;
24
+
25
+ export type ActionBattleAnimationEntity = {
26
+ setGraphicAnimation(animationName: string, repeat: number): void;
27
+ setGraphicAnimation(
28
+ animationName: string,
29
+ graphic: string | string[],
30
+ repeat: number
31
+ ): void;
32
+ [key: string]: any;
33
+ };
34
+
35
+ export interface ActionBattleAnimationContext {
36
+ skill?: any;
37
+ attacker?: ActionBattleAnimationEntity;
38
+ target?: ActionBattleAnimationEntity;
39
+ }
40
+
41
+ export type ActionBattleAnimationResolver = (
42
+ entity: ActionBattleAnimationEntity,
43
+ context?: ActionBattleAnimationContext
44
+ ) => ActionBattleAnimationResult;
45
+
46
+ export type ActionBattleAnimationOptions = Partial<
47
+ Record<
48
+ ActionBattleAnimationKey,
49
+ ActionBattleAnimationResult | ActionBattleAnimationResolver
50
+ >
51
+ >;
52
+
7
53
  export interface ActionBattleSkillTargeting {
8
54
  range: number;
9
55
  aoeMask?: ActionBattleAoeMask;
@@ -49,6 +95,7 @@ export interface ActionBattleOptions {
49
95
  ui?: ActionBattleUiOptions;
50
96
  skills?: ActionBattleSkillOptions;
51
97
  targeting?: ActionBattleTargetingOptions;
98
+ animations?: ActionBattleAnimationOptions;
52
99
  }
53
100
 
54
101
  export interface ActionBattleActionBarItem {