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

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 (103) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +19 -0
  3. package/README.md +392 -22
  4. package/dist/{ai.server.d.ts → client/ai.server.d.ts} +90 -28
  5. package/dist/client/animations.d.ts +16 -0
  6. package/dist/{client.d.ts → client/client.d.ts} +3 -2
  7. package/dist/{config.d.ts → client/config.d.ts} +2 -0
  8. package/dist/client/core/attack-profile.d.ts +9 -0
  9. package/dist/client/core/attack-runtime.d.ts +20 -0
  10. package/dist/client/core/context.d.ts +5 -0
  11. package/dist/client/core/defaults.d.ts +81 -0
  12. package/dist/client/core/enemy-attack-profiles.d.ts +6 -0
  13. package/dist/client/core/equipment.d.ts +2 -0
  14. package/dist/client/core/hit-reaction.d.ts +5 -0
  15. package/dist/client/core/hit.d.ts +2 -0
  16. package/dist/client/enemies/factory.d.ts +7 -0
  17. package/dist/client/index.d.ts +21 -0
  18. package/dist/client/index.js +24 -31
  19. package/dist/client/index10.js +61 -0
  20. package/dist/client/index11.js +55 -0
  21. package/dist/client/index12.js +106 -0
  22. package/dist/client/index13.js +143 -0
  23. package/dist/client/index14.js +25 -0
  24. package/dist/client/index15.js +72 -0
  25. package/dist/client/index16.js +1343 -0
  26. package/dist/client/index17.js +13 -0
  27. package/dist/client/index18.js +60 -0
  28. package/dist/client/index19.js +10 -0
  29. package/dist/client/index2.js +30 -45
  30. package/dist/client/index20.js +504 -0
  31. package/dist/client/index3.js +45 -1288
  32. package/dist/client/index4.js +105 -330
  33. package/dist/client/index5.js +84 -291
  34. package/dist/client/index6.js +309 -95
  35. package/dist/client/index7.js +35 -59
  36. package/dist/client/index8.js +101 -54
  37. package/dist/client/index9.js +79 -30
  38. package/dist/{server.d.ts → client/server.d.ts} +12 -4
  39. package/dist/client/ui/state.d.ts +35 -0
  40. package/dist/server/ai.server.d.ts +569 -0
  41. package/dist/server/animations.d.ts +16 -0
  42. package/dist/server/config.d.ts +5 -0
  43. package/dist/server/core/attack-profile.d.ts +9 -0
  44. package/dist/server/core/attack-runtime.d.ts +20 -0
  45. package/dist/server/core/context.d.ts +5 -0
  46. package/dist/server/core/defaults.d.ts +81 -0
  47. package/dist/server/core/enemy-attack-profiles.d.ts +6 -0
  48. package/dist/server/core/equipment.d.ts +2 -0
  49. package/dist/server/core/hit-reaction.d.ts +5 -0
  50. package/dist/server/core/hit.d.ts +2 -0
  51. package/dist/server/enemies/factory.d.ts +7 -0
  52. package/dist/server/index.d.ts +21 -0
  53. package/dist/server/index.js +23 -31
  54. package/dist/server/index10.js +1342 -0
  55. package/dist/server/index11.js +37 -0
  56. package/dist/server/index12.js +60 -0
  57. package/dist/server/index13.js +13 -0
  58. package/dist/server/index14.js +503 -0
  59. package/dist/server/index15.js +10 -0
  60. package/dist/server/index2.js +59 -332
  61. package/dist/server/index3.js +29 -1286
  62. package/dist/server/index4.js +45 -53
  63. package/dist/server/index5.js +107 -29
  64. package/dist/server/index6.js +143 -0
  65. package/dist/server/index7.js +25 -0
  66. package/dist/server/index8.js +72 -0
  67. package/dist/server/index9.js +55 -0
  68. package/dist/server/server.d.ts +106 -0
  69. package/dist/server/targeting.d.ts +19 -0
  70. package/package.json +12 -12
  71. package/src/ai.server.spec.ts +120 -0
  72. package/src/ai.server.ts +515 -91
  73. package/src/animations.ts +149 -0
  74. package/src/canvas-engine-shim.ts +4 -0
  75. package/src/client.ts +130 -2
  76. package/src/components/action-bar.ce +5 -3
  77. package/src/components/attack-preview.ce +90 -0
  78. package/src/config.ts +61 -0
  79. package/src/core/attack-profile.spec.ts +118 -0
  80. package/src/core/attack-profile.ts +100 -0
  81. package/src/core/attack-runtime.spec.ts +103 -0
  82. package/src/core/attack-runtime.ts +83 -0
  83. package/src/core/context.ts +35 -0
  84. package/src/core/contracts.ts +126 -0
  85. package/src/core/defaults.ts +162 -0
  86. package/src/core/enemy-attack-profiles.spec.ts +35 -0
  87. package/src/core/enemy-attack-profiles.ts +103 -0
  88. package/src/core/equipment.spec.ts +37 -0
  89. package/src/core/equipment.ts +17 -0
  90. package/src/core/hit-reaction.spec.ts +43 -0
  91. package/src/core/hit-reaction.ts +70 -0
  92. package/src/core/hit.spec.ts +111 -0
  93. package/src/core/hit.ts +92 -0
  94. package/src/enemies/factory.ts +25 -0
  95. package/src/index.ts +94 -1
  96. package/src/server.ts +427 -93
  97. package/src/targeting.spec.ts +24 -0
  98. package/src/types/canvas-engine.d.ts +4 -0
  99. package/src/types.ts +148 -0
  100. package/src/ui/state.ts +57 -0
  101. package/dist/index.d.ts +0 -11
  102. package/dist/ui/state.d.ts +0 -18
  103. /package/dist/{targeting.d.ts → client/targeting.d.ts} +0 -0
@@ -0,0 +1,149 @@
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
+ 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
+ };
68
+ };
69
+
70
+ export function resolveActionBattleAnimation(
71
+ key: ActionBattleAnimationKey,
72
+ entity: ActionBattleAnimationEntity,
73
+ animations?: ActionBattleAnimationOptions,
74
+ context?: ActionBattleAnimationContext,
75
+ defaults: ActionBattleAnimationDefaults = {}
76
+ ): ResolvedActionBattleAnimation | null {
77
+ const defaultAnimationName =
78
+ defaults.animationName ?? DEFAULT_ANIMATION_BY_KEY[key];
79
+ const defaultRepeat = defaults.repeat ?? 1;
80
+ const { hasConfiguredAnimation, configured: configuredAnimation } =
81
+ getConfiguredAnimation(key, animations);
82
+ if (!hasConfiguredAnimation && key !== "attack") {
83
+ return null;
84
+ }
85
+
86
+ const configured = hasConfiguredAnimation
87
+ ? configuredAnimation
88
+ : defaultAnimationName;
89
+ const result =
90
+ typeof configured === "function"
91
+ ? configured(entity, context)
92
+ : configured;
93
+
94
+ if (result == null) return null;
95
+
96
+ if (typeof result === "string") {
97
+ return {
98
+ animationName: result,
99
+ repeat: defaultRepeat,
100
+ waitEnd: false,
101
+ };
102
+ }
103
+
104
+ const animationName = result.animationName ?? defaultAnimationName;
105
+ return {
106
+ animationName,
107
+ graphic: result.graphic,
108
+ repeat: result.repeat ?? defaultRepeat,
109
+ waitEnd: result.waitEnd ?? false,
110
+ delayMs: result.delayMs,
111
+ };
112
+ }
113
+
114
+ export function playActionBattleAnimation(
115
+ key: ActionBattleAnimationKey,
116
+ entity: ActionBattleAnimationEntity,
117
+ animations?: ActionBattleAnimationOptions,
118
+ context?: ActionBattleAnimationContext,
119
+ defaults: ActionBattleAnimationDefaults = {}
120
+ ): ResolvedActionBattleAnimation | null {
121
+ const animation = resolveActionBattleAnimation(
122
+ key,
123
+ entity,
124
+ animations,
125
+ context,
126
+ defaults
127
+ );
128
+ if (!animation) return null;
129
+
130
+ if (animation.graphic !== undefined) {
131
+ entity.setGraphicAnimation(
132
+ animation.animationName,
133
+ animation.graphic,
134
+ animation.repeat
135
+ );
136
+ } else {
137
+ entity.setGraphicAnimation(animation.animationName, animation.repeat);
138
+ }
139
+
140
+ return animation;
141
+ }
142
+
143
+ export function getActionBattleAnimationRemovalDelay(
144
+ animation: ResolvedActionBattleAnimation | null
145
+ ): number {
146
+ if (!animation) return 0;
147
+ if (animation.delayMs !== undefined) return animation.delayMs;
148
+ return animation.waitEnd ? DEFAULT_DIE_ANIMATION_DELAY_MS : 0;
149
+ }
@@ -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,114 @@
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
+ import { getNormalizedActionBattleAttackProfile } from "./core/attack-runtime";
18
+
19
+ const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
20
+
21
+ const beginLocalPlayerAttackLock = (
22
+ engine: RpgClientEngine,
23
+ durationMs: number,
24
+ locks: { movement: boolean; direction: boolean }
25
+ ): boolean => {
26
+ if (durationMs <= 0) return true;
27
+
28
+ const player = engine.scene?.getCurrentPlayer?.() as any;
29
+ if (!player) return true;
30
+
31
+ const runtimePlayer = player as any;
32
+ const now = Date.now();
33
+ if (
34
+ typeof runtimePlayer.__actionBattleAttackLockedUntil === "number" &&
35
+ runtimePlayer.__actionBattleAttackLockedUntil > now
36
+ ) {
37
+ return false;
38
+ }
39
+
40
+ const lockId = (runtimePlayer.__actionBattleAttackLockId ?? 0) + 1;
41
+ runtimePlayer.__actionBattleAttackLockId = lockId;
42
+ runtimePlayer.__actionBattleAttackLockedUntil = now + durationMs;
43
+
44
+ const previousCanMove = player.canMove;
45
+ const previousDirectionFixed = player.directionFixed;
46
+ const previousAnimationFixed = player.animationFixed;
47
+
48
+ if (locks.movement) {
49
+ if (typeof engine.interruptCurrentPlayerMovement === "function") {
50
+ engine.interruptCurrentPlayerMovement(player);
51
+ } else {
52
+ (engine.scene as any)?.stopMovement?.(player);
53
+ }
54
+ player.canMove = false;
55
+ }
56
+ if (locks.direction) {
57
+ player.directionFixed = true;
58
+ }
59
+ player.animationFixed = true;
60
+
61
+ setTimeout(() => {
62
+ if (runtimePlayer.__actionBattleAttackLockId !== lockId) return;
63
+ runtimePlayer.__actionBattleAttackLockedUntil = 0;
64
+ player.canMove = previousCanMove;
65
+ player.directionFixed = previousDirectionFixed;
66
+ player.animationFixed = previousAnimationFixed;
67
+ }, durationMs);
68
+
69
+ return true;
70
+ };
71
+
72
+ const resolveLocalPlayerDirection = (player: any) => {
73
+ if (typeof player.getDirection === "function") return player.getDirection();
74
+ if (typeof player.direction === "function") return player.direction();
75
+ return player.direction ?? "down";
76
+ };
77
+
78
+ const playLocalPlayerAttackAnimation = (
79
+ player: any,
80
+ options: ActionBattleOptions
81
+ ) => {
82
+ if (!player || typeof player.setAnimation !== "function") return;
83
+ const animation = resolveActionBattleAnimation(
84
+ "attack",
85
+ player,
86
+ options.animations
87
+ );
88
+ if (!animation) return;
89
+
90
+ if (animation.graphic !== undefined) {
91
+ player.setAnimation(
92
+ animation.animationName,
93
+ animation.graphic,
94
+ animation.repeat
95
+ );
96
+ return;
97
+ }
98
+ player.setAnimation(animation.animationName, animation.repeat);
99
+ };
100
+
101
+ const showLocalAttackPreview = (player: any, options: ActionBattleOptions) => {
102
+ if (!player || options.attack?.showPreview === false) return;
103
+ const durationMs = Math.max(1, options.attack?.previewDurationMs ?? 180);
104
+ const previewId = startAttackPreview({
105
+ direction: resolveLocalPlayerDirection(player),
106
+ durationMs,
107
+ color: options.attack?.previewColor,
108
+ accentColor: options.attack?.previewAccentColor,
109
+ });
110
+ setTimeout(() => stopAttackPreview(previewId), durationMs);
111
+ };
8
112
 
9
113
  export const createActionBattleClient = (
10
114
  options: ActionBattleOptions = {}
@@ -13,6 +117,10 @@ export const createActionBattleClient = (
13
117
  setActionBattleOptions(normalized);
14
118
  const actionBarEnabled = normalized.ui?.actionBar?.enabled;
15
119
  const targetingEnabled = normalized.ui?.targeting?.enabled;
120
+ const componentsInFront = [
121
+ ...(targetingEnabled ? [TargetingOverlayComponent] : []),
122
+ AttackPreviewComponent,
123
+ ];
16
124
  const hitComponent = PrebuiltComponentAnimations?.Hit;
17
125
  return defineModule<RpgClient>({
18
126
  componentAnimations: hitComponent
@@ -36,7 +144,7 @@ export const createActionBattleClient = (
36
144
  ]
37
145
  : [],
38
146
  sprite: {
39
- componentsInFront: targetingEnabled ? [TargetingOverlayComponent] : [],
147
+ componentsInFront,
40
148
  },
41
149
  sceneMap: {
42
150
  onAfterLoading() {
@@ -45,6 +153,26 @@ export const createActionBattleClient = (
45
153
  gui.display('action-battle-action-bar')
46
154
  }
47
155
  }
156
+ },
157
+ engine: {
158
+ onInput(engine: RpgClientEngine, { input }: { input: string }) {
159
+ if (input !== "action") return;
160
+ const player = engine.scene?.getCurrentPlayer?.() as any;
161
+ if (!player) return;
162
+ const attackProfile = getNormalizedActionBattleAttackProfile(normalized);
163
+ const lockDurationMs = Math.max(
164
+ 0,
165
+ attackProfile.totalDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS
166
+ );
167
+ if (attackProfile.movementLock || attackProfile.directionLock) {
168
+ beginLocalPlayerAttackLock(engine, lockDurationMs, {
169
+ movement: attackProfile.movementLock,
170
+ direction: attackProfile.directionLock,
171
+ });
172
+ }
173
+ playLocalPlayerAttackAnimation(player, normalized);
174
+ showLocalAttackPreview(player, normalized);
175
+ },
48
176
  }
49
177
  });
50
178
  };
@@ -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
@@ -1,4 +1,5 @@
1
1
  import { ActionBattleOptions } from "./types";
2
+ import { normalizeActionBattleAttackProfile } from "./core/attack-profile";
2
3
 
3
4
  export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = {
4
5
  ui: {
@@ -24,11 +25,33 @@ export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = {
24
25
  affects: "events",
25
26
  allowEmptyTarget: true,
26
27
  },
28
+ attack: {
29
+ lockMovement: true,
30
+ lockDurationMs: 350,
31
+ showPreview: true,
32
+ previewDurationMs: 180,
33
+ previewColor: 0xfff3b0,
34
+ previewAccentColor: 0xffffff,
35
+ },
36
+ animations: {},
27
37
  };
28
38
 
39
+ let currentActionBattleOptions: ActionBattleOptions =
40
+ DEFAULT_ACTION_BATTLE_OPTIONS;
41
+
29
42
  export function normalizeActionBattleOptions(
30
43
  options: ActionBattleOptions = {}
31
44
  ): ActionBattleOptions {
45
+ const attack = {
46
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.attack,
47
+ ...options.attack,
48
+ };
49
+ const attackProfile = normalizeActionBattleAttackProfile(attack.profile, {
50
+ lockMovement: attack.lockMovement,
51
+ lockDurationMs: attack.lockDurationMs,
52
+ hitboxes: attack.hitboxes,
53
+ });
54
+
32
55
  return {
33
56
  ui: {
34
57
  actionBar: {
@@ -52,5 +75,43 @@ export function normalizeActionBattleOptions(
52
75
  ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
53
76
  ...options.targeting,
54
77
  },
78
+ debug: {
79
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.debug,
80
+ ...options.debug,
81
+ },
82
+ attack: {
83
+ ...attack,
84
+ profile: attackProfile,
85
+ },
86
+ animations: {
87
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
88
+ ...options.animations,
89
+ },
90
+ systems: {
91
+ combat: {
92
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.combat,
93
+ ...options.systems?.combat,
94
+ hooks: {
95
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.combat?.hooks,
96
+ ...options.systems?.combat?.hooks,
97
+ },
98
+ },
99
+ ai: {
100
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai,
101
+ ...options.systems?.ai,
102
+ behaviors: {
103
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai?.behaviors,
104
+ ...options.systems?.ai?.behaviors,
105
+ },
106
+ },
107
+ },
55
108
  };
56
109
  }
110
+
111
+ export function setActionBattleOptions(options: ActionBattleOptions) {
112
+ currentActionBattleOptions = options;
113
+ }
114
+
115
+ export function getActionBattleOptions(): ActionBattleOptions {
116
+ return currentActionBattleOptions;
117
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE,
4
+ normalizeActionBattleAttackProfile,
5
+ } from "./attack-profile";
6
+ import { normalizeActionBattleOptions } from "../config";
7
+ import type { NormalizedActionBattleAttackProfile } from "../types";
8
+
9
+ describe("normalizeActionBattleAttackProfile", () => {
10
+ test("creates a default profile compatible with the legacy 350ms attack lock", () => {
11
+ const profile = normalizeActionBattleAttackProfile();
12
+
13
+ expect(profile).toEqual(DEFAULT_ACTION_BATTLE_ATTACK_PROFILE);
14
+ });
15
+
16
+ test("derives recovery from the legacy lock duration when recovery is omitted", () => {
17
+ const profile = normalizeActionBattleAttackProfile(
18
+ {
19
+ startupMs: 80,
20
+ activeMs: 90,
21
+ },
22
+ {
23
+ lockDurationMs: 400,
24
+ }
25
+ );
26
+
27
+ expect(profile.recoveryMs).toBe(230);
28
+ expect(profile.totalDurationMs).toBe(400);
29
+ expect(profile.cooldownMs).toBe(400);
30
+ });
31
+
32
+ test("keeps explicit timing, movement, hit policy, animation, and hitboxes", () => {
33
+ const hitboxes = {
34
+ right: { offsetX: 18, offsetY: -18, width: 42, height: 36 },
35
+ };
36
+ const profile = normalizeActionBattleAttackProfile({
37
+ id: "heavy-sword",
38
+ startupMs: 140,
39
+ activeMs: 100,
40
+ recoveryMs: 260,
41
+ cooldownMs: 650,
42
+ movementLock: false,
43
+ directionLock: false,
44
+ animationKey: "castSkill",
45
+ hitPolicy: "allowRepeatHits",
46
+ hitboxes,
47
+ });
48
+
49
+ expect(profile).toMatchObject({
50
+ id: "heavy-sword",
51
+ startupMs: 140,
52
+ activeMs: 100,
53
+ recoveryMs: 260,
54
+ cooldownMs: 650,
55
+ movementLock: false,
56
+ directionLock: false,
57
+ animationKey: "castSkill",
58
+ hitPolicy: "allowRepeatHits",
59
+ totalDurationMs: 500,
60
+ });
61
+ expect(profile.hitboxes).toBe(hitboxes);
62
+ });
63
+
64
+ test("normalizes unsafe timing values to playable bounds", () => {
65
+ const profile = normalizeActionBattleAttackProfile({
66
+ startupMs: -20,
67
+ activeMs: 0,
68
+ recoveryMs: -10,
69
+ cooldownMs: -1,
70
+ });
71
+
72
+ expect(profile.startupMs).toBe(0);
73
+ expect(profile.activeMs).toBe(1);
74
+ expect(profile.recoveryMs).toBe(0);
75
+ expect(profile.cooldownMs).toBe(0);
76
+ expect(profile.totalDurationMs).toBe(1);
77
+ });
78
+
79
+ test("normalizes attack.profile through action battle options", () => {
80
+ const options = normalizeActionBattleOptions({
81
+ attack: {
82
+ lockMovement: false,
83
+ lockDurationMs: 300,
84
+ profile: {
85
+ id: "quick-slash",
86
+ startupMs: 60,
87
+ activeMs: 80,
88
+ },
89
+ },
90
+ });
91
+ const profile = options.attack
92
+ ?.profile as NormalizedActionBattleAttackProfile;
93
+
94
+ expect(profile).toMatchObject({
95
+ id: "quick-slash",
96
+ startupMs: 60,
97
+ activeMs: 80,
98
+ recoveryMs: 160,
99
+ cooldownMs: 300,
100
+ movementLock: false,
101
+ totalDurationMs: 300,
102
+ });
103
+ });
104
+
105
+ test("keeps legacy lockDurationMs when no explicit profile is provided", () => {
106
+ const options = normalizeActionBattleOptions({
107
+ attack: {
108
+ lockDurationMs: 500,
109
+ },
110
+ });
111
+ const profile = options.attack
112
+ ?.profile as NormalizedActionBattleAttackProfile;
113
+
114
+ expect(profile.totalDurationMs).toBe(500);
115
+ expect(profile.recoveryMs).toBe(380);
116
+ expect(profile.cooldownMs).toBe(500);
117
+ });
118
+ });