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

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 (111) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/dist/client/ai.server.d.ts +45 -8
  3. package/dist/client/attack-input.d.ts +3 -0
  4. package/dist/client/core/action-use.d.ts +18 -0
  5. package/dist/client/core/ai-behavior-tree.d.ts +99 -0
  6. package/dist/client/core/attack-runtime.d.ts +2 -0
  7. package/dist/client/core/defaults.d.ts +2 -1
  8. package/dist/client/core/equipment.d.ts +1 -0
  9. package/dist/client/core/targets.d.ts +15 -0
  10. package/dist/client/enemies/factory.d.ts +2 -0
  11. package/dist/client/index.d.ts +12 -7
  12. package/dist/client/index.js +16 -11
  13. package/dist/client/index10.js +32 -56
  14. package/dist/client/index11.js +99 -52
  15. package/dist/client/index12.js +76 -103
  16. package/dist/client/index13.js +72 -135
  17. package/dist/client/index14.js +67 -23
  18. package/dist/client/index15.js +197 -63
  19. package/dist/client/index16.js +112 -1337
  20. package/dist/client/index17.js +193 -7
  21. package/dist/client/index18.js +32 -58
  22. package/dist/client/index19.js +70 -8
  23. package/dist/client/index20.js +57 -501
  24. package/dist/client/index21.js +69 -0
  25. package/dist/client/index22.js +225 -0
  26. package/dist/client/index23.js +16 -0
  27. package/dist/client/index24.js +25 -0
  28. package/dist/client/index25.js +107 -0
  29. package/dist/client/index26.js +1707 -0
  30. package/dist/client/index27.js +12 -0
  31. package/dist/client/index28.js +589 -0
  32. package/dist/client/index4.js +79 -38
  33. package/dist/client/index6.js +65 -306
  34. package/dist/client/index7.js +33 -33
  35. package/dist/client/index8.js +24 -100
  36. package/dist/client/index9.js +293 -61
  37. package/dist/client/locomotion.d.ts +16 -0
  38. package/dist/client/movement.d.ts +14 -0
  39. package/dist/client/server.d.ts +7 -3
  40. package/dist/client/ui.d.ts +22 -0
  41. package/dist/client/visual.d.ts +15 -0
  42. package/dist/server/ai.server.d.ts +45 -8
  43. package/dist/server/attack-input.d.ts +3 -0
  44. package/dist/server/core/action-use.d.ts +18 -0
  45. package/dist/server/core/ai-behavior-tree.d.ts +99 -0
  46. package/dist/server/core/attack-runtime.d.ts +2 -0
  47. package/dist/server/core/defaults.d.ts +2 -1
  48. package/dist/server/core/equipment.d.ts +1 -0
  49. package/dist/server/core/targets.d.ts +15 -0
  50. package/dist/server/enemies/factory.d.ts +2 -0
  51. package/dist/server/index.d.ts +12 -7
  52. package/dist/server/index.js +14 -9
  53. package/dist/server/index10.js +64 -1336
  54. package/dist/server/index11.js +33 -33
  55. package/dist/server/index13.js +66 -11
  56. package/dist/server/index14.js +206 -484
  57. package/dist/server/index15.js +15 -9
  58. package/dist/server/index16.js +26 -0
  59. package/dist/server/index17.js +25 -0
  60. package/dist/server/index18.js +107 -0
  61. package/dist/server/index19.js +1707 -0
  62. package/dist/server/index2.js +10 -2
  63. package/dist/server/index20.js +37 -0
  64. package/dist/server/index21.js +588 -0
  65. package/dist/server/index22.js +78 -0
  66. package/dist/server/index23.js +12 -0
  67. package/dist/server/index5.js +79 -38
  68. package/dist/server/index6.js +192 -129
  69. package/dist/server/index7.js +198 -24
  70. package/dist/server/index8.js +28 -66
  71. package/dist/server/index9.js +68 -51
  72. package/dist/server/locomotion.d.ts +16 -0
  73. package/dist/server/movement.d.ts +14 -0
  74. package/dist/server/server.d.ts +7 -3
  75. package/dist/server/ui.d.ts +22 -0
  76. package/dist/server/visual.d.ts +15 -0
  77. package/package.json +10 -10
  78. package/src/ai.server.spec.ts +233 -0
  79. package/src/ai.server.ts +627 -108
  80. package/src/animations.spec.ts +40 -0
  81. package/src/animations.ts +31 -9
  82. package/src/attack-input.spec.ts +51 -0
  83. package/src/attack-input.ts +59 -0
  84. package/src/client.ts +75 -62
  85. package/src/components/action-bar.ce +2 -2
  86. package/src/config.ts +84 -37
  87. package/src/core/action-use.spec.ts +317 -0
  88. package/src/core/action-use.ts +386 -0
  89. package/src/core/ai-behavior-tree.spec.ts +116 -0
  90. package/src/core/ai-behavior-tree.ts +272 -0
  91. package/src/core/attack-profile.spec.ts +46 -0
  92. package/src/core/attack-runtime.spec.ts +35 -0
  93. package/src/core/attack-runtime.ts +32 -0
  94. package/src/core/context.ts +9 -0
  95. package/src/core/contracts.ts +146 -1
  96. package/src/core/defaults.ts +56 -0
  97. package/src/core/equipment.ts +9 -5
  98. package/src/core/targets.spec.ts +112 -0
  99. package/src/core/targets.ts +147 -0
  100. package/src/enemies/factory.ts +8 -0
  101. package/src/index.ts +111 -2
  102. package/src/locomotion.spec.ts +51 -0
  103. package/src/locomotion.ts +48 -0
  104. package/src/movement.spec.ts +78 -0
  105. package/src/movement.ts +46 -0
  106. package/src/server.ts +242 -66
  107. package/src/types.ts +105 -35
  108. package/src/ui.ts +113 -0
  109. package/src/visual.spec.ts +166 -0
  110. package/src/visual.ts +285 -0
  111. package/README.md +0 -1242
@@ -0,0 +1,116 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { AiState, AttackPattern, EnemyType } from "../ai.server";
3
+ import {
4
+ action,
5
+ chase,
6
+ condition,
7
+ defineAiBehavior,
8
+ hpBelow,
9
+ ifHpBelow,
10
+ ifTargetInRange,
11
+ keepDistance,
12
+ selector,
13
+ sequence,
14
+ targetInRange,
15
+ useAttack,
16
+ } from "./ai-behavior-tree";
17
+
18
+ const createContext = (overrides: Record<string, any> = {}) => {
19
+ const event = { id: "enemy-1" };
20
+ const target = { id: "player-1" };
21
+ const distance = overrides.distance ?? 40;
22
+ return {
23
+ event,
24
+ target,
25
+ state: AiState.Combat,
26
+ enemyType: EnemyType.Aggressive,
27
+ distance,
28
+ hpPercent: overrides.hpPercent ?? 0.8,
29
+ now: 100,
30
+ self: {
31
+ event,
32
+ state: AiState.Combat,
33
+ enemyType: EnemyType.Aggressive,
34
+ hpPercent: overrides.hpPercent ?? 0.8,
35
+ attackRange: overrides.attackRange ?? 50,
36
+ },
37
+ targetInfo: overrides.targetInfo ?? {
38
+ entity: target,
39
+ distance,
40
+ inAttackRange: distance <= (overrides.attackRange ?? 50),
41
+ visible: true,
42
+ },
43
+ memory: {},
44
+ ...overrides,
45
+ } as any;
46
+ };
47
+
48
+ describe("action battle AI behavior tree", () => {
49
+ test("selects the first successful branch", () => {
50
+ const tree = selector([
51
+ sequence([condition(hpBelow(0.2)), action(chase())]),
52
+ sequence([condition(targetInRange()), action(useAttack(AttackPattern.Melee))]),
53
+ action(keepDistance(80)),
54
+ ]);
55
+
56
+ const result = tree.tick(createContext());
57
+
58
+ expect(result.status).toBe("success");
59
+ expect(result.intent).toEqual({
60
+ type: "useAttack",
61
+ pattern: AttackPattern.Melee,
62
+ });
63
+ });
64
+
65
+ test("compiles simplified rules to a behavior tree", () => {
66
+ const behavior = defineAiBehavior({
67
+ when: [
68
+ ifHpBelow(0.25, keepDistance(120)),
69
+ ifTargetInRange(useAttack("melee")),
70
+ ],
71
+ otherwise: chase(),
72
+ });
73
+
74
+ expect(behavior.tick(createContext({ hpPercent: 0.1 })).intent).toEqual({
75
+ type: "keepDistance",
76
+ distance: 120,
77
+ tolerance: undefined,
78
+ });
79
+ expect(behavior.tick(createContext({ distance: 30 })).intent).toEqual({
80
+ type: "useAttack",
81
+ pattern: "melee",
82
+ });
83
+ expect(behavior.tick(createContext({ distance: 90 })).intent).toEqual({
84
+ type: "moveToTarget",
85
+ });
86
+ });
87
+
88
+ test("supports dynamic actions with memory", () => {
89
+ const behavior = defineAiBehavior({
90
+ otherwise: ({ memory }) => {
91
+ memory.ticks = (memory.ticks ?? 0) + 1;
92
+ return useAttack(memory.ticks === 1 ? "melee" : "dashAttack");
93
+ },
94
+ });
95
+ const context = createContext();
96
+
97
+ expect(behavior.tick(context).intent).toEqual({
98
+ type: "useAttack",
99
+ pattern: "melee",
100
+ });
101
+ expect(behavior.tick(context).intent).toEqual({
102
+ type: "useAttack",
103
+ pattern: "dashAttack",
104
+ });
105
+ });
106
+
107
+ test("does not evaluate later selector branches after success", () => {
108
+ const later = vi.fn(() => ({ status: "success" as const, intent: chase() }));
109
+ const tree = selector([action(useAttack("melee")), later]);
110
+
111
+ const result = tree.tick(createContext());
112
+
113
+ expect(result.intent).toEqual({ type: "useAttack", pattern: "melee" });
114
+ expect(later).not.toHaveBeenCalled();
115
+ });
116
+ });
@@ -0,0 +1,272 @@
1
+ import type { RpgEvent, RpgPlayer } from "@rpgjs/server";
2
+ import type { AiState, AttackPattern, EnemyType } from "../ai.server";
3
+ import type {
4
+ ActionBattleAiContext,
5
+ ActionBattleAiDecision,
6
+ } from "./contracts";
7
+
8
+ export type ActionBattleAiTreeStatus = "success" | "failure" | "running";
9
+
10
+ export type ActionBattleAiMemory = Record<string, any>;
11
+
12
+ export interface ActionBattleAiIntentBase {
13
+ consume?: boolean;
14
+ metadata?: Record<string, any>;
15
+ }
16
+
17
+ export type ActionBattleAiIntent =
18
+ | (ActionBattleAiIntentBase & { type: "idle" })
19
+ | (ActionBattleAiIntentBase & { type: "patrol" })
20
+ | (ActionBattleAiIntentBase & { type: "faceTarget" })
21
+ | (ActionBattleAiIntentBase & { type: "moveToTarget" })
22
+ | (ActionBattleAiIntentBase & { type: "fleeFromTarget" })
23
+ | (ActionBattleAiIntentBase & {
24
+ type: "keepDistance";
25
+ distance: number;
26
+ tolerance?: number;
27
+ })
28
+ | (ActionBattleAiIntentBase & {
29
+ type: "useAttack";
30
+ pattern?: AttackPattern | string;
31
+ })
32
+ | (ActionBattleAiIntentBase & {
33
+ type: "useSkill";
34
+ skill: any;
35
+ })
36
+ | (ActionBattleAiIntentBase & {
37
+ type: "setMode";
38
+ mode: NonNullable<ActionBattleAiDecision["mode"]>;
39
+ });
40
+
41
+ export interface ActionBattleAiSnapshotSelf {
42
+ event: RpgEvent;
43
+ state: AiState;
44
+ enemyType: EnemyType;
45
+ hpPercent: number | null;
46
+ attackRange: number;
47
+ }
48
+
49
+ export interface ActionBattleAiSnapshotTarget {
50
+ entity: RpgPlayer;
51
+ distance: number;
52
+ inAttackRange: boolean;
53
+ visible: boolean;
54
+ }
55
+
56
+ export interface ActionBattleAiTreeContext extends ActionBattleAiContext {
57
+ self: ActionBattleAiSnapshotSelf;
58
+ targetInfo: ActionBattleAiSnapshotTarget | null;
59
+ memory: ActionBattleAiMemory;
60
+ }
61
+
62
+ export interface ActionBattleAiTreeResult {
63
+ status: ActionBattleAiTreeStatus;
64
+ decision?: ActionBattleAiDecision;
65
+ intent?: ActionBattleAiIntent | ActionBattleAiIntent[];
66
+ }
67
+
68
+ export interface ActionBattleAiTreeNode {
69
+ tick(context: ActionBattleAiTreeContext): ActionBattleAiTreeResult;
70
+ }
71
+
72
+ export type ActionBattleAiTreeInput =
73
+ | ActionBattleAiTreeNode
74
+ | ((context: ActionBattleAiTreeContext) => ActionBattleAiTreeResult | void);
75
+
76
+ export type ActionBattleAiCondition = (
77
+ context: ActionBattleAiTreeContext
78
+ ) => boolean;
79
+
80
+ export type ActionBattleAiIntentInput =
81
+ | ActionBattleAiIntent
82
+ | ActionBattleAiIntent[]
83
+ | ActionBattleAiTreeNode
84
+ | ((context: ActionBattleAiTreeContext) => ActionBattleAiIntent | ActionBattleAiIntent[]);
85
+
86
+ export interface ActionBattleAiRule {
87
+ condition: ActionBattleAiCondition;
88
+ then: ActionBattleAiIntentInput;
89
+ }
90
+
91
+ export interface ActionBattleAiSimpleBehavior {
92
+ when?: ActionBattleAiRule[];
93
+ otherwise?: ActionBattleAiIntentInput;
94
+ }
95
+
96
+ const isTreeNode = (input: unknown): input is ActionBattleAiTreeNode =>
97
+ Boolean(input && typeof (input as ActionBattleAiTreeNode).tick === "function");
98
+
99
+ const normalizeTreeResult = (
100
+ result: ActionBattleAiTreeResult | void
101
+ ): ActionBattleAiTreeResult => result ?? { status: "failure" };
102
+
103
+ const runIntentInput = (
104
+ input: ActionBattleAiIntentInput,
105
+ context: ActionBattleAiTreeContext
106
+ ): ActionBattleAiTreeResult => {
107
+ if (isTreeNode(input)) return input.tick(context);
108
+ const intent = typeof input === "function" ? input(context) : input;
109
+ return { status: "success", intent };
110
+ };
111
+
112
+ export const defineAiTree = (
113
+ input: ActionBattleAiTreeInput
114
+ ): ActionBattleAiTreeNode => {
115
+ if (isTreeNode(input)) return input;
116
+ return {
117
+ tick(context) {
118
+ return normalizeTreeResult(input(context));
119
+ },
120
+ };
121
+ };
122
+
123
+ export const selector = (
124
+ children: ActionBattleAiTreeInput[]
125
+ ): ActionBattleAiTreeNode => ({
126
+ tick(context) {
127
+ for (const child of children) {
128
+ const result = defineAiTree(child).tick(context);
129
+ if (result.status !== "failure") return result;
130
+ }
131
+ return { status: "failure" };
132
+ },
133
+ });
134
+
135
+ export const sequence = (
136
+ children: ActionBattleAiTreeInput[]
137
+ ): ActionBattleAiTreeNode => ({
138
+ tick(context) {
139
+ let last: ActionBattleAiTreeResult = { status: "success" };
140
+ for (const child of children) {
141
+ last = defineAiTree(child).tick(context);
142
+ if (last.status !== "success") return last;
143
+ }
144
+ return last;
145
+ },
146
+ });
147
+
148
+ export const condition = (
149
+ predicate: ActionBattleAiCondition
150
+ ): ActionBattleAiTreeNode => ({
151
+ tick(context) {
152
+ return { status: predicate(context) ? "success" : "failure" };
153
+ },
154
+ });
155
+
156
+ export const action = (
157
+ input: ActionBattleAiIntentInput,
158
+ status: ActionBattleAiTreeStatus = "success"
159
+ ): ActionBattleAiTreeNode => ({
160
+ tick(context) {
161
+ const result = runIntentInput(input, context);
162
+ return { ...result, status };
163
+ },
164
+ });
165
+
166
+ export const decision = (
167
+ resolve: ActionBattleAiDecision | ((context: ActionBattleAiTreeContext) => ActionBattleAiDecision)
168
+ ): ActionBattleAiTreeNode => ({
169
+ tick(context) {
170
+ return {
171
+ status: "success",
172
+ decision: typeof resolve === "function" ? resolve(context) : resolve,
173
+ };
174
+ },
175
+ });
176
+
177
+ export const rule = (
178
+ predicate: ActionBattleAiCondition,
179
+ then: ActionBattleAiIntentInput
180
+ ): ActionBattleAiRule => ({
181
+ condition: predicate,
182
+ then,
183
+ });
184
+
185
+ export const defineAiBehavior = (
186
+ behavior: ActionBattleAiSimpleBehavior
187
+ ): ActionBattleAiTreeNode => {
188
+ const branches = [
189
+ ...(behavior.when ?? []).map((entry) =>
190
+ sequence([condition(entry.condition), action(entry.then)])
191
+ ),
192
+ ];
193
+ if (behavior.otherwise) {
194
+ branches.push(action(behavior.otherwise));
195
+ }
196
+ return selector(branches);
197
+ };
198
+
199
+ export const hpBelow = (ratio: number): ActionBattleAiCondition => {
200
+ return ({ self }) => self.hpPercent !== null && self.hpPercent < ratio;
201
+ };
202
+
203
+ export const targetVisible = (): ActionBattleAiCondition => {
204
+ return ({ targetInfo }) => Boolean(targetInfo?.visible);
205
+ };
206
+
207
+ export const targetInRange = (
208
+ range?: number
209
+ ): ActionBattleAiCondition => {
210
+ return ({ self, targetInfo }) => {
211
+ if (!targetInfo) return false;
212
+ return targetInfo.distance <= (range ?? self.attackRange);
213
+ };
214
+ };
215
+
216
+ export const distanceLessThan = (
217
+ distance: number
218
+ ): ActionBattleAiCondition => {
219
+ return ({ targetInfo }) =>
220
+ targetInfo !== null && targetInfo.distance < distance;
221
+ };
222
+
223
+ export const inState = (state: AiState): ActionBattleAiCondition => {
224
+ return ({ self }) => self.state === state;
225
+ };
226
+
227
+ export const isEnemyType = (
228
+ enemyType: EnemyType
229
+ ): ActionBattleAiCondition => {
230
+ return ({ self }) => self.enemyType === enemyType;
231
+ };
232
+
233
+ export const idle = (): ActionBattleAiIntent => ({ type: "idle" });
234
+ export const patrol = (): ActionBattleAiIntent => ({ type: "patrol" });
235
+ export const faceTarget = (): ActionBattleAiIntent => ({ type: "faceTarget" });
236
+ export const chase = (): ActionBattleAiIntent => ({ type: "moveToTarget" });
237
+ export const moveToTarget = chase;
238
+ export const flee = (): ActionBattleAiIntent => ({ type: "fleeFromTarget" });
239
+ export const fleeFromTarget = flee;
240
+ export const keepDistance = (
241
+ distance: number,
242
+ tolerance?: number
243
+ ): ActionBattleAiIntent => ({ type: "keepDistance", distance, tolerance });
244
+ export const useAttack = (
245
+ pattern?: AttackPattern | string
246
+ ): ActionBattleAiIntent => ({ type: "useAttack", pattern });
247
+ export const useSkill = (skill: any): ActionBattleAiIntent => ({
248
+ type: "useSkill",
249
+ skill,
250
+ });
251
+ export const setMode = (
252
+ mode: NonNullable<ActionBattleAiDecision["mode"]>
253
+ ): ActionBattleAiIntent => ({ type: "setMode", mode, consume: false });
254
+
255
+ export const ifHpBelow = (
256
+ ratio: number,
257
+ then: ActionBattleAiIntentInput
258
+ ): ActionBattleAiRule => rule(hpBelow(ratio), then);
259
+
260
+ export const ifTargetVisible = (
261
+ then: ActionBattleAiIntentInput
262
+ ): ActionBattleAiRule => rule(targetVisible(), then);
263
+
264
+ export const ifTargetInRange = (
265
+ then: ActionBattleAiIntentInput,
266
+ range?: number
267
+ ): ActionBattleAiRule => rule(targetInRange(range), then);
268
+
269
+ export const ifDistanceLessThan = (
270
+ distance: number,
271
+ then: ActionBattleAiIntentInput
272
+ ): ActionBattleAiRule => rule(distanceLessThan(distance), then);
@@ -115,4 +115,50 @@ describe("normalizeActionBattleAttackProfile", () => {
115
115
  expect(profile.recoveryMs).toBe(380);
116
116
  expect(profile.cooldownMs).toBe(500);
117
117
  });
118
+
119
+ test("normalizes the new combat, ai, skills, and ui option shape", () => {
120
+ const damage = () => ({ damage: 1, defeated: false });
121
+ const behavior = () => ({ mode: "assault" as const });
122
+ const targeting = () => ({ range: 4, aoeMask: ["#"] });
123
+ const options = normalizeActionBattleOptions({
124
+ attack: {
125
+ lockDurationMs: 500,
126
+ },
127
+ systems: {
128
+ combat: {
129
+ damage: () => ({ damage: 0, defeated: false }),
130
+ },
131
+ },
132
+ combat: {
133
+ attack: {
134
+ lockDurationMs: 260,
135
+ },
136
+ damage,
137
+ },
138
+ ai: {
139
+ behaviors: {
140
+ slime: behavior,
141
+ },
142
+ },
143
+ skills: {
144
+ targeting,
145
+ },
146
+ ui: {
147
+ actionBar: true,
148
+ targeting: false,
149
+ attackPreview: false,
150
+ },
151
+ });
152
+
153
+ expect(options.attack?.lockDurationMs).toBe(260);
154
+ expect(options.systems?.combat?.damage).toBe(damage);
155
+ expect(options.combat?.damage).toBe(damage);
156
+ expect(options.systems?.ai?.behaviors?.slime).toBe(behavior);
157
+ expect(options.ai?.behaviors?.slime).toBe(behavior);
158
+ expect(options.skills?.getTargeting).toBe(targeting);
159
+ expect(options.skills?.targeting).toBe(targeting);
160
+ expect((options.ui?.actionBar as any).enabled).toBe(true);
161
+ expect((options.ui?.targeting as any).enabled).toBe(false);
162
+ expect((options.ui?.attackPreview as any).enabled).toBe(false);
163
+ });
118
164
  });
@@ -4,6 +4,7 @@ import {
4
4
  createActionBattleAttackId,
5
5
  getNormalizedActionBattleAttackProfile,
6
6
  resolveActionBattleHitboxSpeed,
7
+ runActionBattleActiveHitbox,
7
8
  scheduleActionBattleStartup,
8
9
  } from "./attack-runtime";
9
10
 
@@ -76,6 +77,40 @@ describe("attack runtime helpers", () => {
76
77
  expect(scheduler).toHaveBeenCalledWith(callback, 120);
77
78
  });
78
79
 
80
+ test("runs hitbox queries across the active window", () => {
81
+ const callbacks: Array<() => void> = [];
82
+ const scheduler = vi.fn((callback: () => void) => {
83
+ callbacks.push(callback);
84
+ return callbacks.length;
85
+ });
86
+ const onHitboxes = vi.fn();
87
+ const profile = getNormalizedActionBattleAttackProfile({
88
+ attack: {
89
+ profile: {
90
+ startupMs: 20,
91
+ activeMs: 32,
92
+ },
93
+ },
94
+ });
95
+
96
+ runActionBattleActiveHitbox(
97
+ profile,
98
+ () => [{ x: 0, y: 0, width: 10, height: 10 }],
99
+ onHitboxes,
100
+ scheduler
101
+ );
102
+
103
+ expect(onHitboxes).not.toHaveBeenCalled();
104
+ expect(scheduler).toHaveBeenCalledWith(expect.any(Function), 20);
105
+
106
+ callbacks.shift()?.();
107
+ expect(onHitboxes).toHaveBeenCalledTimes(1);
108
+ expect(scheduler).toHaveBeenLastCalledWith(expect.any(Function), 16);
109
+
110
+ callbacks.shift()?.();
111
+ expect(onHitboxes).toHaveBeenCalledTimes(2);
112
+ });
113
+
79
114
  test("creates stable unique attack ids", () => {
80
115
  const first = createActionBattleAttackId("player-1", "sword");
81
116
  const second = createActionBattleAttackId("player-1", "sword");
@@ -3,6 +3,7 @@ import type {
3
3
  ActionBattleOptions,
4
4
  NormalizedActionBattleAttackProfile,
5
5
  } from "../types";
6
+ import type { ActionBattleHitbox } from "./contracts";
6
7
  import { normalizeActionBattleAttackProfile } from "./attack-profile";
7
8
 
8
9
  export const ACTION_BATTLE_HITBOX_FRAME_MS = 16;
@@ -42,6 +43,37 @@ export function scheduleActionBattleStartup(
42
43
  return scheduler(callback, profile.startupMs);
43
44
  }
44
45
 
46
+ export function runActionBattleActiveHitbox(
47
+ profile: NormalizedActionBattleAttackProfile,
48
+ resolveHitboxes: () => ActionBattleHitbox[],
49
+ onHitboxes: (hitboxes: ActionBattleHitbox[]) => void,
50
+ scheduler: (callback: () => void, delayMs: number) => unknown = setTimeout
51
+ ) {
52
+ const frames = Math.max(
53
+ 1,
54
+ Math.ceil(profile.activeMs / ACTION_BATTLE_HITBOX_FRAME_MS)
55
+ );
56
+ let frame = 0;
57
+
58
+ const step = () => {
59
+ const hitboxes = resolveHitboxes();
60
+ if (hitboxes.length > 0) {
61
+ onHitboxes(hitboxes);
62
+ }
63
+ frame++;
64
+ if (frame < frames) {
65
+ scheduler(step, ACTION_BATTLE_HITBOX_FRAME_MS);
66
+ }
67
+ };
68
+
69
+ if (profile.startupMs <= 0) {
70
+ step();
71
+ return null;
72
+ }
73
+
74
+ return scheduler(step, profile.startupMs);
75
+ }
76
+
45
77
  let attackIdCounter = 0;
46
78
 
47
79
  export function createActionBattleAttackId(
@@ -6,20 +6,29 @@ const mergeSystems = (options: ActionBattleOptions = {}): ActionBattleSystems =>
6
6
  combat: {
7
7
  ...defaultActionBattleSystems.combat,
8
8
  resolveDamage:
9
+ options.combat?.damage ??
9
10
  options.systems?.combat?.damage ??
10
11
  defaultActionBattleSystems.combat.resolveDamage,
11
12
  resolveKnockback:
13
+ options.combat?.knockback ??
12
14
  options.systems?.combat?.knockback ??
13
15
  defaultActionBattleSystems.combat.resolveKnockback,
14
16
  hooks: {
15
17
  ...defaultActionBattleSystems.combat.hooks,
16
18
  ...options.systems?.combat?.hooks,
19
+ ...options.combat?.hooks,
17
20
  },
18
21
  },
19
22
  ai: {
20
23
  behaviors: {
21
24
  ...defaultActionBattleSystems.ai.behaviors,
22
25
  ...options.systems?.ai?.behaviors,
26
+ ...options.ai?.behaviors,
27
+ },
28
+ presets: {
29
+ ...defaultActionBattleSystems.ai.presets,
30
+ ...options.systems?.ai?.presets,
31
+ ...options.ai?.presets,
23
32
  },
24
33
  },
25
34
  });