@rpgjs/action-battle 5.0.0-beta.11 → 5.0.0-beta.13
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 +57 -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 +3 -2
- 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 +203 -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 +70 -0
- package/dist/client/index22.js +226 -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 +1949 -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 +57 -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 +3 -2
- 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 +67 -11
- package/dist/server/index14.js +207 -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 +1949 -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 +208 -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 +5 -5
- package/src/ai.server.spec.ts +380 -1
- package/src/ai.server.ts +963 -137
- 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/config.ts +84 -37
- package/src/core/action-use.spec.ts +317 -0
- package/src/core/action-use.ts +387 -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 +72 -1
- package/src/core/equipment.ts +9 -5
- package/src/core/hit.spec.ts +21 -0
- package/src/core/targets.spec.ts +124 -0
- package/src/core/targets.ts +150 -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
package/src/core/contracts.ts
CHANGED
|
@@ -1,9 +1,114 @@
|
|
|
1
1
|
import type { RpgEvent, RpgPlayer } from "@rpgjs/server";
|
|
2
2
|
import type { AttackPattern, EnemyType, AiState } from "../ai.server";
|
|
3
3
|
import type { NormalizedActionBattleHitReactionProfile } from "../types";
|
|
4
|
+
import type {
|
|
5
|
+
ActionBattleAiIntent,
|
|
6
|
+
ActionBattleAiSimpleBehavior,
|
|
7
|
+
ActionBattleAiTreeInput,
|
|
8
|
+
} from "./ai-behavior-tree";
|
|
4
9
|
|
|
5
10
|
export type ActionBattleEntity = RpgPlayer | RpgEvent;
|
|
6
11
|
|
|
12
|
+
export type ActionBattleTargetSelector =
|
|
13
|
+
| "players"
|
|
14
|
+
| "events"
|
|
15
|
+
| "all"
|
|
16
|
+
| "hostile"
|
|
17
|
+
| string[]
|
|
18
|
+
| ((context: ActionBattleTargetContext) => boolean);
|
|
19
|
+
|
|
20
|
+
export interface ActionBattleTargetContext {
|
|
21
|
+
attacker: ActionBattleEntity;
|
|
22
|
+
target: ActionBattleEntity;
|
|
23
|
+
attackerFaction?: string;
|
|
24
|
+
targetFaction?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ActionBattleTargetOptions {
|
|
28
|
+
faction?: string;
|
|
29
|
+
targets?: ActionBattleTargetSelector;
|
|
30
|
+
getFaction?: (entity: ActionBattleEntity) => string | undefined;
|
|
31
|
+
canTarget?: (context: ActionBattleTargetContext) => boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ActionBattleActionMode = "instant" | "melee" | "projectile";
|
|
35
|
+
|
|
36
|
+
export type ActionBattleActionTarget = "enemy" | "ally" | "self" | "any";
|
|
37
|
+
|
|
38
|
+
export interface ActionBattleProjectileOptions {
|
|
39
|
+
type: string;
|
|
40
|
+
speed?: number;
|
|
41
|
+
range?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Random direction offset in degrees, applied as +/- half this value.
|
|
44
|
+
* Useful for less accurate ranged attacks.
|
|
45
|
+
*/
|
|
46
|
+
spreadDegrees?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Convenience precision value from 0 to 1. Ignored when `spreadDegrees` is set.
|
|
49
|
+
* `1` is perfectly accurate, `0` can deviate up to 30 degrees.
|
|
50
|
+
*/
|
|
51
|
+
accuracy?: number;
|
|
52
|
+
trajectory?: any;
|
|
53
|
+
direction?: { x: number; y: number };
|
|
54
|
+
origin?: { x: number; y: number };
|
|
55
|
+
collision?: any;
|
|
56
|
+
repeat?: any;
|
|
57
|
+
pattern?: any;
|
|
58
|
+
payload?: Record<string, unknown>;
|
|
59
|
+
params?: Record<string, unknown>;
|
|
60
|
+
onImpact?: (
|
|
61
|
+
context: ActionBattleProjectileImpactContext,
|
|
62
|
+
action: ActionBattleUseContext
|
|
63
|
+
) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ActionBattleActionConfig {
|
|
67
|
+
target?: ActionBattleActionTarget;
|
|
68
|
+
range?: number;
|
|
69
|
+
cooldownMs?: number;
|
|
70
|
+
mode?: ActionBattleActionMode;
|
|
71
|
+
projectile?: Omit<ActionBattleProjectileOptions, "onImpact">;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ActionBattleProjectileImpactContext {
|
|
75
|
+
attacker: ActionBattleEntity;
|
|
76
|
+
target?: ActionBattleEntity;
|
|
77
|
+
projectile: any;
|
|
78
|
+
hit: any;
|
|
79
|
+
map: any;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ActionBattleUseContext {
|
|
83
|
+
attacker: ActionBattleEntity;
|
|
84
|
+
user: ActionBattleEntity;
|
|
85
|
+
target?: ActionBattleEntity | ActionBattleEntity[] | null;
|
|
86
|
+
usable: any;
|
|
87
|
+
skill?: any;
|
|
88
|
+
weapon?: any;
|
|
89
|
+
action?: ActionBattleActionConfig;
|
|
90
|
+
pattern?: AttackPattern | string;
|
|
91
|
+
defaultEffect(target?: ActionBattleEntity | ActionBattleEntity[] | null): any;
|
|
92
|
+
damage(target?: ActionBattleEntity | null): any;
|
|
93
|
+
heal(
|
|
94
|
+
target: ActionBattleEntity | ActionBattleEntity[] | null | undefined,
|
|
95
|
+
amount: number
|
|
96
|
+
): number;
|
|
97
|
+
projectile(options?: ActionBattleProjectileOptions): any[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ActionBattleUsable {
|
|
101
|
+
id?: string;
|
|
102
|
+
_type?: string;
|
|
103
|
+
action?: ActionBattleActionConfig;
|
|
104
|
+
actionBattle?: ActionBattleActionConfig;
|
|
105
|
+
onUse?: (
|
|
106
|
+
user: ActionBattleEntity,
|
|
107
|
+
target: ActionBattleEntity | ActionBattleEntity[] | null | undefined,
|
|
108
|
+
action: ActionBattleUseContext
|
|
109
|
+
) => any;
|
|
110
|
+
}
|
|
111
|
+
|
|
7
112
|
export interface ActionBattleHitbox {
|
|
8
113
|
x: number;
|
|
9
114
|
y: number;
|
|
@@ -98,7 +203,7 @@ export interface ActionBattleCombatSystem {
|
|
|
98
203
|
|
|
99
204
|
export interface ActionBattleAiContext {
|
|
100
205
|
event: RpgEvent;
|
|
101
|
-
target:
|
|
206
|
+
target: ActionBattleEntity | null;
|
|
102
207
|
state: AiState;
|
|
103
208
|
enemyType: EnemyType;
|
|
104
209
|
distance: number | null;
|
|
@@ -111,6 +216,7 @@ export interface ActionBattleAiDecision {
|
|
|
111
216
|
attackPatterns?: AttackPattern[];
|
|
112
217
|
attackCooldown?: number;
|
|
113
218
|
moveToCooldown?: number;
|
|
219
|
+
intent?: ActionBattleAiIntent | ActionBattleAiIntent[];
|
|
114
220
|
metadata?: Record<string, any>;
|
|
115
221
|
}
|
|
116
222
|
|
|
@@ -118,9 +224,48 @@ export type ActionBattleAiBehavior = (
|
|
|
118
224
|
context: ActionBattleAiContext
|
|
119
225
|
) => ActionBattleAiDecision | void;
|
|
120
226
|
|
|
227
|
+
export interface ActionBattleAiPreset {
|
|
228
|
+
preset?: string | ActionBattleAiPreset;
|
|
229
|
+
faction?: string;
|
|
230
|
+
targets?: ActionBattleTargetSelector;
|
|
231
|
+
enemyType?: EnemyType;
|
|
232
|
+
attackCooldown?: number;
|
|
233
|
+
visionRange?: number;
|
|
234
|
+
attackRange?: number;
|
|
235
|
+
dodgeChance?: number;
|
|
236
|
+
dodgeCooldown?: number;
|
|
237
|
+
fleeThreshold?: number;
|
|
238
|
+
attackSkill?: any;
|
|
239
|
+
attackPatterns?: AttackPattern[];
|
|
240
|
+
attackProfiles?: any;
|
|
241
|
+
patrolWaypoints?: Array<{ x: number; y: number }>;
|
|
242
|
+
groupBehavior?: boolean;
|
|
243
|
+
moveToCooldown?: number;
|
|
244
|
+
retreatCooldown?: number;
|
|
245
|
+
poise?: number;
|
|
246
|
+
hitstunMs?: number;
|
|
247
|
+
invincibilityMs?: number;
|
|
248
|
+
behavior?: {
|
|
249
|
+
baseScore?: number;
|
|
250
|
+
updateInterval?: number;
|
|
251
|
+
minStateDuration?: number;
|
|
252
|
+
assaultThreshold?: number;
|
|
253
|
+
retreatThreshold?: number;
|
|
254
|
+
};
|
|
255
|
+
behaviorKey?: string;
|
|
256
|
+
tree?: ActionBattleAiTreeInput;
|
|
257
|
+
behaviorTree?: ActionBattleAiTreeInput;
|
|
258
|
+
simpleBehavior?: ActionBattleAiSimpleBehavior;
|
|
259
|
+
animations?: any;
|
|
260
|
+
rewards?: any;
|
|
261
|
+
autoAwardRewards?: boolean;
|
|
262
|
+
onDefeated?: any;
|
|
263
|
+
}
|
|
264
|
+
|
|
121
265
|
export interface ActionBattleSystems {
|
|
122
266
|
combat: ActionBattleCombatSystem;
|
|
123
267
|
ai: {
|
|
124
268
|
behaviors: Record<string, ActionBattleAiBehavior>;
|
|
269
|
+
presets: Record<string, ActionBattleAiPreset>;
|
|
125
270
|
};
|
|
126
271
|
}
|
package/src/core/defaults.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { RpgPlayer } from "@rpgjs/server";
|
|
2
2
|
import type {
|
|
3
3
|
ActionBattleAiBehavior,
|
|
4
|
+
ActionBattleAiPreset,
|
|
4
5
|
ActionBattleAttackContext,
|
|
5
6
|
ActionBattleCombatSystem,
|
|
6
7
|
ActionBattleDamageContext,
|
|
@@ -84,9 +85,24 @@ export const defaultRpgjsDamageResolver = (
|
|
|
84
85
|
context: ActionBattleDamageContext
|
|
85
86
|
) => {
|
|
86
87
|
const target = context.target as any;
|
|
88
|
+
const previousHp =
|
|
89
|
+
typeof target.hp === "number" && Number.isFinite(target.hp)
|
|
90
|
+
? target.hp
|
|
91
|
+
: undefined;
|
|
87
92
|
const raw = target.applyDamage(context.attacker as any, context.skill);
|
|
93
|
+
const resolvedDamage = Number(raw?.damage ?? 0);
|
|
94
|
+
if (!Number.isFinite(resolvedDamage)) {
|
|
95
|
+
if (previousHp !== undefined) {
|
|
96
|
+
target.hp = previousHp;
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
damage: 0,
|
|
100
|
+
defeated: false,
|
|
101
|
+
raw,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
88
104
|
return {
|
|
89
|
-
damage:
|
|
105
|
+
damage: resolvedDamage,
|
|
90
106
|
defeated: target.hp <= 0,
|
|
91
107
|
raw,
|
|
92
108
|
};
|
|
@@ -146,10 +162,65 @@ export const defaultEnemyBehaviors: Record<string, ActionBattleAiBehavior> = {
|
|
|
146
162
|
}),
|
|
147
163
|
};
|
|
148
164
|
|
|
165
|
+
export const defaultEnemyPresets: Record<string, ActionBattleAiPreset> = {
|
|
166
|
+
[CoreEnemyType.Aggressive]: {
|
|
167
|
+
enemyType: CoreEnemyType.Aggressive as any,
|
|
168
|
+
attackCooldown: 600,
|
|
169
|
+
visionRange: 150,
|
|
170
|
+
attackRange: 50,
|
|
171
|
+
dodgeChance: 0.1,
|
|
172
|
+
dodgeCooldown: 3000,
|
|
173
|
+
fleeThreshold: 0.15,
|
|
174
|
+
behaviorKey: CoreEnemyType.Aggressive,
|
|
175
|
+
},
|
|
176
|
+
[CoreEnemyType.Defensive]: {
|
|
177
|
+
enemyType: CoreEnemyType.Defensive as any,
|
|
178
|
+
attackCooldown: 1500,
|
|
179
|
+
visionRange: 120,
|
|
180
|
+
attackRange: 60,
|
|
181
|
+
dodgeChance: 0.5,
|
|
182
|
+
dodgeCooldown: 1500,
|
|
183
|
+
fleeThreshold: 0.3,
|
|
184
|
+
behaviorKey: CoreEnemyType.Defensive,
|
|
185
|
+
},
|
|
186
|
+
[CoreEnemyType.Ranged]: {
|
|
187
|
+
enemyType: CoreEnemyType.Ranged as any,
|
|
188
|
+
attackCooldown: 1200,
|
|
189
|
+
visionRange: 200,
|
|
190
|
+
attackRange: 120,
|
|
191
|
+
dodgeChance: 0.4,
|
|
192
|
+
dodgeCooldown: 2000,
|
|
193
|
+
fleeThreshold: 0.25,
|
|
194
|
+
behaviorKey: CoreEnemyType.Ranged,
|
|
195
|
+
},
|
|
196
|
+
[CoreEnemyType.Tank]: {
|
|
197
|
+
enemyType: CoreEnemyType.Tank as any,
|
|
198
|
+
attackCooldown: 2000,
|
|
199
|
+
visionRange: 100,
|
|
200
|
+
attackRange: 50,
|
|
201
|
+
dodgeChance: 0,
|
|
202
|
+
dodgeCooldown: 5000,
|
|
203
|
+
fleeThreshold: 0.1,
|
|
204
|
+
poise: 2,
|
|
205
|
+
behaviorKey: CoreEnemyType.Tank,
|
|
206
|
+
},
|
|
207
|
+
[CoreEnemyType.Berserker]: {
|
|
208
|
+
enemyType: CoreEnemyType.Berserker as any,
|
|
209
|
+
attackCooldown: 800,
|
|
210
|
+
visionRange: 180,
|
|
211
|
+
attackRange: 55,
|
|
212
|
+
dodgeChance: 0.15,
|
|
213
|
+
dodgeCooldown: 2500,
|
|
214
|
+
fleeThreshold: 0.05,
|
|
215
|
+
behaviorKey: CoreEnemyType.Berserker,
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
149
219
|
export const defaultActionBattleSystems: ActionBattleSystems = {
|
|
150
220
|
combat: defaultCombatSystem,
|
|
151
221
|
ai: {
|
|
152
222
|
behaviors: defaultEnemyBehaviors,
|
|
223
|
+
presets: defaultEnemyPresets,
|
|
153
224
|
},
|
|
154
225
|
};
|
|
155
226
|
|
package/src/core/equipment.ts
CHANGED
|
@@ -2,16 +2,20 @@ import type { ActionBattleAttackProfile } from "../types";
|
|
|
2
2
|
|
|
3
3
|
const resolveItemId = (item: any) => item?.id?.() ?? item?.id;
|
|
4
4
|
|
|
5
|
-
export function
|
|
6
|
-
entity: any
|
|
7
|
-
): ActionBattleAttackProfile | null {
|
|
5
|
+
export function resolveActionBattleWeapon(entity: any): any | null {
|
|
8
6
|
const equipments = entity?.equipments?.() || [];
|
|
9
7
|
for (const item of equipments) {
|
|
10
8
|
const itemId = resolveItemId(item);
|
|
11
9
|
const itemData = entity?.databaseById?.(itemId);
|
|
12
|
-
if (itemData?._type === "weapon"
|
|
13
|
-
return itemData
|
|
10
|
+
if (itemData?._type === "weapon") {
|
|
11
|
+
return itemData;
|
|
14
12
|
}
|
|
15
13
|
}
|
|
16
14
|
return null;
|
|
17
15
|
}
|
|
16
|
+
|
|
17
|
+
export function resolveActionBattleWeaponAttackProfile(
|
|
18
|
+
entity: any
|
|
19
|
+
): ActionBattleAttackProfile | null {
|
|
20
|
+
return resolveActionBattleWeapon(entity)?.attackProfile ?? null;
|
|
21
|
+
}
|
package/src/core/hit.spec.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
2
|
import { applyActionBattleHit } from "./hit";
|
|
3
|
+
import { defaultRpgjsDamageResolver } from "./defaults";
|
|
3
4
|
import type { ActionBattleCombatSystem } from "./contracts";
|
|
4
5
|
import { setActionBattleInvincibility } from "./hit-reaction";
|
|
5
6
|
|
|
@@ -108,4 +109,24 @@ describe("applyActionBattleHit", () => {
|
|
|
108
109
|
target: target as any,
|
|
109
110
|
}).cancelled).toBe(true);
|
|
110
111
|
});
|
|
112
|
+
|
|
113
|
+
test("normalizes invalid RPGJS damage without poisoning target hp", () => {
|
|
114
|
+
const attacker = entity();
|
|
115
|
+
const target = {
|
|
116
|
+
...entity(100),
|
|
117
|
+
applyDamage() {
|
|
118
|
+
this.hp = Number.NaN;
|
|
119
|
+
return { damage: Number.NaN };
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const result = defaultRpgjsDamageResolver({
|
|
124
|
+
attacker: attacker as any,
|
|
125
|
+
target: target as any,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(result.damage).toBe(0);
|
|
129
|
+
expect(result.defeated).toBe(false);
|
|
130
|
+
expect(target.hp).toBe(100);
|
|
131
|
+
});
|
|
111
132
|
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { RpgEvent } from "@rpgjs/server";
|
|
3
|
+
import { BattleAi } from "../ai.server";
|
|
4
|
+
import {
|
|
5
|
+
canActionBattleTarget,
|
|
6
|
+
getActionBattleEntityKind,
|
|
7
|
+
getActionBattleFaction,
|
|
8
|
+
getActionBattleTargets,
|
|
9
|
+
} from "./targets";
|
|
10
|
+
|
|
11
|
+
const player = (id: string, faction?: string) =>
|
|
12
|
+
({
|
|
13
|
+
id,
|
|
14
|
+
hp: 100,
|
|
15
|
+
actionBattleFaction: faction,
|
|
16
|
+
}) as any;
|
|
17
|
+
|
|
18
|
+
const battleEvent = (id: string, faction?: string) =>
|
|
19
|
+
({
|
|
20
|
+
id,
|
|
21
|
+
hp: 100,
|
|
22
|
+
actionBattleFaction: faction,
|
|
23
|
+
battleAi: {
|
|
24
|
+
getFaction: () => faction,
|
|
25
|
+
getTargets: () => "players",
|
|
26
|
+
},
|
|
27
|
+
attachShape() {},
|
|
28
|
+
}) as any;
|
|
29
|
+
|
|
30
|
+
describe("action battle targets", () => {
|
|
31
|
+
test("keeps player default targets on action battle events", () => {
|
|
32
|
+
const attacker = player("player-1");
|
|
33
|
+
const target = battleEvent("enemy-1", "enemies");
|
|
34
|
+
|
|
35
|
+
expect(getActionBattleTargets(attacker, "events")).toBe("events");
|
|
36
|
+
expect(canActionBattleTarget(attacker, target, "events")).toBe(true);
|
|
37
|
+
expect(canActionBattleTarget(attacker, player("player-2"), "events")).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("classifies runtime RpgEvent enemies before their RpgPlayer base class", () => {
|
|
41
|
+
const attacker = player("player-1");
|
|
42
|
+
const target = new RpgEvent() as any;
|
|
43
|
+
target.id = "enemy-1";
|
|
44
|
+
target.hp = 100;
|
|
45
|
+
target.battleAi = {
|
|
46
|
+
getFaction: () => "enemies",
|
|
47
|
+
getTargets: () => "players",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
expect(getActionBattleEntityKind(target)).toBe("event");
|
|
51
|
+
expect(canActionBattleTarget(attacker, target, "events")).toBe(true);
|
|
52
|
+
expect(canActionBattleTarget(attacker, target, "players")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("classifies runtime players with shape helpers as players", () => {
|
|
56
|
+
const attacker = battleEvent("enemy-1", "enemies");
|
|
57
|
+
const target = {
|
|
58
|
+
...player("player-1"),
|
|
59
|
+
attachShape() {},
|
|
60
|
+
isEvent: () => false,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
expect(getActionBattleEntityKind(target as any)).toBe("player");
|
|
64
|
+
expect(canActionBattleTarget(attacker, target as any, "players")).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("supports player targets explicitly", () => {
|
|
68
|
+
const attacker = player("player-1");
|
|
69
|
+
const target = player("player-2");
|
|
70
|
+
|
|
71
|
+
expect(canActionBattleTarget(attacker, target, "players")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("supports all targets", () => {
|
|
75
|
+
const attacker = battleEvent("enemy-1", "enemies");
|
|
76
|
+
|
|
77
|
+
expect(canActionBattleTarget(attacker, player("player-1"), "all")).toBe(true);
|
|
78
|
+
expect(canActionBattleTarget(attacker, battleEvent("enemy-2", "enemies"), "all")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("supports hostile targets from different factions", () => {
|
|
82
|
+
const guard = battleEvent("guard-1", "guards");
|
|
83
|
+
const guardAlly = battleEvent("guard-2", "guards");
|
|
84
|
+
const bandit = battleEvent("bandit-1", "bandits");
|
|
85
|
+
|
|
86
|
+
expect(canActionBattleTarget(guard, guardAlly, "hostile")).toBe(false);
|
|
87
|
+
expect(canActionBattleTarget(guard, bandit, "hostile")).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("supports explicit faction target lists", () => {
|
|
91
|
+
const guard = battleEvent("guard-1", "guards");
|
|
92
|
+
const bandit = battleEvent("bandit-1", "bandits");
|
|
93
|
+
const monster = battleEvent("monster-1", "monsters");
|
|
94
|
+
|
|
95
|
+
expect(canActionBattleTarget(guard, bandit, ["bandits"])).toBe(true);
|
|
96
|
+
expect(canActionBattleTarget(guard, monster, ["bandits"])).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("BattleAi exposes runtime faction and targets", () => {
|
|
100
|
+
const event = {
|
|
101
|
+
id: "guard-1",
|
|
102
|
+
hp: 100,
|
|
103
|
+
param: {},
|
|
104
|
+
attachShape: () => ({ id: "vision_guard-1" }),
|
|
105
|
+
getCurrentMap: () => ({}),
|
|
106
|
+
stopMoveTo() {},
|
|
107
|
+
};
|
|
108
|
+
const ai = new BattleAi(event as any, {
|
|
109
|
+
faction: "guards",
|
|
110
|
+
targets: ["bandits"],
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
expect(getActionBattleFaction(event as any)).toBe("guards");
|
|
114
|
+
expect(getActionBattleTargets(event as any, "players")).toEqual(["bandits"]);
|
|
115
|
+
|
|
116
|
+
ai.setFaction("bandits");
|
|
117
|
+
ai.setTargets("hostile");
|
|
118
|
+
|
|
119
|
+
expect(getActionBattleFaction(event as any)).toBe("bandits");
|
|
120
|
+
expect(getActionBattleTargets(event as any, "players")).toBe("hostile");
|
|
121
|
+
|
|
122
|
+
ai.destroy();
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { RpgEvent, RpgPlayer } from "@rpgjs/server";
|
|
2
|
+
import type {
|
|
3
|
+
ActionBattleEntity,
|
|
4
|
+
ActionBattleTargetContext,
|
|
5
|
+
ActionBattleTargetOptions,
|
|
6
|
+
ActionBattleTargetSelector,
|
|
7
|
+
} from "./contracts";
|
|
8
|
+
|
|
9
|
+
export const ACTION_BATTLE_PLAYER_FACTION = "players";
|
|
10
|
+
export const ACTION_BATTLE_ENEMY_FACTION = "enemies";
|
|
11
|
+
|
|
12
|
+
type EntityKind = "player" | "event";
|
|
13
|
+
|
|
14
|
+
const getBattleAi = (entity: ActionBattleEntity) => (entity as any)?.battleAi;
|
|
15
|
+
|
|
16
|
+
export const getActionBattleEntityKind = (
|
|
17
|
+
entity: ActionBattleEntity
|
|
18
|
+
): EntityKind => {
|
|
19
|
+
if (getBattleAi(entity)) return "event";
|
|
20
|
+
if (typeof (entity as any)?.isEvent === "function") {
|
|
21
|
+
return (entity as any).isEvent() ? "event" : "player";
|
|
22
|
+
}
|
|
23
|
+
if ((entity as any) instanceof RpgEvent) return "event";
|
|
24
|
+
if ((entity as any) instanceof RpgPlayer) return "player";
|
|
25
|
+
if (typeof (entity as any)?.attachShape === "function") return "event";
|
|
26
|
+
return "player";
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const isActionBattlePlayer = (entity: ActionBattleEntity): boolean =>
|
|
30
|
+
getActionBattleEntityKind(entity) === "player";
|
|
31
|
+
|
|
32
|
+
export const isActionBattleEvent = (entity: ActionBattleEntity): boolean =>
|
|
33
|
+
getActionBattleEntityKind(entity) === "event";
|
|
34
|
+
|
|
35
|
+
export const isActionBattleCombatEntity = (
|
|
36
|
+
entity: ActionBattleEntity | undefined | null
|
|
37
|
+
): entity is ActionBattleEntity => {
|
|
38
|
+
if (!entity) return false;
|
|
39
|
+
if (isActionBattlePlayer(entity as ActionBattleEntity)) return true;
|
|
40
|
+
return !!getBattleAi(entity as ActionBattleEntity);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const getActionBattleFaction = (
|
|
44
|
+
entity: ActionBattleEntity,
|
|
45
|
+
options: ActionBattleTargetOptions = {}
|
|
46
|
+
): string | undefined => {
|
|
47
|
+
const configured = options.getFaction?.(entity);
|
|
48
|
+
if (configured !== undefined) return configured;
|
|
49
|
+
|
|
50
|
+
const battleAi = getBattleAi(entity);
|
|
51
|
+
if (battleAi && typeof battleAi.getFaction === "function") {
|
|
52
|
+
const faction = battleAi.getFaction();
|
|
53
|
+
if (faction !== undefined) return faction;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const entityFaction =
|
|
57
|
+
(entity as any).actionBattleFaction ?? (entity as any).faction;
|
|
58
|
+
if (entityFaction !== undefined) return String(entityFaction);
|
|
59
|
+
|
|
60
|
+
if (isActionBattlePlayer(entity)) return ACTION_BATTLE_PLAYER_FACTION;
|
|
61
|
+
if (battleAi) return ACTION_BATTLE_ENEMY_FACTION;
|
|
62
|
+
return undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const getActionBattleTargets = (
|
|
66
|
+
entity: ActionBattleEntity,
|
|
67
|
+
fallback: ActionBattleTargetSelector
|
|
68
|
+
): ActionBattleTargetSelector => {
|
|
69
|
+
const battleAi = getBattleAi(entity);
|
|
70
|
+
if (battleAi && typeof battleAi.getTargets === "function") {
|
|
71
|
+
return battleAi.getTargets();
|
|
72
|
+
}
|
|
73
|
+
return (entity as any).actionBattleTargets ?? fallback;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const isActionBattleTargetDefeated = (
|
|
77
|
+
target: ActionBattleEntity | null | undefined
|
|
78
|
+
): boolean => {
|
|
79
|
+
if (!target) return true;
|
|
80
|
+
const hp = (target as any).hp;
|
|
81
|
+
return typeof hp === "number" && hp <= 0;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const matchesActionBattleTargetSelector = (
|
|
85
|
+
selector: ActionBattleTargetSelector | undefined,
|
|
86
|
+
context: ActionBattleTargetContext
|
|
87
|
+
): boolean => {
|
|
88
|
+
if (!selector) return false;
|
|
89
|
+
if (typeof selector === "function") return selector(context);
|
|
90
|
+
if (selector === "all") return true;
|
|
91
|
+
if (selector === "players") return isActionBattlePlayer(context.target);
|
|
92
|
+
if (selector === "events") return isActionBattleEvent(context.target);
|
|
93
|
+
if (selector === "hostile") {
|
|
94
|
+
return (
|
|
95
|
+
!!context.attackerFaction &&
|
|
96
|
+
!!context.targetFaction &&
|
|
97
|
+
context.attackerFaction !== context.targetFaction
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(selector)) {
|
|
101
|
+
return !!context.targetFaction && selector.includes(context.targetFaction);
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export const canActionBattleTarget = (
|
|
107
|
+
attacker: ActionBattleEntity,
|
|
108
|
+
target: ActionBattleEntity,
|
|
109
|
+
selector: ActionBattleTargetSelector | undefined,
|
|
110
|
+
options: ActionBattleTargetOptions = {}
|
|
111
|
+
): boolean => {
|
|
112
|
+
if (attacker === target) return false;
|
|
113
|
+
if (!isActionBattleCombatEntity(target)) return false;
|
|
114
|
+
if (isActionBattleTargetDefeated(target)) return false;
|
|
115
|
+
|
|
116
|
+
const context: ActionBattleTargetContext = {
|
|
117
|
+
attacker,
|
|
118
|
+
target,
|
|
119
|
+
attackerFaction: getActionBattleFaction(attacker, options),
|
|
120
|
+
targetFaction: getActionBattleFaction(target, options),
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const allowed = options.canTarget?.(context);
|
|
124
|
+
if (allowed !== undefined) return allowed;
|
|
125
|
+
|
|
126
|
+
return matchesActionBattleTargetSelector(selector, context);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
export const getActionBattleEntitiesInRange = (
|
|
130
|
+
attacker: ActionBattleEntity,
|
|
131
|
+
radius: number,
|
|
132
|
+
selector: ActionBattleTargetSelector | undefined,
|
|
133
|
+
options: ActionBattleTargetOptions = {}
|
|
134
|
+
): ActionBattleEntity[] => {
|
|
135
|
+
const map = (attacker as any).getCurrentMap?.();
|
|
136
|
+
if (!map) return [];
|
|
137
|
+
|
|
138
|
+
const candidates: ActionBattleEntity[] = [];
|
|
139
|
+
map.getPlayers?.().forEach((player: RpgPlayer) => candidates.push(player));
|
|
140
|
+
map.getEvents?.().forEach((event: RpgEvent) => candidates.push(event));
|
|
141
|
+
|
|
142
|
+
return candidates.filter((candidate) => {
|
|
143
|
+
if (!canActionBattleTarget(attacker, candidate, selector, options)) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
const dx = (attacker as any).x() - (candidate as any).x();
|
|
147
|
+
const dy = (attacker as any).y() - (candidate as any).y();
|
|
148
|
+
return Math.sqrt(dx * dx + dy * dy) <= radius;
|
|
149
|
+
});
|
|
150
|
+
};
|
package/src/enemies/factory.ts
CHANGED
|
@@ -7,6 +7,14 @@ export interface ActionBattleEnemyPreset extends BattleAiOptions {
|
|
|
7
7
|
|
|
8
8
|
export type ActionBattleEnemyPresetMap = Record<string, ActionBattleEnemyPreset>;
|
|
9
9
|
|
|
10
|
+
export const defineActionBattleEnemy = <T extends ActionBattleEnemyPreset>(
|
|
11
|
+
preset: T
|
|
12
|
+
): T => preset;
|
|
13
|
+
|
|
14
|
+
export const defineActionBattleAiPreset = <T extends BattleAiOptions>(
|
|
15
|
+
preset: T
|
|
16
|
+
): T => preset;
|
|
17
|
+
|
|
10
18
|
export const createActionEnemy = (
|
|
11
19
|
event: RpgEvent,
|
|
12
20
|
presetOrOptions: string | BattleAiOptions,
|