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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/LICENSE +19 -0
  3. package/README.md +392 -22
  4. package/dist/{ai.server.d.ts → client/ai.server.d.ts} +90 -28
  5. package/dist/client/animations.d.ts +16 -0
  6. package/dist/{client.d.ts → client/client.d.ts} +3 -2
  7. package/dist/{config.d.ts → client/config.d.ts} +2 -0
  8. package/dist/client/core/attack-profile.d.ts +9 -0
  9. package/dist/client/core/attack-runtime.d.ts +20 -0
  10. package/dist/client/core/context.d.ts +5 -0
  11. package/dist/client/core/defaults.d.ts +81 -0
  12. package/dist/client/core/enemy-attack-profiles.d.ts +6 -0
  13. package/dist/client/core/equipment.d.ts +2 -0
  14. package/dist/client/core/hit-reaction.d.ts +5 -0
  15. package/dist/client/core/hit.d.ts +2 -0
  16. package/dist/client/enemies/factory.d.ts +7 -0
  17. package/dist/client/index.d.ts +21 -0
  18. package/dist/client/index.js +24 -31
  19. package/dist/client/index10.js +61 -0
  20. package/dist/client/index11.js +55 -0
  21. package/dist/client/index12.js +106 -0
  22. package/dist/client/index13.js +143 -0
  23. package/dist/client/index14.js +25 -0
  24. package/dist/client/index15.js +72 -0
  25. package/dist/client/index16.js +1343 -0
  26. package/dist/client/index17.js +13 -0
  27. package/dist/client/index18.js +60 -0
  28. package/dist/client/index19.js +10 -0
  29. package/dist/client/index2.js +30 -45
  30. package/dist/client/index20.js +504 -0
  31. package/dist/client/index3.js +45 -1288
  32. package/dist/client/index4.js +105 -330
  33. package/dist/client/index5.js +84 -291
  34. package/dist/client/index6.js +309 -95
  35. package/dist/client/index7.js +35 -59
  36. package/dist/client/index8.js +101 -54
  37. package/dist/client/index9.js +79 -30
  38. package/dist/{server.d.ts → client/server.d.ts} +12 -4
  39. package/dist/client/ui/state.d.ts +35 -0
  40. package/dist/server/ai.server.d.ts +569 -0
  41. package/dist/server/animations.d.ts +16 -0
  42. package/dist/server/config.d.ts +5 -0
  43. package/dist/server/core/attack-profile.d.ts +9 -0
  44. package/dist/server/core/attack-runtime.d.ts +20 -0
  45. package/dist/server/core/context.d.ts +5 -0
  46. package/dist/server/core/defaults.d.ts +81 -0
  47. package/dist/server/core/enemy-attack-profiles.d.ts +6 -0
  48. package/dist/server/core/equipment.d.ts +2 -0
  49. package/dist/server/core/hit-reaction.d.ts +5 -0
  50. package/dist/server/core/hit.d.ts +2 -0
  51. package/dist/server/enemies/factory.d.ts +7 -0
  52. package/dist/server/index.d.ts +21 -0
  53. package/dist/server/index.js +23 -31
  54. package/dist/server/index10.js +1342 -0
  55. package/dist/server/index11.js +37 -0
  56. package/dist/server/index12.js +60 -0
  57. package/dist/server/index13.js +13 -0
  58. package/dist/server/index14.js +503 -0
  59. package/dist/server/index15.js +10 -0
  60. package/dist/server/index2.js +59 -332
  61. package/dist/server/index3.js +29 -1286
  62. package/dist/server/index4.js +45 -53
  63. package/dist/server/index5.js +107 -29
  64. package/dist/server/index6.js +143 -0
  65. package/dist/server/index7.js +25 -0
  66. package/dist/server/index8.js +72 -0
  67. package/dist/server/index9.js +55 -0
  68. package/dist/server/server.d.ts +106 -0
  69. package/dist/server/targeting.d.ts +19 -0
  70. package/package.json +15 -15
  71. package/src/ai.server.spec.ts +120 -0
  72. package/src/ai.server.ts +515 -91
  73. package/src/animations.ts +149 -0
  74. package/src/canvas-engine-shim.ts +4 -0
  75. package/src/client.ts +130 -2
  76. package/src/components/action-bar.ce +7 -5
  77. package/src/components/attack-preview.ce +90 -0
  78. package/src/config.ts +61 -0
  79. package/src/core/attack-profile.spec.ts +118 -0
  80. package/src/core/attack-profile.ts +100 -0
  81. package/src/core/attack-runtime.spec.ts +103 -0
  82. package/src/core/attack-runtime.ts +83 -0
  83. package/src/core/context.ts +35 -0
  84. package/src/core/contracts.ts +126 -0
  85. package/src/core/defaults.ts +162 -0
  86. package/src/core/enemy-attack-profiles.spec.ts +35 -0
  87. package/src/core/enemy-attack-profiles.ts +103 -0
  88. package/src/core/equipment.spec.ts +37 -0
  89. package/src/core/equipment.ts +17 -0
  90. package/src/core/hit-reaction.spec.ts +43 -0
  91. package/src/core/hit-reaction.ts +70 -0
  92. package/src/core/hit.spec.ts +111 -0
  93. package/src/core/hit.ts +92 -0
  94. package/src/enemies/factory.ts +25 -0
  95. package/src/index.ts +94 -1
  96. package/src/server.ts +427 -93
  97. package/src/targeting.spec.ts +24 -0
  98. package/src/types/canvas-engine.d.ts +4 -0
  99. package/src/types.ts +148 -0
  100. package/src/ui/state.ts +57 -0
  101. package/dist/index.d.ts +0 -11
  102. package/dist/ui/state.d.ts +0 -18
  103. /package/dist/{targeting.d.ts → client/targeting.d.ts} +0 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,35 @@
1
+ import type { ActionBattleOptions } from "../types";
2
+ import { defaultActionBattleSystems } from "./defaults";
3
+ import type { ActionBattleSystems } from "./contracts";
4
+
5
+ const mergeSystems = (options: ActionBattleOptions = {}): ActionBattleSystems => ({
6
+ combat: {
7
+ ...defaultActionBattleSystems.combat,
8
+ resolveDamage:
9
+ options.systems?.combat?.damage ??
10
+ defaultActionBattleSystems.combat.resolveDamage,
11
+ resolveKnockback:
12
+ options.systems?.combat?.knockback ??
13
+ defaultActionBattleSystems.combat.resolveKnockback,
14
+ hooks: {
15
+ ...defaultActionBattleSystems.combat.hooks,
16
+ ...options.systems?.combat?.hooks,
17
+ },
18
+ },
19
+ ai: {
20
+ behaviors: {
21
+ ...defaultActionBattleSystems.ai.behaviors,
22
+ ...options.systems?.ai?.behaviors,
23
+ },
24
+ },
25
+ });
26
+
27
+ let currentActionBattleSystems = mergeSystems();
28
+
29
+ export const setActionBattleSystems = (options: ActionBattleOptions = {}) => {
30
+ currentActionBattleSystems = mergeSystems(options);
31
+ };
32
+
33
+ export const getActionBattleSystems = () => currentActionBattleSystems;
34
+
35
+ export const createActionBattleSystems = mergeSystems;
@@ -0,0 +1,126 @@
1
+ import type { RpgEvent, RpgPlayer } from "@rpgjs/server";
2
+ import type { AttackPattern, EnemyType, AiState } from "../ai.server";
3
+ import type { NormalizedActionBattleHitReactionProfile } from "../types";
4
+
5
+ export type ActionBattleEntity = RpgPlayer | RpgEvent;
6
+
7
+ export interface ActionBattleHitbox {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ }
13
+
14
+ export interface ActionBattleDirection {
15
+ x: number;
16
+ y: number;
17
+ }
18
+
19
+ export interface ActionBattleAttackContext {
20
+ attacker: ActionBattleEntity;
21
+ target?: ActionBattleEntity | null;
22
+ direction?: string;
23
+ skill?: any;
24
+ pattern?: AttackPattern | string;
25
+ map?: any;
26
+ now: number;
27
+ }
28
+
29
+ export interface ActionBattleDamageContext {
30
+ attacker: ActionBattleEntity;
31
+ target: ActionBattleEntity;
32
+ skill?: any;
33
+ pattern?: AttackPattern | string;
34
+ }
35
+
36
+ export interface ActionBattleDamageResult {
37
+ damage: number;
38
+ defeated: boolean;
39
+ raw?: any;
40
+ }
41
+
42
+ export interface ActionBattleKnockbackContext {
43
+ attacker: ActionBattleEntity;
44
+ target: ActionBattleEntity;
45
+ damage: ActionBattleDamageResult;
46
+ weapon?: any;
47
+ }
48
+
49
+ export interface ActionBattleKnockbackResult {
50
+ force: number;
51
+ duration: number;
52
+ direction?: ActionBattleDirection;
53
+ }
54
+
55
+ export interface ActionBattleHitContext {
56
+ attacker: ActionBattleEntity;
57
+ target: ActionBattleEntity;
58
+ skill?: any;
59
+ pattern?: AttackPattern | string;
60
+ damage?: ActionBattleDamageResult;
61
+ knockback?: ActionBattleKnockbackResult;
62
+ reaction?: NormalizedActionBattleHitReactionProfile;
63
+ cancelled?: boolean;
64
+ metadata?: Record<string, any>;
65
+ }
66
+
67
+ export interface ActionBattleHitResult {
68
+ damage: number;
69
+ knockbackForce: number;
70
+ knockbackDuration: number;
71
+ defeated: boolean;
72
+ attacker: ActionBattleEntity;
73
+ target: ActionBattleEntity;
74
+ rawDamage?: any;
75
+ reaction?: NormalizedActionBattleHitReactionProfile;
76
+ cancelled?: boolean;
77
+ metadata?: Record<string, any>;
78
+ }
79
+
80
+ export interface ActionBattleHitHooks {
81
+ beforeHit?: (
82
+ context: ActionBattleHitContext
83
+ ) => ActionBattleHitContext | false | void;
84
+ afterDamage?: (
85
+ context: ActionBattleHitContext
86
+ ) => ActionBattleHitContext | void;
87
+ afterHit?: (result: ActionBattleHitResult) => void;
88
+ }
89
+
90
+ export interface ActionBattleCombatSystem {
91
+ resolveHitboxes(context: ActionBattleAttackContext): ActionBattleHitbox[];
92
+ resolveDamage(context: ActionBattleDamageContext): ActionBattleDamageResult;
93
+ resolveKnockback(
94
+ context: ActionBattleKnockbackContext
95
+ ): ActionBattleKnockbackResult;
96
+ hooks?: ActionBattleHitHooks;
97
+ }
98
+
99
+ export interface ActionBattleAiContext {
100
+ event: RpgEvent;
101
+ target: RpgPlayer | null;
102
+ state: AiState;
103
+ enemyType: EnemyType;
104
+ distance: number | null;
105
+ hpPercent: number | null;
106
+ now: number;
107
+ }
108
+
109
+ export interface ActionBattleAiDecision {
110
+ mode?: "assault" | "tactical" | "retreat";
111
+ attackPatterns?: AttackPattern[];
112
+ attackCooldown?: number;
113
+ moveToCooldown?: number;
114
+ metadata?: Record<string, any>;
115
+ }
116
+
117
+ export type ActionBattleAiBehavior = (
118
+ context: ActionBattleAiContext
119
+ ) => ActionBattleAiDecision | void;
120
+
121
+ export interface ActionBattleSystems {
122
+ combat: ActionBattleCombatSystem;
123
+ ai: {
124
+ behaviors: Record<string, ActionBattleAiBehavior>;
125
+ };
126
+ }
@@ -0,0 +1,162 @@
1
+ import type { RpgPlayer } from "@rpgjs/server";
2
+ import type {
3
+ ActionBattleAiBehavior,
4
+ ActionBattleAttackContext,
5
+ ActionBattleCombatSystem,
6
+ ActionBattleDamageContext,
7
+ ActionBattleKnockbackContext,
8
+ ActionBattleKnockbackResult,
9
+ ActionBattleSystems,
10
+ } from "./contracts";
11
+
12
+ const DEFAULT_CORE_KNOCKBACK = {
13
+ force: 50,
14
+ duration: 300,
15
+ };
16
+
17
+ const CoreAttackPattern = {
18
+ Melee: "melee",
19
+ Combo: "combo",
20
+ Charged: "charged",
21
+ Zone: "zone",
22
+ DashAttack: "dashAttack",
23
+ } as const;
24
+
25
+ const CoreEnemyType = {
26
+ Aggressive: "aggressive",
27
+ Defensive: "defensive",
28
+ Ranged: "ranged",
29
+ Tank: "tank",
30
+ Berserker: "berserker",
31
+ } as const;
32
+
33
+ export const DEFAULT_ZELDA_PLAYER_HITBOXES = {
34
+ up: { offsetX: -16, offsetY: -48, width: 32, height: 32 },
35
+ down: { offsetX: -16, offsetY: 16, width: 32, height: 32 },
36
+ left: { offsetX: -48, offsetY: -16, width: 32, height: 32 },
37
+ right: { offsetX: 16, offsetY: -16, width: 32, height: 32 },
38
+ default: { offsetX: 0, offsetY: -32, width: 32, height: 32 },
39
+ };
40
+
41
+ const resolveEquippedWeapon = (entity: any) => {
42
+ const equipments = entity?.equipments?.() || [];
43
+ for (const item of equipments) {
44
+ const itemId = item?.id?.() ?? item?.id;
45
+ const itemData = entity?.databaseById?.(itemId);
46
+ if (itemData?._type === "weapon") return itemData;
47
+ }
48
+ return null;
49
+ };
50
+
51
+ const resolveDirection = (attacker: any, target: any) => {
52
+ const dx = target.x() - attacker.x();
53
+ const dy = target.y() - attacker.y();
54
+ const distance = Math.sqrt(dx * dx + dy * dy);
55
+ if (distance <= 0) return undefined;
56
+ return {
57
+ x: dx / distance,
58
+ y: dy / distance,
59
+ };
60
+ };
61
+
62
+ export const createDefaultPlayerHitboxResolver =
63
+ (hitboxes = DEFAULT_ZELDA_PLAYER_HITBOXES) =>
64
+ (context: ActionBattleAttackContext) => {
65
+ const attacker = context.attacker as any;
66
+ const direction =
67
+ context.direction ??
68
+ (typeof attacker.getDirection === "function"
69
+ ? attacker.getDirection()
70
+ : "default");
71
+ const config =
72
+ hitboxes[direction as keyof typeof hitboxes] || hitboxes.default;
73
+ return [
74
+ {
75
+ x: attacker.x() + config.offsetX,
76
+ y: attacker.y() + config.offsetY,
77
+ width: config.width,
78
+ height: config.height,
79
+ },
80
+ ];
81
+ };
82
+
83
+ export const defaultRpgjsDamageResolver = (
84
+ context: ActionBattleDamageContext
85
+ ) => {
86
+ const target = context.target as any;
87
+ const raw = target.applyDamage(context.attacker as any, context.skill);
88
+ return {
89
+ damage: raw?.damage ?? 0,
90
+ defeated: target.hp <= 0,
91
+ raw,
92
+ };
93
+ };
94
+
95
+ export const defaultKnockbackResolver = (
96
+ context: ActionBattleKnockbackContext
97
+ ): ActionBattleKnockbackResult => {
98
+ const weapon = context.weapon ?? resolveEquippedWeapon(context.attacker);
99
+ return {
100
+ force: weapon?.knockbackForce ?? DEFAULT_CORE_KNOCKBACK.force,
101
+ duration: weapon?.knockbackDuration ?? DEFAULT_CORE_KNOCKBACK.duration,
102
+ direction: resolveDirection(context.attacker as any, context.target as any),
103
+ };
104
+ };
105
+
106
+ export const defaultCombatSystem: ActionBattleCombatSystem = {
107
+ resolveHitboxes: createDefaultPlayerHitboxResolver(),
108
+ resolveDamage: defaultRpgjsDamageResolver,
109
+ resolveKnockback: defaultKnockbackResolver,
110
+ };
111
+
112
+ export const defaultEnemyBehaviors: Record<string, ActionBattleAiBehavior> = {
113
+ [CoreEnemyType.Aggressive]: ({ hpPercent }) => ({
114
+ mode: hpPercent !== null && hpPercent < 0.15 ? "retreat" : "assault",
115
+ attackPatterns: [
116
+ CoreAttackPattern.Melee as any,
117
+ CoreAttackPattern.Combo as any,
118
+ CoreAttackPattern.DashAttack as any,
119
+ ],
120
+ }),
121
+ [CoreEnemyType.Defensive]: ({ hpPercent }) => ({
122
+ mode: hpPercent !== null && hpPercent < 0.3 ? "retreat" : "tactical",
123
+ attackPatterns: [CoreAttackPattern.Melee as any, CoreAttackPattern.Charged as any],
124
+ }),
125
+ [CoreEnemyType.Ranged]: ({ distance }) => ({
126
+ mode: distance !== null && distance < 80 ? "retreat" : "tactical",
127
+ attackPatterns: [CoreAttackPattern.Melee as any, CoreAttackPattern.Zone as any],
128
+ }),
129
+ [CoreEnemyType.Tank]: () => ({
130
+ mode: "assault",
131
+ attackPatterns: [
132
+ CoreAttackPattern.Melee as any,
133
+ CoreAttackPattern.Charged as any,
134
+ CoreAttackPattern.Zone as any,
135
+ ],
136
+ }),
137
+ [CoreEnemyType.Berserker]: ({ hpPercent }) => ({
138
+ mode: "assault",
139
+ attackCooldown:
140
+ hpPercent === null ? undefined : Math.max(250, 800 * Math.max(0.3, hpPercent)),
141
+ attackPatterns: [
142
+ CoreAttackPattern.Melee as any,
143
+ CoreAttackPattern.Combo as any,
144
+ CoreAttackPattern.DashAttack as any,
145
+ ],
146
+ }),
147
+ };
148
+
149
+ export const defaultActionBattleSystems: ActionBattleSystems = {
150
+ combat: defaultCombatSystem,
151
+ ai: {
152
+ behaviors: defaultEnemyBehaviors,
153
+ },
154
+ };
155
+
156
+ export const getEntityWeaponKnockbackForce = (entity: RpgPlayer): number => {
157
+ return defaultKnockbackResolver({
158
+ attacker: entity,
159
+ target: entity,
160
+ damage: { damage: 0, defeated: false },
161
+ }).force;
162
+ };
@@ -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
+ });