@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.
- package/CHANGELOG.md +22 -0
- package/dist/client/ai.server.d.ts +45 -8
- package/dist/client/attack-input.d.ts +3 -0
- package/dist/client/core/action-use.d.ts +18 -0
- package/dist/client/core/ai-behavior-tree.d.ts +99 -0
- package/dist/client/core/attack-runtime.d.ts +2 -0
- package/dist/client/core/defaults.d.ts +2 -1
- package/dist/client/core/equipment.d.ts +1 -0
- package/dist/client/core/targets.d.ts +15 -0
- package/dist/client/enemies/factory.d.ts +2 -0
- package/dist/client/index.d.ts +12 -7
- package/dist/client/index.js +16 -11
- package/dist/client/index10.js +32 -56
- package/dist/client/index11.js +99 -52
- package/dist/client/index12.js +76 -103
- package/dist/client/index13.js +72 -135
- package/dist/client/index14.js +67 -23
- package/dist/client/index15.js +197 -63
- package/dist/client/index16.js +112 -1337
- package/dist/client/index17.js +193 -7
- package/dist/client/index18.js +32 -58
- package/dist/client/index19.js +70 -8
- package/dist/client/index20.js +57 -501
- package/dist/client/index21.js +69 -0
- package/dist/client/index22.js +225 -0
- package/dist/client/index23.js +16 -0
- package/dist/client/index24.js +25 -0
- package/dist/client/index25.js +107 -0
- package/dist/client/index26.js +1707 -0
- package/dist/client/index27.js +12 -0
- package/dist/client/index28.js +589 -0
- package/dist/client/index4.js +79 -38
- package/dist/client/index6.js +65 -306
- package/dist/client/index7.js +33 -33
- package/dist/client/index8.js +24 -100
- package/dist/client/index9.js +293 -61
- package/dist/client/locomotion.d.ts +16 -0
- package/dist/client/movement.d.ts +14 -0
- package/dist/client/server.d.ts +7 -3
- package/dist/client/ui.d.ts +22 -0
- package/dist/client/visual.d.ts +15 -0
- package/dist/server/ai.server.d.ts +45 -8
- package/dist/server/attack-input.d.ts +3 -0
- package/dist/server/core/action-use.d.ts +18 -0
- package/dist/server/core/ai-behavior-tree.d.ts +99 -0
- package/dist/server/core/attack-runtime.d.ts +2 -0
- package/dist/server/core/defaults.d.ts +2 -1
- package/dist/server/core/equipment.d.ts +1 -0
- package/dist/server/core/targets.d.ts +15 -0
- package/dist/server/enemies/factory.d.ts +2 -0
- package/dist/server/index.d.ts +12 -7
- package/dist/server/index.js +14 -9
- package/dist/server/index10.js +64 -1336
- package/dist/server/index11.js +33 -33
- package/dist/server/index13.js +66 -11
- package/dist/server/index14.js +206 -484
- package/dist/server/index15.js +15 -9
- package/dist/server/index16.js +26 -0
- package/dist/server/index17.js +25 -0
- package/dist/server/index18.js +107 -0
- package/dist/server/index19.js +1707 -0
- package/dist/server/index2.js +10 -2
- package/dist/server/index20.js +37 -0
- package/dist/server/index21.js +588 -0
- package/dist/server/index22.js +78 -0
- package/dist/server/index23.js +12 -0
- package/dist/server/index5.js +79 -38
- package/dist/server/index6.js +192 -129
- package/dist/server/index7.js +198 -24
- package/dist/server/index8.js +28 -66
- package/dist/server/index9.js +68 -51
- package/dist/server/locomotion.d.ts +16 -0
- package/dist/server/movement.d.ts +14 -0
- package/dist/server/server.d.ts +7 -3
- package/dist/server/ui.d.ts +22 -0
- package/dist/server/visual.d.ts +15 -0
- package/package.json +10 -10
- package/src/ai.server.spec.ts +233 -0
- package/src/ai.server.ts +627 -108
- package/src/animations.spec.ts +40 -0
- package/src/animations.ts +31 -9
- package/src/attack-input.spec.ts +51 -0
- package/src/attack-input.ts +59 -0
- package/src/client.ts +75 -62
- package/src/components/action-bar.ce +2 -2
- package/src/config.ts +84 -37
- package/src/core/action-use.spec.ts +317 -0
- package/src/core/action-use.ts +386 -0
- package/src/core/ai-behavior-tree.spec.ts +116 -0
- package/src/core/ai-behavior-tree.ts +272 -0
- package/src/core/attack-profile.spec.ts +46 -0
- package/src/core/attack-runtime.spec.ts +35 -0
- package/src/core/attack-runtime.ts +32 -0
- package/src/core/context.ts +9 -0
- package/src/core/contracts.ts +146 -1
- package/src/core/defaults.ts +56 -0
- package/src/core/equipment.ts +9 -5
- package/src/core/targets.spec.ts +112 -0
- package/src/core/targets.ts +147 -0
- package/src/enemies/factory.ts +8 -0
- package/src/index.ts +111 -2
- package/src/locomotion.spec.ts +51 -0
- package/src/locomotion.ts +48 -0
- package/src/movement.spec.ts +78 -0
- package/src/movement.ts +46 -0
- package/src/server.ts +242 -66
- package/src/types.ts +105 -35
- package/src/ui.ts +113 -0
- package/src/visual.spec.ts +166 -0
- package/src/visual.ts +285 -0
- 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(
|
package/src/core/context.ts
CHANGED
|
@@ -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
|
});
|