@rpgjs/action-battle 5.0.0-beta.5 → 5.0.0-beta.7
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/README.md +161 -22
- package/dist/ai.server.d.ts +55 -4
- package/dist/client/index.js +13 -8
- package/dist/client/index10.js +54 -136
- package/dist/client/index11.js +52 -23
- package/dist/client/index12.js +101 -1217
- package/dist/client/index13.js +139 -42
- package/dist/client/index14.js +23 -8
- package/dist/client/index15.js +68 -444
- package/dist/client/index16.js +1343 -0
- package/dist/client/index17.js +13 -0
- package/dist/client/index18.js +60 -0
- package/dist/client/index19.js +10 -0
- package/dist/client/index2.js +25 -87
- package/dist/client/index20.js +504 -0
- package/dist/client/index3.js +45 -83
- package/dist/client/index4.js +98 -297
- package/dist/client/index5.js +81 -33
- package/dist/client/index6.js +284 -78
- package/dist/client/index7.js +33 -74
- package/dist/client/index8.js +95 -55
- package/dist/client/index9.js +75 -96
- package/dist/core/attack-profile.d.ts +9 -0
- package/dist/core/attack-runtime.d.ts +20 -0
- package/dist/core/enemy-attack-profiles.d.ts +6 -0
- package/dist/core/equipment.d.ts +2 -0
- package/dist/core/hit-reaction.d.ts +5 -0
- package/dist/index.d.ts +7 -2
- package/dist/server/index.js +12 -7
- package/dist/server/index10.js +1340 -8
- package/dist/server/index11.js +37 -0
- package/dist/server/index12.js +60 -0
- package/dist/server/index13.js +13 -0
- package/dist/server/index14.js +503 -0
- package/dist/server/index15.js +10 -0
- package/dist/server/index2.js +1 -1
- package/dist/server/index3.js +25 -87
- package/dist/server/index4.js +45 -141
- package/dist/server/index5.js +104 -21
- package/dist/server/index6.js +137 -1215
- package/dist/server/index7.js +22 -34
- package/dist/server/index8.js +70 -44
- package/dist/server/index9.js +44 -437
- package/dist/server.d.ts +8 -2
- package/dist/ui/state.d.ts +5 -5
- package/package.json +5 -5
- package/src/ai.server.spec.ts +120 -0
- package/src/ai.server.ts +362 -56
- package/src/client.ts +21 -12
- package/src/config.ts +17 -2
- package/src/core/attack-profile.spec.ts +118 -0
- package/src/core/attack-profile.ts +100 -0
- package/src/core/attack-runtime.spec.ts +103 -0
- package/src/core/attack-runtime.ts +83 -0
- package/src/core/contracts.ts +3 -0
- package/src/core/enemy-attack-profiles.spec.ts +35 -0
- package/src/core/enemy-attack-profiles.ts +103 -0
- package/src/core/equipment.spec.ts +37 -0
- package/src/core/equipment.ts +17 -0
- package/src/core/hit-reaction.spec.ts +43 -0
- package/src/core/hit-reaction.ts +70 -0
- package/src/core/hit.spec.ts +54 -1
- package/src/core/hit.ts +26 -0
- package/src/index.ts +48 -1
- package/src/server.ts +192 -34
- package/src/types.ts +62 -6
package/src/client.ts
CHANGED
|
@@ -14,12 +14,14 @@ import {
|
|
|
14
14
|
import { ActionBattleOptions } from "./types";
|
|
15
15
|
import { normalizeActionBattleOptions } from "./config";
|
|
16
16
|
import { resolveActionBattleAnimation } from "./animations";
|
|
17
|
+
import { getNormalizedActionBattleAttackProfile } from "./core/attack-runtime";
|
|
17
18
|
|
|
18
19
|
const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
|
|
19
20
|
|
|
20
21
|
const beginLocalPlayerAttackLock = (
|
|
21
22
|
engine: RpgClientEngine,
|
|
22
|
-
durationMs: number
|
|
23
|
+
durationMs: number,
|
|
24
|
+
locks: { movement: boolean; direction: boolean }
|
|
23
25
|
): boolean => {
|
|
24
26
|
if (durationMs <= 0) return true;
|
|
25
27
|
|
|
@@ -44,13 +46,17 @@ const beginLocalPlayerAttackLock = (
|
|
|
44
46
|
const previousDirectionFixed = player.directionFixed;
|
|
45
47
|
const previousAnimationFixed = player.animationFixed;
|
|
46
48
|
|
|
47
|
-
if (
|
|
48
|
-
engine.interruptCurrentPlayerMovement
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
if (locks.movement) {
|
|
50
|
+
if (typeof engine.interruptCurrentPlayerMovement === "function") {
|
|
51
|
+
engine.interruptCurrentPlayerMovement(player);
|
|
52
|
+
} else {
|
|
53
|
+
(engine.scene as any)?.stopMovement?.(player);
|
|
54
|
+
}
|
|
55
|
+
player.canMove.set(false);
|
|
56
|
+
}
|
|
57
|
+
if (locks.direction) {
|
|
58
|
+
player.directionFixed = true;
|
|
51
59
|
}
|
|
52
|
-
player.canMove.set(false);
|
|
53
|
-
player.directionFixed = true;
|
|
54
60
|
player.animationFixed = true;
|
|
55
61
|
|
|
56
62
|
setTimeout(() => {
|
|
@@ -154,14 +160,17 @@ export const createActionBattleClient = (
|
|
|
154
160
|
if (input !== "action") return;
|
|
155
161
|
const player = engine.scene?.getCurrentPlayer?.() as any;
|
|
156
162
|
if (!player) return;
|
|
163
|
+
const attackProfile = getNormalizedActionBattleAttackProfile(normalized);
|
|
157
164
|
const lockDurationMs = Math.max(
|
|
158
165
|
0,
|
|
159
|
-
|
|
160
|
-
);
|
|
161
|
-
beginLocalPlayerAttackLock(
|
|
162
|
-
engine,
|
|
163
|
-
normalized.attack?.lockMovement === false ? 0 : lockDurationMs
|
|
166
|
+
attackProfile.totalDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS
|
|
164
167
|
);
|
|
168
|
+
if (attackProfile.movementLock || attackProfile.directionLock) {
|
|
169
|
+
beginLocalPlayerAttackLock(engine, lockDurationMs, {
|
|
170
|
+
movement: attackProfile.movementLock,
|
|
171
|
+
direction: attackProfile.directionLock,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
165
174
|
playLocalPlayerAttackAnimation(player, normalized);
|
|
166
175
|
showLocalAttackPreview(player, normalized);
|
|
167
176
|
},
|
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: {
|
|
@@ -41,6 +42,16 @@ let currentActionBattleOptions: ActionBattleOptions =
|
|
|
41
42
|
export function normalizeActionBattleOptions(
|
|
42
43
|
options: ActionBattleOptions = {}
|
|
43
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
|
+
|
|
44
55
|
return {
|
|
45
56
|
ui: {
|
|
46
57
|
actionBar: {
|
|
@@ -64,9 +75,13 @@ export function normalizeActionBattleOptions(
|
|
|
64
75
|
...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
|
|
65
76
|
...options.targeting,
|
|
66
77
|
},
|
|
78
|
+
debug: {
|
|
79
|
+
...DEFAULT_ACTION_BATTLE_OPTIONS.debug,
|
|
80
|
+
...options.debug,
|
|
81
|
+
},
|
|
67
82
|
attack: {
|
|
68
|
-
...
|
|
69
|
-
|
|
83
|
+
...attack,
|
|
84
|
+
profile: attackProfile,
|
|
70
85
|
},
|
|
71
86
|
animations: {
|
|
72
87
|
...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActionBattleAttackHitboxMap,
|
|
3
|
+
ActionBattleAttackHitPolicy,
|
|
4
|
+
ActionBattleAttackProfile,
|
|
5
|
+
ActionBattleAnimationKey,
|
|
6
|
+
NormalizedActionBattleAttackProfile,
|
|
7
|
+
} from "../types";
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_ACTION_BATTLE_HIT_REACTION,
|
|
10
|
+
normalizeActionBattleHitReaction,
|
|
11
|
+
} from "./hit-reaction";
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_ACTION_BATTLE_ATTACK_PROFILE:
|
|
14
|
+
NormalizedActionBattleAttackProfile = {
|
|
15
|
+
id: "basic",
|
|
16
|
+
startupMs: 0,
|
|
17
|
+
activeMs: 120,
|
|
18
|
+
recoveryMs: 230,
|
|
19
|
+
cooldownMs: 350,
|
|
20
|
+
movementLock: true,
|
|
21
|
+
directionLock: true,
|
|
22
|
+
animationKey: "attack",
|
|
23
|
+
hitPolicy: "oncePerTarget",
|
|
24
|
+
reaction: DEFAULT_ACTION_BATTLE_HIT_REACTION,
|
|
25
|
+
totalDurationMs: 350,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export interface ActionBattleAttackProfileFallbacks {
|
|
29
|
+
id?: string;
|
|
30
|
+
lockMovement?: boolean;
|
|
31
|
+
lockDurationMs?: number;
|
|
32
|
+
hitboxes?: ActionBattleAttackHitboxMap;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const isFiniteNumber = (value: unknown): value is number =>
|
|
36
|
+
typeof value === "number" && Number.isFinite(value);
|
|
37
|
+
|
|
38
|
+
const nonNegativeMs = (value: unknown, fallback: number) =>
|
|
39
|
+
isFiniteNumber(value) ? Math.max(0, value) : fallback;
|
|
40
|
+
|
|
41
|
+
const positiveMs = (value: unknown, fallback: number) =>
|
|
42
|
+
isFiniteNumber(value) ? Math.max(1, value) : fallback;
|
|
43
|
+
|
|
44
|
+
const resolveHitPolicy = (
|
|
45
|
+
value: ActionBattleAttackHitPolicy | undefined
|
|
46
|
+
): ActionBattleAttackHitPolicy =>
|
|
47
|
+
value === "allowRepeatHits" ? "allowRepeatHits" : "oncePerTarget";
|
|
48
|
+
|
|
49
|
+
const resolveAnimationKey = (
|
|
50
|
+
value: ActionBattleAnimationKey | undefined
|
|
51
|
+
): ActionBattleAnimationKey =>
|
|
52
|
+
value ?? DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.animationKey;
|
|
53
|
+
|
|
54
|
+
export function normalizeActionBattleAttackProfile(
|
|
55
|
+
profile: ActionBattleAttackProfile | undefined = {},
|
|
56
|
+
fallbacks: ActionBattleAttackProfileFallbacks = {}
|
|
57
|
+
): NormalizedActionBattleAttackProfile {
|
|
58
|
+
const startupMs = nonNegativeMs(
|
|
59
|
+
profile.startupMs,
|
|
60
|
+
DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.startupMs
|
|
61
|
+
);
|
|
62
|
+
const activeMs = positiveMs(
|
|
63
|
+
profile.activeMs,
|
|
64
|
+
DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.activeMs
|
|
65
|
+
);
|
|
66
|
+
const legacyDuration = nonNegativeMs(
|
|
67
|
+
fallbacks.lockDurationMs,
|
|
68
|
+
DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.totalDurationMs
|
|
69
|
+
);
|
|
70
|
+
const fallbackRecoveryMs = Math.max(0, legacyDuration - startupMs - activeMs);
|
|
71
|
+
const recoveryMs = nonNegativeMs(profile.recoveryMs, fallbackRecoveryMs);
|
|
72
|
+
const totalDurationMs = startupMs + activeMs + recoveryMs;
|
|
73
|
+
const cooldownMs = nonNegativeMs(profile.cooldownMs, totalDurationMs);
|
|
74
|
+
const hitboxes = profile.hitboxes ?? fallbacks.hitboxes;
|
|
75
|
+
|
|
76
|
+
const normalized: NormalizedActionBattleAttackProfile = {
|
|
77
|
+
id: profile.id || fallbacks.id || DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.id,
|
|
78
|
+
startupMs,
|
|
79
|
+
activeMs,
|
|
80
|
+
recoveryMs,
|
|
81
|
+
cooldownMs,
|
|
82
|
+
movementLock:
|
|
83
|
+
profile.movementLock ??
|
|
84
|
+
fallbacks.lockMovement ??
|
|
85
|
+
DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.movementLock,
|
|
86
|
+
directionLock:
|
|
87
|
+
profile.directionLock ??
|
|
88
|
+
DEFAULT_ACTION_BATTLE_ATTACK_PROFILE.directionLock,
|
|
89
|
+
animationKey: resolveAnimationKey(profile.animationKey),
|
|
90
|
+
hitPolicy: resolveHitPolicy(profile.hitPolicy),
|
|
91
|
+
reaction: normalizeActionBattleHitReaction(profile.reaction),
|
|
92
|
+
totalDurationMs,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (hitboxes) {
|
|
96
|
+
normalized.hitboxes = hitboxes;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ActionBattleHitTracker,
|
|
4
|
+
createActionBattleAttackId,
|
|
5
|
+
getNormalizedActionBattleAttackProfile,
|
|
6
|
+
resolveActionBattleHitboxSpeed,
|
|
7
|
+
scheduleActionBattleStartup,
|
|
8
|
+
} from "./attack-runtime";
|
|
9
|
+
|
|
10
|
+
describe("attack runtime helpers", () => {
|
|
11
|
+
test("resolves a normalized profile from action battle options", () => {
|
|
12
|
+
const profile = getNormalizedActionBattleAttackProfile({
|
|
13
|
+
attack: {
|
|
14
|
+
lockDurationMs: 420,
|
|
15
|
+
profile: {
|
|
16
|
+
startupMs: 100,
|
|
17
|
+
activeMs: 80,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(profile).toMatchObject({
|
|
23
|
+
startupMs: 100,
|
|
24
|
+
activeMs: 80,
|
|
25
|
+
recoveryMs: 240,
|
|
26
|
+
totalDurationMs: 420,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("maps activeMs to moving hitbox speed", () => {
|
|
31
|
+
const profile = getNormalizedActionBattleAttackProfile({
|
|
32
|
+
attack: {
|
|
33
|
+
profile: {
|
|
34
|
+
activeMs: 96,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
expect(resolveActionBattleHitboxSpeed(profile, 1)).toBe(6);
|
|
40
|
+
expect(resolveActionBattleHitboxSpeed(profile, 3)).toBe(2);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("runs startup immediately when there is no wind-up", () => {
|
|
44
|
+
const callback = vi.fn();
|
|
45
|
+
const scheduler = vi.fn();
|
|
46
|
+
const profile = getNormalizedActionBattleAttackProfile({
|
|
47
|
+
attack: {
|
|
48
|
+
profile: {
|
|
49
|
+
startupMs: 0,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const timer = scheduleActionBattleStartup(profile, callback, scheduler);
|
|
55
|
+
|
|
56
|
+
expect(timer).toBeNull();
|
|
57
|
+
expect(callback).toHaveBeenCalledOnce();
|
|
58
|
+
expect(scheduler).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("schedules startup when wind-up is configured", () => {
|
|
62
|
+
const callback = vi.fn();
|
|
63
|
+
const scheduler = vi.fn(() => "timer-id");
|
|
64
|
+
const profile = getNormalizedActionBattleAttackProfile({
|
|
65
|
+
attack: {
|
|
66
|
+
profile: {
|
|
67
|
+
startupMs: 120,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const timer = scheduleActionBattleStartup(profile, callback, scheduler);
|
|
73
|
+
|
|
74
|
+
expect(timer).toBe("timer-id");
|
|
75
|
+
expect(callback).not.toHaveBeenCalled();
|
|
76
|
+
expect(scheduler).toHaveBeenCalledWith(callback, 120);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("creates stable unique attack ids", () => {
|
|
80
|
+
const first = createActionBattleAttackId("player-1", "sword");
|
|
81
|
+
const second = createActionBattleAttackId("player-1", "sword");
|
|
82
|
+
|
|
83
|
+
expect(first).toContain("player-1:sword:");
|
|
84
|
+
expect(second).toContain("player-1:sword:");
|
|
85
|
+
expect(first).not.toBe(second);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("tracks once-per-target hit policy", () => {
|
|
89
|
+
const tracker = new ActionBattleHitTracker("oncePerTarget");
|
|
90
|
+
const target = { id: "enemy-1" };
|
|
91
|
+
|
|
92
|
+
expect(tracker.tryHit(target)).toBe(true);
|
|
93
|
+
expect(tracker.tryHit(target)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("allows repeated hits when configured", () => {
|
|
97
|
+
const tracker = new ActionBattleHitTracker("allowRepeatHits");
|
|
98
|
+
const target = { id: "enemy-1" };
|
|
99
|
+
|
|
100
|
+
expect(tracker.tryHit(target)).toBe(true);
|
|
101
|
+
expect(tracker.tryHit(target)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActionBattleAttackHitPolicy,
|
|
3
|
+
ActionBattleOptions,
|
|
4
|
+
NormalizedActionBattleAttackProfile,
|
|
5
|
+
} from "../types";
|
|
6
|
+
import { normalizeActionBattleAttackProfile } from "./attack-profile";
|
|
7
|
+
|
|
8
|
+
export const ACTION_BATTLE_HITBOX_FRAME_MS = 16;
|
|
9
|
+
|
|
10
|
+
export function getNormalizedActionBattleAttackProfile(
|
|
11
|
+
options: ActionBattleOptions = {}
|
|
12
|
+
): NormalizedActionBattleAttackProfile {
|
|
13
|
+
const attack = options.attack ?? {};
|
|
14
|
+
return normalizeActionBattleAttackProfile(attack.profile, {
|
|
15
|
+
lockMovement: attack.lockMovement,
|
|
16
|
+
lockDurationMs: attack.lockDurationMs,
|
|
17
|
+
hitboxes: attack.hitboxes,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveActionBattleHitboxSpeed(
|
|
22
|
+
profile: NormalizedActionBattleAttackProfile,
|
|
23
|
+
hitboxCount: number
|
|
24
|
+
): number {
|
|
25
|
+
const positions = Math.max(1, Math.floor(hitboxCount));
|
|
26
|
+
const activeFrames = Math.max(
|
|
27
|
+
1,
|
|
28
|
+
Math.ceil(profile.activeMs / ACTION_BATTLE_HITBOX_FRAME_MS)
|
|
29
|
+
);
|
|
30
|
+
return Math.max(1, Math.ceil(activeFrames / positions));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function scheduleActionBattleStartup(
|
|
34
|
+
profile: NormalizedActionBattleAttackProfile,
|
|
35
|
+
callback: () => void,
|
|
36
|
+
scheduler: (callback: () => void, delayMs: number) => unknown = setTimeout
|
|
37
|
+
) {
|
|
38
|
+
if (profile.startupMs <= 0) {
|
|
39
|
+
callback();
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return scheduler(callback, profile.startupMs);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let attackIdCounter = 0;
|
|
46
|
+
|
|
47
|
+
export function createActionBattleAttackId(
|
|
48
|
+
attackerId: string | number | undefined,
|
|
49
|
+
profileId: string
|
|
50
|
+
): string {
|
|
51
|
+
attackIdCounter++;
|
|
52
|
+
return `${attackerId ?? "unknown"}:${profileId}:${Date.now()}:${attackIdCounter}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const getTargetKey = (target: { id?: string | number } | undefined) => {
|
|
56
|
+
if (!target || target.id === undefined || target.id === null) return null;
|
|
57
|
+
return String(target.id);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export class ActionBattleHitTracker {
|
|
61
|
+
private hitTargets = new Set<string>();
|
|
62
|
+
|
|
63
|
+
constructor(private readonly hitPolicy: ActionBattleAttackHitPolicy) {}
|
|
64
|
+
|
|
65
|
+
canHit(target: { id?: string | number } | undefined): boolean {
|
|
66
|
+
if (this.hitPolicy === "allowRepeatHits") return true;
|
|
67
|
+
const key = getTargetKey(target);
|
|
68
|
+
return !key || !this.hitTargets.has(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
recordHit(target: { id?: string | number } | undefined): void {
|
|
72
|
+
const key = getTargetKey(target);
|
|
73
|
+
if (key) {
|
|
74
|
+
this.hitTargets.add(key);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
tryHit(target: { id?: string | number } | undefined): boolean {
|
|
79
|
+
if (!this.canHit(target)) return false;
|
|
80
|
+
this.recordHit(target);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/core/contracts.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { RpgEvent, RpgPlayer } from "@rpgjs/server";
|
|
2
2
|
import type { AttackPattern, EnemyType, AiState } from "../ai.server";
|
|
3
|
+
import type { NormalizedActionBattleHitReactionProfile } from "../types";
|
|
3
4
|
|
|
4
5
|
export type ActionBattleEntity = RpgPlayer | RpgEvent;
|
|
5
6
|
|
|
@@ -58,6 +59,7 @@ export interface ActionBattleHitContext {
|
|
|
58
59
|
pattern?: AttackPattern | string;
|
|
59
60
|
damage?: ActionBattleDamageResult;
|
|
60
61
|
knockback?: ActionBattleKnockbackResult;
|
|
62
|
+
reaction?: NormalizedActionBattleHitReactionProfile;
|
|
61
63
|
cancelled?: boolean;
|
|
62
64
|
metadata?: Record<string, any>;
|
|
63
65
|
}
|
|
@@ -70,6 +72,7 @@ export interface ActionBattleHitResult {
|
|
|
70
72
|
attacker: ActionBattleEntity;
|
|
71
73
|
target: ActionBattleEntity;
|
|
72
74
|
rawDamage?: any;
|
|
75
|
+
reaction?: NormalizedActionBattleHitReactionProfile;
|
|
73
76
|
cancelled?: boolean;
|
|
74
77
|
metadata?: Record<string, any>;
|
|
75
78
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES,
|
|
4
|
+
normalizeActionBattleEnemyAttackProfiles,
|
|
5
|
+
} from "./enemy-attack-profiles";
|
|
6
|
+
|
|
7
|
+
describe("enemy attack profiles", () => {
|
|
8
|
+
test("normalizes every built-in enemy attack pattern", () => {
|
|
9
|
+
const profiles = normalizeActionBattleEnemyAttackProfiles();
|
|
10
|
+
|
|
11
|
+
expect(Object.keys(profiles).sort()).toEqual(
|
|
12
|
+
Object.keys(DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES).sort()
|
|
13
|
+
);
|
|
14
|
+
expect(profiles.charged.startupMs).toBe(800);
|
|
15
|
+
expect(profiles.zone.reaction.staggerPower).toBe(1.25);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("overrides individual pattern timing", () => {
|
|
19
|
+
const profiles = normalizeActionBattleEnemyAttackProfiles({
|
|
20
|
+
melee: {
|
|
21
|
+
startupMs: 60,
|
|
22
|
+
activeMs: 40,
|
|
23
|
+
recoveryMs: 100,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(profiles.melee).toMatchObject({
|
|
28
|
+
startupMs: 60,
|
|
29
|
+
activeMs: 40,
|
|
30
|
+
recoveryMs: 100,
|
|
31
|
+
totalDurationMs: 200,
|
|
32
|
+
});
|
|
33
|
+
expect(profiles.charged.startupMs).toBe(800);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActionBattleAttackProfile,
|
|
3
|
+
NormalizedActionBattleAttackProfile,
|
|
4
|
+
} from "../types";
|
|
5
|
+
import { normalizeActionBattleAttackProfile } from "./attack-profile";
|
|
6
|
+
|
|
7
|
+
export type ActionBattleEnemyAttackProfileKey =
|
|
8
|
+
| "melee"
|
|
9
|
+
| "combo"
|
|
10
|
+
| "charged"
|
|
11
|
+
| "zone"
|
|
12
|
+
| "dashAttack";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES: Record<
|
|
15
|
+
ActionBattleEnemyAttackProfileKey,
|
|
16
|
+
ActionBattleAttackProfile
|
|
17
|
+
> = {
|
|
18
|
+
melee: {
|
|
19
|
+
id: "enemy-melee",
|
|
20
|
+
startupMs: 120,
|
|
21
|
+
activeMs: 100,
|
|
22
|
+
recoveryMs: 220,
|
|
23
|
+
cooldownMs: 440,
|
|
24
|
+
reaction: {
|
|
25
|
+
invincibilityMs: 250,
|
|
26
|
+
hitstunMs: 120,
|
|
27
|
+
staggerPower: 1,
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
combo: {
|
|
31
|
+
id: "enemy-combo",
|
|
32
|
+
startupMs: 80,
|
|
33
|
+
activeMs: 80,
|
|
34
|
+
recoveryMs: 140,
|
|
35
|
+
cooldownMs: 300,
|
|
36
|
+
reaction: {
|
|
37
|
+
invincibilityMs: 180,
|
|
38
|
+
hitstunMs: 90,
|
|
39
|
+
staggerPower: 0.75,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
charged: {
|
|
43
|
+
id: "enemy-charged",
|
|
44
|
+
startupMs: 800,
|
|
45
|
+
activeMs: 140,
|
|
46
|
+
recoveryMs: 320,
|
|
47
|
+
cooldownMs: 1260,
|
|
48
|
+
reaction: {
|
|
49
|
+
invincibilityMs: 350,
|
|
50
|
+
hitstunMs: 220,
|
|
51
|
+
staggerPower: 2,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
zone: {
|
|
55
|
+
id: "enemy-zone",
|
|
56
|
+
startupMs: 450,
|
|
57
|
+
activeMs: 180,
|
|
58
|
+
recoveryMs: 320,
|
|
59
|
+
cooldownMs: 950,
|
|
60
|
+
reaction: {
|
|
61
|
+
invincibilityMs: 300,
|
|
62
|
+
hitstunMs: 160,
|
|
63
|
+
staggerPower: 1.25,
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
dashAttack: {
|
|
67
|
+
id: "enemy-dash",
|
|
68
|
+
startupMs: 180,
|
|
69
|
+
activeMs: 120,
|
|
70
|
+
recoveryMs: 260,
|
|
71
|
+
cooldownMs: 560,
|
|
72
|
+
reaction: {
|
|
73
|
+
invincibilityMs: 280,
|
|
74
|
+
hitstunMs: 150,
|
|
75
|
+
staggerPower: 1.2,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type ActionBattleEnemyAttackProfileMap = Partial<
|
|
81
|
+
Record<ActionBattleEnemyAttackProfileKey, ActionBattleAttackProfile>
|
|
82
|
+
>;
|
|
83
|
+
|
|
84
|
+
export type NormalizedActionBattleEnemyAttackProfileMap = Record<
|
|
85
|
+
ActionBattleEnemyAttackProfileKey,
|
|
86
|
+
NormalizedActionBattleAttackProfile
|
|
87
|
+
>;
|
|
88
|
+
|
|
89
|
+
export function normalizeActionBattleEnemyAttackProfiles(
|
|
90
|
+
overrides: ActionBattleEnemyAttackProfileMap = {}
|
|
91
|
+
): NormalizedActionBattleEnemyAttackProfileMap {
|
|
92
|
+
return Object.fromEntries(
|
|
93
|
+
Object.entries(DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES).map(
|
|
94
|
+
([key, defaultProfile]) => [
|
|
95
|
+
key,
|
|
96
|
+
normalizeActionBattleAttackProfile({
|
|
97
|
+
...defaultProfile,
|
|
98
|
+
...overrides[key as ActionBattleEnemyAttackProfileKey],
|
|
99
|
+
}),
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
) as NormalizedActionBattleEnemyAttackProfileMap;
|
|
103
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { resolveActionBattleWeaponAttackProfile } from "./equipment";
|
|
3
|
+
|
|
4
|
+
describe("equipment helpers", () => {
|
|
5
|
+
test("resolves attack profile from equipped weapon data", () => {
|
|
6
|
+
const attackProfile = {
|
|
7
|
+
id: "dagger",
|
|
8
|
+
startupMs: 40,
|
|
9
|
+
activeMs: 70,
|
|
10
|
+
recoveryMs: 120,
|
|
11
|
+
};
|
|
12
|
+
const entity = {
|
|
13
|
+
equipments: () => [{ id: () => "dagger" }],
|
|
14
|
+
databaseById: (id: string) =>
|
|
15
|
+
id === "dagger"
|
|
16
|
+
? {
|
|
17
|
+
_type: "weapon",
|
|
18
|
+
attackProfile,
|
|
19
|
+
}
|
|
20
|
+
: null,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
expect(resolveActionBattleWeaponAttackProfile(entity)).toBe(attackProfile);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("ignores non-weapon equipment", () => {
|
|
27
|
+
const entity = {
|
|
28
|
+
equipments: () => [{ id: () => "ring" }],
|
|
29
|
+
databaseById: () => ({
|
|
30
|
+
_type: "armor",
|
|
31
|
+
attackProfile: { id: "invalid" },
|
|
32
|
+
}),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
expect(resolveActionBattleWeaponAttackProfile(entity)).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
});
|