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

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 (55) hide show
  1. package/README.md +137 -0
  2. package/dist/ai.server.d.ts +8 -1
  3. package/dist/client/index.js +19 -31
  4. package/dist/client/index10.js +142 -29
  5. package/dist/client/index11.js +25 -0
  6. package/dist/client/index12.js +1222 -0
  7. package/dist/client/index13.js +46 -0
  8. package/dist/client/index14.js +10 -0
  9. package/dist/client/index15.js +448 -0
  10. package/dist/client/index2.js +93 -46
  11. package/dist/client/index3.js +82 -1329
  12. package/dist/client/index4.js +305 -344
  13. package/dist/client/index5.js +36 -291
  14. package/dist/client/index6.js +99 -95
  15. package/dist/client/index7.js +78 -61
  16. package/dist/client/index8.js +57 -65
  17. package/dist/client/index9.js +97 -62
  18. package/dist/client.d.ts +3 -2
  19. package/dist/core/context.d.ts +5 -0
  20. package/dist/core/defaults.d.ts +81 -0
  21. package/dist/core/hit.d.ts +2 -0
  22. package/dist/enemies/factory.d.ts +7 -0
  23. package/dist/index.d.ts +9 -4
  24. package/dist/server/index.js +18 -31
  25. package/dist/server/index10.js +10 -0
  26. package/dist/server/index2.js +59 -345
  27. package/dist/server/index3.js +92 -1329
  28. package/dist/server/index4.js +141 -67
  29. package/dist/server/index5.js +24 -29
  30. package/dist/server/index6.js +1219 -62
  31. package/dist/server/index7.js +37 -0
  32. package/dist/server/index8.js +46 -0
  33. package/dist/server/index9.js +447 -0
  34. package/dist/server.d.ts +5 -3
  35. package/dist/ui/state.d.ts +20 -3
  36. package/package.json +5 -5
  37. package/src/ai.server.ts +91 -24
  38. package/src/animations.ts +43 -4
  39. package/src/canvas-engine-shim.ts +4 -0
  40. package/src/client.ts +122 -2
  41. package/src/components/action-bar.ce +5 -3
  42. package/src/components/attack-preview.ce +90 -0
  43. package/src/config.ts +30 -0
  44. package/src/core/context.ts +35 -0
  45. package/src/core/contracts.ts +123 -0
  46. package/src/core/defaults.ts +162 -0
  47. package/src/core/hit.spec.ts +58 -0
  48. package/src/core/hit.ts +66 -0
  49. package/src/enemies/factory.ts +25 -0
  50. package/src/index.ts +40 -0
  51. package/src/server.ts +235 -71
  52. package/src/targeting.spec.ts +24 -0
  53. package/src/types/canvas-engine.d.ts +4 -0
  54. package/src/types.ts +46 -1
  55. package/src/ui/state.ts +57 -0
package/src/ai.server.ts CHANGED
@@ -4,10 +4,12 @@ import {
4
4
  playActionBattleAnimation,
5
5
  } from "./animations";
6
6
  import { getActionBattleOptions } from "./config";
7
+ import { getActionBattleSystems } from "./core/context";
8
+ import type { ActionBattleDamageResult } from "./core/contracts";
7
9
  import type { ActionBattleAnimationOptions } from "./types";
8
10
 
9
11
  type RpgEventWithBattleAi = RpgEvent & {
10
- battleAi: BattleAi;
12
+ battleAi?: BattleAi;
11
13
  };
12
14
 
13
15
  export interface BattleAiOptions {
@@ -31,6 +33,7 @@ export interface BattleAiOptions {
31
33
  assaultThreshold?: number;
32
34
  retreatThreshold?: number;
33
35
  };
36
+ behaviorKey?: string;
34
37
  animations?: ActionBattleAnimationOptions;
35
38
  /** Callback called when the AI is defeated */
36
39
  onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
@@ -131,7 +134,9 @@ export interface ApplyHitHooks {
131
134
  */
132
135
  export const AiDebug = {
133
136
  /** Enable/disable all AI debug logs */
134
- enabled: (typeof process !== 'undefined' && process.env?.RPGJS_DEBUG_AI === '1') || false,
137
+ enabled:
138
+ ((globalThis as { process?: { env?: Record<string, string> } }).process
139
+ ?.env?.RPGJS_DEBUG_AI === "1") || false,
135
140
 
136
141
  /** Filter logs to a specific event ID (null = all events) */
137
142
  filterEventId: null as string | null,
@@ -275,17 +280,17 @@ export class BattleAi {
275
280
 
276
281
  // Enemy type and behavior
277
282
  private enemyType: EnemyType;
278
- private attackCooldown: number;
279
- private visionRange: number;
280
- private attackRange: number;
283
+ private attackCooldown: number = 1000;
284
+ private visionRange: number = 150;
285
+ private attackRange: number = 60;
281
286
 
282
287
  // Dodge system
283
- private dodgeChance: number;
284
- private dodgeCooldown: number;
288
+ private dodgeChance: number = 0.2;
289
+ private dodgeCooldown: number = 2000;
285
290
  private lastDodgeTime: number = 0;
286
291
 
287
292
  // Flee threshold (HP percentage)
288
- private fleeThreshold: number;
293
+ private fleeThreshold: number = 0.2;
289
294
 
290
295
  // Attack configuration
291
296
  private attackSkill: any | null; // Skill to use for attacks
@@ -333,6 +338,8 @@ export class BattleAi {
333
338
  private lastMoveToTime: number = 0;
334
339
  private retreatCooldown: number = 600;
335
340
  private lastRetreatTime: number = 0;
341
+ private timers: ReturnType<typeof setTimeout>[] = [];
342
+ private behaviorKey?: string;
336
343
 
337
344
  /**
338
345
  * Create a new Battle AI Controller
@@ -367,6 +374,7 @@ export class BattleAi {
367
374
 
368
375
  // Set enemy type and apply behavior modifiers
369
376
  this.enemyType = options.enemyType || EnemyType.Aggressive;
377
+ this.behaviorKey = options.behaviorKey ?? this.enemyType;
370
378
  this.applyEnemyTypeBehavior(options);
371
379
 
372
380
  // Store attack skill reference
@@ -423,7 +431,9 @@ export class BattleAi {
423
431
  // Setup AI systems
424
432
  this.setupVision();
425
433
  this.startAiBehaviorLoop();
426
- this.changeState(AiState.Idle);
434
+ if (this.patrolWaypoints.length > 0) {
435
+ this.startPatrol();
436
+ }
427
437
 
428
438
  this.debugLog('init', `AI created (type=${this.enemyType}, visionRange=${this.visionRange}, attackRange=${this.attackRange})`);
429
439
  }
@@ -530,6 +540,9 @@ export class BattleAi {
530
540
  * Change AI state with validated transitions
531
541
  */
532
542
  private changeState(newState: AiState) {
543
+ if (newState === this.state) {
544
+ return;
545
+ }
533
546
  const validTransitions: Record<AiState, AiState[]> = {
534
547
  [AiState.Idle]: [AiState.Alert, AiState.Combat],
535
548
  [AiState.Alert]: [AiState.Idle, AiState.Combat],
@@ -601,6 +614,8 @@ export class BattleAi {
601
614
  this.updateBehavior(currentTime);
602
615
  }
603
616
 
617
+ this.applyCustomBehavior(currentTime);
618
+
604
619
  // State-specific behavior
605
620
  switch (this.state) {
606
621
  case AiState.Idle:
@@ -634,6 +649,7 @@ export class BattleAi {
634
649
 
635
650
  if (distance < 10) {
636
651
  this.currentPatrolIndex = (this.currentPatrolIndex + 1) % this.patrolWaypoints.length;
652
+ this.startPatrol();
637
653
  }
638
654
  }
639
655
  }
@@ -690,9 +706,10 @@ export class BattleAi {
690
706
  // Try dodge
691
707
  if (this.canDodge() && this.shouldDodge()) {
692
708
  this.debugLog('combat', 'Attempting dodge');
693
- this.isMovingToTarget = false;
694
- this.tryDodge();
695
- return;
709
+ if (this.tryDodge()) {
710
+ this.isMovingToTarget = false;
711
+ return;
712
+ }
696
713
  }
697
714
 
698
715
  if (this.behaviorEnabled) {
@@ -1066,7 +1083,7 @@ export class BattleAi {
1066
1083
  this.performMeleeAttack();
1067
1084
 
1068
1085
  if (this.comboCount < this.comboMax) {
1069
- setTimeout(() => {
1086
+ this.schedule(() => {
1070
1087
  if (this.target && this.state === AiState.Combat) {
1071
1088
  this.performComboAttack();
1072
1089
  } else {
@@ -1096,7 +1113,7 @@ export class BattleAi {
1096
1113
  { repeat: 2 }
1097
1114
  );
1098
1115
 
1099
- setTimeout(() => {
1116
+ this.schedule(() => {
1100
1117
  if (!this.target || this.state !== AiState.Combat) {
1101
1118
  this.chargingAttack = false;
1102
1119
  return;
@@ -1176,7 +1193,7 @@ export class BattleAi {
1176
1193
  this.faceTarget();
1177
1194
  this.event.dash({ x: dirX, y: dirY }, 10, 200);
1178
1195
 
1179
- setTimeout(() => {
1196
+ this.schedule(() => {
1180
1197
  if (!this.target || this.state !== AiState.Combat) return;
1181
1198
  this.performMeleeAttack();
1182
1199
  }, 200);
@@ -1241,24 +1258,24 @@ export class BattleAi {
1241
1258
  /**
1242
1259
  * Try to dodge
1243
1260
  */
1244
- private tryDodge() {
1261
+ private tryDodge(): boolean {
1245
1262
  const currentTime = Date.now();
1246
1263
 
1247
1264
  if (currentTime - this.lastDodgeTime < this.dodgeCooldown) {
1248
1265
  this.debugLog('dodge', `Dodge on cooldown (${this.dodgeCooldown - (currentTime - this.lastDodgeTime)}ms remaining)`);
1249
- return;
1266
+ return false;
1250
1267
  }
1251
1268
  if (Math.random() > this.dodgeChance) {
1252
1269
  this.debugLog('dodge', `Dodge roll failed (chance=${(this.dodgeChance * 100).toFixed(0)}%)`);
1253
- return;
1270
+ return false;
1254
1271
  }
1255
- if (!this.target) return;
1272
+ if (!this.target) return false;
1256
1273
 
1257
1274
  const dx = this.target.x() - this.event.x();
1258
1275
  const dy = this.target.y() - this.event.y();
1259
1276
  const dist = Math.sqrt(dx * dx + dy * dy);
1260
1277
 
1261
- if (dist === 0) return;
1278
+ if (dist === 0) return false;
1262
1279
 
1263
1280
  // Perpendicular direction
1264
1281
  const dodgeDirX = -dy / dist;
@@ -1272,12 +1289,13 @@ export class BattleAi {
1272
1289
  // Counter-attack for defensive types
1273
1290
  if (this.enemyType === EnemyType.Defensive && Math.random() < 0.5) {
1274
1291
  this.debugLog('dodge', 'Counter-attack after dodge');
1275
- setTimeout(() => {
1292
+ this.schedule(() => {
1276
1293
  if (this.target && this.state === AiState.Combat) {
1277
1294
  this.selectAndPerformAttack();
1278
1295
  }
1279
1296
  }, 400);
1280
1297
  }
1298
+ return true;
1281
1299
  }
1282
1300
 
1283
1301
  private canDodge(): boolean {
@@ -1461,8 +1479,16 @@ export class BattleAi {
1461
1479
  */
1462
1480
  takeDamage(attacker: RpgPlayer): boolean {
1463
1481
  // Apply damage using RPGJS system
1464
- const { damage } = this.event.applyDamage(attacker);
1482
+ const raw = this.event.applyDamage(attacker);
1483
+ return this.handleDamage(attacker, {
1484
+ damage: raw.damage ?? 0,
1485
+ defeated: this.event.hp <= 0,
1486
+ raw,
1487
+ });
1488
+ }
1465
1489
 
1490
+ handleDamage(attacker: RpgPlayer, damageResult: ActionBattleDamageResult): boolean {
1491
+ const damage = damageResult.damage;
1466
1492
  this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
1467
1493
 
1468
1494
  // Visual feedback
@@ -1489,7 +1515,7 @@ export class BattleAi {
1489
1515
  }
1490
1516
 
1491
1517
  // Check death
1492
- if (this.event.hp <= 0) {
1518
+ if (damageResult.defeated || this.event.hp <= 0) {
1493
1519
  this.debugLog('damage', 'Defeated!');
1494
1520
  this.kill(attacker);
1495
1521
  return true;
@@ -1522,7 +1548,7 @@ export class BattleAi {
1522
1548
 
1523
1549
  this.destroy();
1524
1550
  if (removeDelay > 0) {
1525
- setTimeout(() => this.event.remove(), removeDelay);
1551
+ this.schedule(() => this.event.remove(), removeDelay);
1526
1552
  } else {
1527
1553
  this.event.remove();
1528
1554
  }
@@ -1595,6 +1621,36 @@ export class BattleAi {
1595
1621
  }
1596
1622
  }
1597
1623
 
1624
+ private applyCustomBehavior(currentTime: number) {
1625
+ if (!this.behaviorKey) return;
1626
+ const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
1627
+ if (!behavior) return;
1628
+ const maxHp = this.event.param[MAXHP];
1629
+ const decision = behavior({
1630
+ event: this.event,
1631
+ target: this.target,
1632
+ state: this.state,
1633
+ enemyType: this.enemyType,
1634
+ distance: this.target ? this.getDistance(this.event, this.target) : null,
1635
+ hpPercent: maxHp ? this.event.hp / maxHp : null,
1636
+ now: currentTime,
1637
+ });
1638
+ if (!decision) return;
1639
+ if (decision.attackCooldown !== undefined) {
1640
+ this.attackCooldown = decision.attackCooldown;
1641
+ }
1642
+ if (decision.moveToCooldown !== undefined) {
1643
+ this.moveToCooldown = decision.moveToCooldown;
1644
+ }
1645
+ if (decision.attackPatterns?.length) {
1646
+ this.attackPatterns = decision.attackPatterns;
1647
+ }
1648
+ if (decision.mode) {
1649
+ this.behaviorMode = decision.mode;
1650
+ this.behaviorEnabled = true;
1651
+ }
1652
+ }
1653
+
1598
1654
  private handleTacticalMovement(distance: number) {
1599
1655
  if (!this.target) return;
1600
1656
  const minRange = this.attackRange * 0.7;
@@ -1651,6 +1707,15 @@ export class BattleAi {
1651
1707
  return true;
1652
1708
  }
1653
1709
 
1710
+ private schedule(callback: () => void, delay: number) {
1711
+ const timer = setTimeout(() => {
1712
+ this.timers = this.timers.filter((entry) => entry !== timer);
1713
+ callback();
1714
+ }, delay);
1715
+ this.timers.push(timer);
1716
+ return timer;
1717
+ }
1718
+
1654
1719
  // Public getters
1655
1720
  getHealth(): number { return this.event.hp; }
1656
1721
  getMaxHealth(): number { return this.event.param[MAXHP]; }
@@ -1668,5 +1733,7 @@ export class BattleAi {
1668
1733
  }
1669
1734
  this.target = null;
1670
1735
  this.nearbyEnemies = [];
1736
+ this.timers.forEach((timer) => clearTimeout(timer));
1737
+ this.timers = [];
1671
1738
  }
1672
1739
  }
package/src/animations.ts CHANGED
@@ -25,6 +25,46 @@ const DEFAULT_ANIMATION_BY_KEY: Record<ActionBattleAnimationKey, string> = {
25
25
  hurt: "hurt",
26
26
  die: "die",
27
27
  castSkill: "skill",
28
+ castSpell: "skill",
29
+ };
30
+
31
+ const getConfiguredAnimation = (
32
+ key: ActionBattleAnimationKey,
33
+ animations?: ActionBattleAnimationOptions,
34
+ ) => {
35
+ if (!animations) {
36
+ return {
37
+ hasConfiguredAnimation: false,
38
+ configured: undefined,
39
+ };
40
+ }
41
+
42
+ const hasConfiguredAnimation = Object.prototype.hasOwnProperty.call(
43
+ animations,
44
+ key,
45
+ );
46
+ if (hasConfiguredAnimation) {
47
+ return {
48
+ hasConfiguredAnimation,
49
+ configured: animations[key],
50
+ };
51
+ }
52
+
53
+ if (key === "castSkill") {
54
+ const hasCastSpellAlias = Object.prototype.hasOwnProperty.call(
55
+ animations,
56
+ "castSpell",
57
+ );
58
+ return {
59
+ hasConfiguredAnimation: hasCastSpellAlias,
60
+ configured: animations.castSpell,
61
+ };
62
+ }
63
+
64
+ return {
65
+ hasConfiguredAnimation: false,
66
+ configured: undefined,
67
+ };
28
68
  };
29
69
 
30
70
  export function resolveActionBattleAnimation(
@@ -37,15 +77,14 @@ export function resolveActionBattleAnimation(
37
77
  const defaultAnimationName =
38
78
  defaults.animationName ?? DEFAULT_ANIMATION_BY_KEY[key];
39
79
  const defaultRepeat = defaults.repeat ?? 1;
40
- const hasConfiguredAnimation = animations
41
- ? Object.prototype.hasOwnProperty.call(animations, key)
42
- : false;
80
+ const { hasConfiguredAnimation, configured: configuredAnimation } =
81
+ getConfiguredAnimation(key, animations);
43
82
  if (!hasConfiguredAnimation && key !== "attack") {
44
83
  return null;
45
84
  }
46
85
 
47
86
  const configured = hasConfiguredAnimation
48
- ? animations?.[key]
87
+ ? configuredAnimation
49
88
  : defaultAnimationName;
50
89
  const result =
51
90
  typeof configured === "function"
@@ -0,0 +1,4 @@
1
+ declare module "*.ce" {
2
+ const component: any;
3
+ export default component;
4
+ }
package/src/client.ts CHANGED
@@ -1,10 +1,109 @@
1
1
  import { inject, PrebuiltComponentAnimations, RpgClient, RpgClientEngine, RpgGui } from "@rpgjs/client";
2
2
  import { defineModule } from "@rpgjs/common";
3
+ // @ts-ignore CanvasEngine components are compiled by @canvasengine/compiler.
3
4
  import ActionBarComponent from "./components/action-bar.ce";
5
+ // @ts-ignore CanvasEngine components are compiled by @canvasengine/compiler.
4
6
  import TargetingOverlayComponent from "./components/targeting-overlay.ce";
5
- import { setActionBattleOptions } from "./ui/state";
7
+ // @ts-ignore CanvasEngine components are compiled by @canvasengine/compiler.
8
+ import AttackPreviewComponent from "./components/attack-preview.ce";
9
+ import {
10
+ setActionBattleOptions,
11
+ startAttackPreview,
12
+ stopAttackPreview,
13
+ } from "./ui/state";
6
14
  import { ActionBattleOptions } from "./types";
7
15
  import { normalizeActionBattleOptions } from "./config";
16
+ import { resolveActionBattleAnimation } from "./animations";
17
+
18
+ const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
19
+
20
+ const beginLocalPlayerAttackLock = (
21
+ engine: RpgClientEngine,
22
+ durationMs: number
23
+ ): boolean => {
24
+ if (durationMs <= 0) return true;
25
+
26
+ const player = engine.scene?.getCurrentPlayer?.() as any;
27
+ if (!player) return true;
28
+
29
+ const runtimePlayer = player as any;
30
+ const now = Date.now();
31
+ if (
32
+ typeof runtimePlayer.__actionBattleAttackLockedUntil === "number" &&
33
+ runtimePlayer.__actionBattleAttackLockedUntil > now
34
+ ) {
35
+ return false;
36
+ }
37
+
38
+ const lockId = (runtimePlayer.__actionBattleAttackLockId ?? 0) + 1;
39
+ runtimePlayer.__actionBattleAttackLockId = lockId;
40
+ runtimePlayer.__actionBattleAttackLockedUntil = now + durationMs;
41
+
42
+ const previousCanMove =
43
+ typeof player.canMove === "function" ? player.canMove() : true;
44
+ const previousDirectionFixed = player.directionFixed;
45
+ const previousAnimationFixed = player.animationFixed;
46
+
47
+ if (typeof engine.interruptCurrentPlayerMovement === "function") {
48
+ engine.interruptCurrentPlayerMovement(player);
49
+ } else {
50
+ (engine.scene as any)?.stopMovement?.(player);
51
+ }
52
+ player.canMove.set(false);
53
+ player.directionFixed = true;
54
+ player.animationFixed = true;
55
+
56
+ setTimeout(() => {
57
+ if (runtimePlayer.__actionBattleAttackLockId !== lockId) return;
58
+ runtimePlayer.__actionBattleAttackLockedUntil = 0;
59
+ player.canMove.set(previousCanMove);
60
+ player.directionFixed = previousDirectionFixed;
61
+ player.animationFixed = previousAnimationFixed;
62
+ }, durationMs);
63
+
64
+ return true;
65
+ };
66
+
67
+ const resolveLocalPlayerDirection = (player: any) => {
68
+ if (typeof player.getDirection === "function") return player.getDirection();
69
+ if (typeof player.direction === "function") return player.direction();
70
+ return player.direction ?? "down";
71
+ };
72
+
73
+ const playLocalPlayerAttackAnimation = (
74
+ player: any,
75
+ options: ActionBattleOptions
76
+ ) => {
77
+ if (!player || typeof player.setAnimation !== "function") return;
78
+ const animation = resolveActionBattleAnimation(
79
+ "attack",
80
+ player,
81
+ options.animations
82
+ );
83
+ if (!animation) return;
84
+
85
+ if (animation.graphic !== undefined) {
86
+ player.setAnimation(
87
+ animation.animationName,
88
+ animation.graphic,
89
+ animation.repeat
90
+ );
91
+ return;
92
+ }
93
+ player.setAnimation(animation.animationName, animation.repeat);
94
+ };
95
+
96
+ const showLocalAttackPreview = (player: any, options: ActionBattleOptions) => {
97
+ if (!player || options.attack?.showPreview === false) return;
98
+ const durationMs = Math.max(1, options.attack?.previewDurationMs ?? 180);
99
+ const previewId = startAttackPreview({
100
+ direction: resolveLocalPlayerDirection(player),
101
+ durationMs,
102
+ color: options.attack?.previewColor,
103
+ accentColor: options.attack?.previewAccentColor,
104
+ });
105
+ setTimeout(() => stopAttackPreview(previewId), durationMs);
106
+ };
8
107
 
9
108
  export const createActionBattleClient = (
10
109
  options: ActionBattleOptions = {}
@@ -13,6 +112,10 @@ export const createActionBattleClient = (
13
112
  setActionBattleOptions(normalized);
14
113
  const actionBarEnabled = normalized.ui?.actionBar?.enabled;
15
114
  const targetingEnabled = normalized.ui?.targeting?.enabled;
115
+ const componentsInFront = [
116
+ ...(targetingEnabled ? [TargetingOverlayComponent] : []),
117
+ AttackPreviewComponent,
118
+ ];
16
119
  const hitComponent = PrebuiltComponentAnimations?.Hit;
17
120
  return defineModule<RpgClient>({
18
121
  componentAnimations: hitComponent
@@ -36,7 +139,7 @@ export const createActionBattleClient = (
36
139
  ]
37
140
  : [],
38
141
  sprite: {
39
- componentsInFront: targetingEnabled ? [TargetingOverlayComponent] : [],
142
+ componentsInFront,
40
143
  },
41
144
  sceneMap: {
42
145
  onAfterLoading() {
@@ -45,6 +148,23 @@ export const createActionBattleClient = (
45
148
  gui.display('action-battle-action-bar')
46
149
  }
47
150
  }
151
+ },
152
+ engine: {
153
+ onInput(engine: RpgClientEngine, { input }: { input: string }) {
154
+ if (input !== "action") return;
155
+ const player = engine.scene?.getCurrentPlayer?.() as any;
156
+ if (!player) return;
157
+ const lockDurationMs = Math.max(
158
+ 0,
159
+ normalized.attack?.lockDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS
160
+ );
161
+ beginLocalPlayerAttackLock(
162
+ engine,
163
+ normalized.attack?.lockMovement === false ? 0 : lockDurationMs
164
+ );
165
+ playLocalPlayerAttackAnimation(player, normalized);
166
+ showLocalAttackPreview(player, normalized);
167
+ },
48
168
  }
49
169
  });
50
170
  };
@@ -72,7 +72,6 @@
72
72
  const engine = inject(RpgClientEngine);
73
73
  const keyboardControls = engine.globalConfig.keyboardControls;
74
74
  const { data, onInteraction, onBack } = defineProps();
75
- const currentPlayer = engine.getCurrentPlayer();
76
75
  const ACTION_BAR_SIZE = 10;
77
76
  const SLOT_LABELS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
78
77
  const SLOT_CONFIG_KEYS = [
@@ -107,10 +106,13 @@
107
106
  playing: "default"
108
107
  });
109
108
 
109
+ const resolveProp = (value) => typeof value === "function" ? value() : value;
110
+ const actionBarData = computed(() => resolveProp(data) || { items: [], skills: [] });
111
+
110
112
  const actionBarSlots = computed(() => {
111
113
  const entries = [];
112
114
  if (showSkills()) {
113
- currentPlayer.skills().forEach((skill, index) => {
115
+ (actionBarData().skills || []).forEach((skill, index) => {
114
116
  entries.push({
115
117
  type: "skill",
116
118
  skill,
@@ -120,7 +122,7 @@
120
122
  });
121
123
  }
122
124
  if (showItems()) {
123
- currentPlayer.items().forEach((item, index) => {
125
+ (actionBarData().items || []).forEach((item, index) => {
124
126
  entries.push({
125
127
  type: "item",
126
128
  skill: null,
@@ -0,0 +1,90 @@
1
+ <Container>
2
+ @if (shouldRender) {
3
+ <Graphics draw={drawSlash} />
4
+ }
5
+ </Container>
6
+
7
+ <script>
8
+ import { computed, signal, tick } from "canvasengine";
9
+ import { inject, RpgClientEngine } from "@rpgjs/client";
10
+ import { actionBattleAttackPreviewState } from "../ui/state";
11
+
12
+ const { object } = defineProps();
13
+ const engine = inject(RpgClientEngine);
14
+ const now = signal(Date.now());
15
+
16
+ tick(() => {
17
+ if (actionBattleAttackPreviewState().active) {
18
+ now.set(Date.now());
19
+ }
20
+ });
21
+
22
+ const isCurrentPlayer = computed(() => {
23
+ if (!object?.id) return false;
24
+ const idValue = typeof object.id === "function" ? object.id() : object.id;
25
+ return idValue === engine.playerId;
26
+ });
27
+
28
+ const preview = computed(() => actionBattleAttackPreviewState());
29
+ const progress = computed(() => {
30
+ const state = preview();
31
+ if (!state.active) return 1;
32
+ const elapsed = now() - state.startedAt;
33
+ return Math.max(0, Math.min(1, elapsed / state.durationMs));
34
+ });
35
+
36
+ const shouldRender = computed(() => {
37
+ const state = preview();
38
+ return isCurrentPlayer() && state.active && progress() < 1;
39
+ });
40
+
41
+ const getHitbox = () => object.hitbox?.() || { w: 32, h: 32 };
42
+
43
+ const drawRect = (g, x, y, width, height, color, alpha) => {
44
+ g.rect(x, y, width, height);
45
+ g.fill({ color, alpha });
46
+ };
47
+
48
+ const drawSlash = (g) => {
49
+ g.clear();
50
+ if (!shouldRender()) return;
51
+
52
+ const state = preview();
53
+ const p = progress();
54
+ const alpha = Math.sin(Math.PI * p);
55
+ if (alpha <= 0) return;
56
+
57
+ const hitbox = getHitbox();
58
+ const width = hitbox.w || 32;
59
+ const height = hitbox.h || 32;
60
+ const reach = 16 + 18 * p;
61
+ const thickness = 4 + 3 * (1 - p);
62
+ const color = state.color;
63
+ const accent = state.accentColor;
64
+
65
+ if (state.direction === "left") {
66
+ drawRect(g, -reach - 6, height * 0.24, reach, thickness, accent, alpha * 0.55);
67
+ drawRect(g, -reach - 10, height * 0.46, reach + 4, thickness + 2, color, alpha);
68
+ drawRect(g, -reach - 6, height * 0.70, reach, thickness, accent, alpha * 0.4);
69
+ return;
70
+ }
71
+
72
+ if (state.direction === "right") {
73
+ drawRect(g, width + 6, height * 0.24, reach, thickness, accent, alpha * 0.55);
74
+ drawRect(g, width + 6, height * 0.46, reach + 4, thickness + 2, color, alpha);
75
+ drawRect(g, width + 6, height * 0.70, reach, thickness, accent, alpha * 0.4);
76
+ return;
77
+ }
78
+
79
+ if (state.direction === "up") {
80
+ drawRect(g, width * 0.24, -reach - 6, thickness, reach, accent, alpha * 0.55);
81
+ drawRect(g, width * 0.46, -reach - 10, thickness + 2, reach + 4, color, alpha);
82
+ drawRect(g, width * 0.70, -reach - 6, thickness, reach, accent, alpha * 0.4);
83
+ return;
84
+ }
85
+
86
+ drawRect(g, width * 0.24, height + 6, thickness, reach, accent, alpha * 0.55);
87
+ drawRect(g, width * 0.46, height + 6, thickness + 2, reach + 4, color, alpha);
88
+ drawRect(g, width * 0.70, height + 6, thickness, reach, accent, alpha * 0.4);
89
+ };
90
+ </script>
package/src/config.ts CHANGED
@@ -24,6 +24,14 @@ export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = {
24
24
  affects: "events",
25
25
  allowEmptyTarget: true,
26
26
  },
27
+ attack: {
28
+ lockMovement: true,
29
+ lockDurationMs: 350,
30
+ showPreview: true,
31
+ previewDurationMs: 180,
32
+ previewColor: 0xfff3b0,
33
+ previewAccentColor: 0xffffff,
34
+ },
27
35
  animations: {},
28
36
  };
29
37
 
@@ -56,10 +64,32 @@ export function normalizeActionBattleOptions(
56
64
  ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
57
65
  ...options.targeting,
58
66
  },
67
+ attack: {
68
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.attack,
69
+ ...options.attack,
70
+ },
59
71
  animations: {
60
72
  ...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
61
73
  ...options.animations,
62
74
  },
75
+ systems: {
76
+ combat: {
77
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.combat,
78
+ ...options.systems?.combat,
79
+ hooks: {
80
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.combat?.hooks,
81
+ ...options.systems?.combat?.hooks,
82
+ },
83
+ },
84
+ ai: {
85
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai,
86
+ ...options.systems?.ai,
87
+ behaviors: {
88
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai?.behaviors,
89
+ ...options.systems?.ai?.behaviors,
90
+ },
91
+ },
92
+ },
63
93
  };
64
94
  }
65
95