@rpgjs/action-battle 5.0.0-beta.2 → 5.0.0-beta.4

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 (55) hide show
  1. package/README.md +137 -0
  2. package/dist/ai.server.d.ts +8 -1
  3. package/dist/client/index.js +19 -31
  4. package/dist/client/index10.js +142 -29
  5. package/dist/client/index11.js +25 -0
  6. package/dist/client/index12.js +1222 -0
  7. package/dist/client/index13.js +46 -0
  8. package/dist/client/index14.js +10 -0
  9. package/dist/client/index15.js +448 -0
  10. package/dist/client/index2.js +93 -46
  11. package/dist/client/index3.js +82 -1329
  12. package/dist/client/index4.js +305 -344
  13. package/dist/client/index5.js +36 -291
  14. package/dist/client/index6.js +99 -95
  15. package/dist/client/index7.js +78 -61
  16. package/dist/client/index8.js +57 -65
  17. package/dist/client/index9.js +97 -62
  18. package/dist/client.d.ts +3 -2
  19. package/dist/core/context.d.ts +5 -0
  20. package/dist/core/defaults.d.ts +81 -0
  21. package/dist/core/hit.d.ts +2 -0
  22. package/dist/enemies/factory.d.ts +7 -0
  23. package/dist/index.d.ts +9 -4
  24. package/dist/server/index.js +18 -31
  25. package/dist/server/index10.js +10 -0
  26. package/dist/server/index2.js +59 -345
  27. package/dist/server/index3.js +92 -1329
  28. package/dist/server/index4.js +141 -67
  29. package/dist/server/index5.js +24 -29
  30. package/dist/server/index6.js +1219 -62
  31. package/dist/server/index7.js +37 -0
  32. package/dist/server/index8.js +46 -0
  33. package/dist/server/index9.js +447 -0
  34. package/dist/server.d.ts +5 -3
  35. package/dist/ui/state.d.ts +20 -3
  36. package/package.json +5 -5
  37. package/src/ai.server.ts +91 -24
  38. package/src/animations.ts +43 -4
  39. package/src/canvas-engine-shim.ts +4 -0
  40. package/src/client.ts +122 -2
  41. package/src/components/action-bar.ce +5 -3
  42. package/src/components/attack-preview.ce +90 -0
  43. package/src/config.ts +30 -0
  44. package/src/core/context.ts +35 -0
  45. package/src/core/contracts.ts +123 -0
  46. package/src/core/defaults.ts +162 -0
  47. package/src/core/hit.spec.ts +58 -0
  48. package/src/core/hit.ts +66 -0
  49. package/src/enemies/factory.ts +25 -0
  50. package/src/index.ts +40 -0
  51. package/src/server.ts +235 -71
  52. package/src/targeting.spec.ts +24 -0
  53. package/src/types/canvas-engine.d.ts +4 -0
  54. package/src/types.ts +46 -1
  55. package/src/ui/state.ts +57 -0
@@ -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,123 @@
1
+ import type { RpgEvent, RpgPlayer } from "@rpgjs/server";
2
+ import type { AttackPattern, EnemyType, AiState } from "../ai.server";
3
+
4
+ export type ActionBattleEntity = RpgPlayer | RpgEvent;
5
+
6
+ export interface ActionBattleHitbox {
7
+ x: number;
8
+ y: number;
9
+ width: number;
10
+ height: number;
11
+ }
12
+
13
+ export interface ActionBattleDirection {
14
+ x: number;
15
+ y: number;
16
+ }
17
+
18
+ export interface ActionBattleAttackContext {
19
+ attacker: ActionBattleEntity;
20
+ target?: ActionBattleEntity | null;
21
+ direction?: string;
22
+ skill?: any;
23
+ pattern?: AttackPattern | string;
24
+ map?: any;
25
+ now: number;
26
+ }
27
+
28
+ export interface ActionBattleDamageContext {
29
+ attacker: ActionBattleEntity;
30
+ target: ActionBattleEntity;
31
+ skill?: any;
32
+ pattern?: AttackPattern | string;
33
+ }
34
+
35
+ export interface ActionBattleDamageResult {
36
+ damage: number;
37
+ defeated: boolean;
38
+ raw?: any;
39
+ }
40
+
41
+ export interface ActionBattleKnockbackContext {
42
+ attacker: ActionBattleEntity;
43
+ target: ActionBattleEntity;
44
+ damage: ActionBattleDamageResult;
45
+ weapon?: any;
46
+ }
47
+
48
+ export interface ActionBattleKnockbackResult {
49
+ force: number;
50
+ duration: number;
51
+ direction?: ActionBattleDirection;
52
+ }
53
+
54
+ export interface ActionBattleHitContext {
55
+ attacker: ActionBattleEntity;
56
+ target: ActionBattleEntity;
57
+ skill?: any;
58
+ pattern?: AttackPattern | string;
59
+ damage?: ActionBattleDamageResult;
60
+ knockback?: ActionBattleKnockbackResult;
61
+ cancelled?: boolean;
62
+ metadata?: Record<string, any>;
63
+ }
64
+
65
+ export interface ActionBattleHitResult {
66
+ damage: number;
67
+ knockbackForce: number;
68
+ knockbackDuration: number;
69
+ defeated: boolean;
70
+ attacker: ActionBattleEntity;
71
+ target: ActionBattleEntity;
72
+ rawDamage?: any;
73
+ cancelled?: boolean;
74
+ metadata?: Record<string, any>;
75
+ }
76
+
77
+ export interface ActionBattleHitHooks {
78
+ beforeHit?: (
79
+ context: ActionBattleHitContext
80
+ ) => ActionBattleHitContext | false | void;
81
+ afterDamage?: (
82
+ context: ActionBattleHitContext
83
+ ) => ActionBattleHitContext | void;
84
+ afterHit?: (result: ActionBattleHitResult) => void;
85
+ }
86
+
87
+ export interface ActionBattleCombatSystem {
88
+ resolveHitboxes(context: ActionBattleAttackContext): ActionBattleHitbox[];
89
+ resolveDamage(context: ActionBattleDamageContext): ActionBattleDamageResult;
90
+ resolveKnockback(
91
+ context: ActionBattleKnockbackContext
92
+ ): ActionBattleKnockbackResult;
93
+ hooks?: ActionBattleHitHooks;
94
+ }
95
+
96
+ export interface ActionBattleAiContext {
97
+ event: RpgEvent;
98
+ target: RpgPlayer | null;
99
+ state: AiState;
100
+ enemyType: EnemyType;
101
+ distance: number | null;
102
+ hpPercent: number | null;
103
+ now: number;
104
+ }
105
+
106
+ export interface ActionBattleAiDecision {
107
+ mode?: "assault" | "tactical" | "retreat";
108
+ attackPatterns?: AttackPattern[];
109
+ attackCooldown?: number;
110
+ moveToCooldown?: number;
111
+ metadata?: Record<string, any>;
112
+ }
113
+
114
+ export type ActionBattleAiBehavior = (
115
+ context: ActionBattleAiContext
116
+ ) => ActionBattleAiDecision | void;
117
+
118
+ export interface ActionBattleSystems {
119
+ combat: ActionBattleCombatSystem;
120
+ ai: {
121
+ behaviors: Record<string, ActionBattleAiBehavior>;
122
+ };
123
+ }
@@ -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,58 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { applyActionBattleHit } from "./hit";
3
+ import type { ActionBattleCombatSystem } from "./contracts";
4
+
5
+ const entity = (hp = 100) => ({
6
+ hp,
7
+ x: () => 0,
8
+ y: () => 0,
9
+ knockback: vi.fn(),
10
+ });
11
+
12
+ describe("applyActionBattleHit", () => {
13
+ test("runs beforeHit before resolving damage", () => {
14
+ const calls: string[] = [];
15
+ const attacker = entity();
16
+ const target = entity();
17
+ const system: ActionBattleCombatSystem = {
18
+ resolveHitboxes: () => [],
19
+ resolveDamage: () => {
20
+ calls.push("damage");
21
+ return { damage: 12, defeated: false };
22
+ },
23
+ resolveKnockback: () => ({ force: 0, duration: 0 }),
24
+ hooks: {
25
+ beforeHit() {
26
+ calls.push("before");
27
+ },
28
+ afterHit() {
29
+ calls.push("after");
30
+ },
31
+ },
32
+ };
33
+
34
+ const result = applyActionBattleHit(system, { attacker: attacker as any, target: target as any });
35
+
36
+ expect(result.damage).toBe(12);
37
+ expect(calls).toEqual(["before", "damage", "after"]);
38
+ });
39
+
40
+ test("can cancel a hit before damage is resolved", () => {
41
+ const resolveDamage = vi.fn();
42
+ const attacker = entity();
43
+ const target = entity();
44
+ const system: ActionBattleCombatSystem = {
45
+ resolveHitboxes: () => [],
46
+ resolveDamage,
47
+ resolveKnockback: () => ({ force: 0, duration: 0 }),
48
+ hooks: {
49
+ beforeHit: () => false,
50
+ },
51
+ };
52
+
53
+ const result = applyActionBattleHit(system, { attacker: attacker as any, target: target as any });
54
+
55
+ expect(result.cancelled).toBe(true);
56
+ expect(resolveDamage).not.toHaveBeenCalled();
57
+ });
58
+ });
@@ -0,0 +1,66 @@
1
+ import type { ActionBattleCombatSystem, ActionBattleHitContext, ActionBattleHitResult } from "./contracts";
2
+
3
+ export const applyActionBattleHit = (
4
+ system: ActionBattleCombatSystem,
5
+ context: ActionBattleHitContext
6
+ ): ActionBattleHitResult => {
7
+ let hitContext = { ...context };
8
+ const before = system.hooks?.beforeHit?.(hitContext);
9
+ if (before === false) {
10
+ return {
11
+ damage: 0,
12
+ knockbackForce: 0,
13
+ knockbackDuration: 0,
14
+ defeated: false,
15
+ attacker: hitContext.attacker,
16
+ target: hitContext.target,
17
+ cancelled: true,
18
+ metadata: hitContext.metadata,
19
+ };
20
+ }
21
+ if (before) hitContext = before;
22
+
23
+ const damage =
24
+ hitContext.damage ??
25
+ system.resolveDamage({
26
+ attacker: hitContext.attacker,
27
+ target: hitContext.target,
28
+ skill: hitContext.skill,
29
+ pattern: hitContext.pattern,
30
+ });
31
+ hitContext.damage = damage;
32
+
33
+ const afterDamage = system.hooks?.afterDamage?.(hitContext);
34
+ if (afterDamage) hitContext = afterDamage;
35
+
36
+ const knockback =
37
+ hitContext.knockback ??
38
+ system.resolveKnockback({
39
+ attacker: hitContext.attacker,
40
+ target: hitContext.target,
41
+ damage,
42
+ });
43
+ hitContext.knockback = knockback;
44
+
45
+ if (!damage.defeated && knockback.force > 0 && knockback.direction) {
46
+ (hitContext.target as any).knockback?.(
47
+ knockback.direction,
48
+ knockback.force,
49
+ knockback.duration
50
+ );
51
+ }
52
+
53
+ const result: ActionBattleHitResult = {
54
+ damage: damage.damage,
55
+ knockbackForce: knockback.force,
56
+ knockbackDuration: knockback.duration,
57
+ defeated: damage.defeated,
58
+ attacker: hitContext.attacker,
59
+ target: hitContext.target,
60
+ rawDamage: damage.raw,
61
+ metadata: hitContext.metadata,
62
+ };
63
+
64
+ system.hooks?.afterHit?.(result);
65
+ return result;
66
+ };
@@ -0,0 +1,25 @@
1
+ import type { RpgEvent } from "@rpgjs/server";
2
+ import { BattleAi, type BattleAiOptions } from "../ai.server";
3
+
4
+ export interface ActionBattleEnemyPreset extends BattleAiOptions {
5
+ stats?: (event: RpgEvent) => void;
6
+ }
7
+
8
+ export type ActionBattleEnemyPresetMap = Record<string, ActionBattleEnemyPreset>;
9
+
10
+ export const createActionEnemy = (
11
+ event: RpgEvent,
12
+ presetOrOptions: string | BattleAiOptions,
13
+ presets: ActionBattleEnemyPresetMap = {}
14
+ ) => {
15
+ const options =
16
+ typeof presetOrOptions === "string"
17
+ ? presets[presetOrOptions]
18
+ : presetOrOptions;
19
+ if (!options) {
20
+ throw new Error(`Action battle enemy preset not found: ${presetOrOptions}`);
21
+ }
22
+ const preset = options as ActionBattleEnemyPreset;
23
+ preset.stats?.(event);
24
+ return new BattleAi(event, options);
25
+ };
package/src/index.ts CHANGED
@@ -21,10 +21,50 @@ export type {
21
21
  ActionBattleActionBarSkill,
22
22
  ActionBattleSkillTargeting,
23
23
  ActionBattleSkillTargetingResolver,
24
+ ActionBattleAttackOptions,
24
25
  ActionBattleUiOptions,
25
26
  ActionBattleUiActionBarOptions,
26
27
  ActionBattleUiTargetingOptions,
28
+ ActionBattleCombatOptions,
29
+ ActionBattleSystemOptions,
30
+ ActionBattleAiSystemOptions,
27
31
  } from "./types";
32
+ export type {
33
+ ActionBattleAiBehavior,
34
+ ActionBattleAiContext,
35
+ ActionBattleAiDecision,
36
+ ActionBattleAttackContext,
37
+ ActionBattleCombatSystem,
38
+ ActionBattleDamageContext,
39
+ ActionBattleDamageResult,
40
+ ActionBattleDirection,
41
+ ActionBattleEntity,
42
+ ActionBattleHitContext,
43
+ ActionBattleHitHooks,
44
+ ActionBattleHitResult,
45
+ ActionBattleHitbox,
46
+ ActionBattleKnockbackContext,
47
+ ActionBattleKnockbackResult,
48
+ ActionBattleSystems,
49
+ } from "./core/contracts";
50
+ export {
51
+ DEFAULT_ZELDA_PLAYER_HITBOXES,
52
+ createDefaultPlayerHitboxResolver,
53
+ defaultCombatSystem,
54
+ defaultEnemyBehaviors,
55
+ defaultKnockbackResolver,
56
+ defaultRpgjsDamageResolver,
57
+ } from "./core/defaults";
58
+ export {
59
+ createActionBattleSystems,
60
+ getActionBattleSystems,
61
+ } from "./core/context";
62
+ export { applyActionBattleHit } from "./core/hit";
63
+ export {
64
+ createActionEnemy,
65
+ type ActionBattleEnemyPreset,
66
+ type ActionBattleEnemyPresetMap,
67
+ } from "./enemies/factory";
28
68
 
29
69
  // Server exports
30
70
  export {