@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
package/src/server.ts CHANGED
@@ -9,8 +9,13 @@ import {
9
9
  import { normalizeActionBattleOptions, setActionBattleOptions } from "./config";
10
10
  import { manhattanDistance, parseAoeMask } from "./targeting";
11
11
  import { playActionBattleAnimation } from "./animations";
12
+ import { getActionBattleSystems, setActionBattleSystems } from "./core/context";
13
+ import { applyActionBattleHit } from "./core/hit";
14
+ import { DEFAULT_ZELDA_PLAYER_HITBOXES } from "./core/defaults";
15
+ import type { ActionBattleHitbox } from "./core/contracts";
12
16
 
13
17
  export const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
18
+ const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
14
19
 
15
20
  /**
16
21
  * Default player attack hitboxes offsets for each direction
@@ -20,11 +25,113 @@ export const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
20
25
  * when creating the moving hitbox.
21
26
  */
22
27
  export const DEFAULT_PLAYER_ATTACK_HITBOXES = {
23
- up: { offsetX: -16, offsetY: -48, width: 32, height: 32 },
24
- down: { offsetX: -16, offsetY: 16, width: 32, height: 32 },
25
- left: { offsetX: -48, offsetY: -16, width: 32, height: 32 },
26
- right: { offsetX: 16, offsetY: -16, width: 32, height: 32 },
27
- default: { offsetX: 0, offsetY: -32, width: 32, height: 32 }
28
+ ...DEFAULT_ZELDA_PLAYER_HITBOXES,
29
+ };
30
+
31
+ const beginPlayerAttackLock = (
32
+ player: RpgPlayer,
33
+ map: ReturnType<RpgPlayer["getCurrentMap"]> | undefined,
34
+ durationMs: number
35
+ ): boolean => {
36
+ if (durationMs <= 0) return true;
37
+
38
+ const runtimePlayer = player as any;
39
+ const now = Date.now();
40
+ if (
41
+ typeof runtimePlayer.__actionBattleAttackLockedUntil === "number" &&
42
+ runtimePlayer.__actionBattleAttackLockedUntil > now
43
+ ) {
44
+ return false;
45
+ }
46
+
47
+ const lockId = (runtimePlayer.__actionBattleAttackLockId ?? 0) + 1;
48
+ runtimePlayer.__actionBattleAttackLockId = lockId;
49
+ runtimePlayer.__actionBattleAttackLockedUntil = now + durationMs;
50
+
51
+ const previousCanMove =
52
+ typeof player.canMove === "function" ? player.canMove() : true;
53
+ const previousDirectionFixed = player.directionFixed;
54
+ const previousAnimationFixed = player.animationFixed;
55
+
56
+ player.pendingInputs = [];
57
+ player.lastProcessedInputTs = 0;
58
+ (map as any)?.stopMovement?.(player);
59
+ player.canMove.set(false);
60
+ player.directionFixed = true;
61
+
62
+ setTimeout(() => {
63
+ if (runtimePlayer.__actionBattleAttackLockId !== lockId) return;
64
+ runtimePlayer.__actionBattleAttackLockedUntil = 0;
65
+ player.canMove.set(previousCanMove);
66
+ player.directionFixed = previousDirectionFixed;
67
+ player.animationFixed = previousAnimationFixed;
68
+ }, durationMs);
69
+
70
+ return true;
71
+ };
72
+
73
+ const isBattleEvent = (event: RpgEvent) => !!(event as any).battleAi;
74
+
75
+ const rectsOverlap = (
76
+ a: { x: number; y: number; width: number; height: number },
77
+ b: { x: number; y: number; width: number; height: number }
78
+ ) =>
79
+ a.x < b.x + b.width &&
80
+ a.x + a.width > b.x &&
81
+ a.y < b.y + b.height &&
82
+ a.y + a.height > b.y;
83
+
84
+ const eventRect = (event: RpgEvent) => {
85
+ const hitbox =
86
+ typeof event.hitbox === "function" ? event.hitbox() : (event as any).hitbox;
87
+ return {
88
+ x: event.x(),
89
+ y: event.y(),
90
+ width: hitbox?.w ?? 32,
91
+ height: hitbox?.h ?? 32,
92
+ };
93
+ };
94
+
95
+ const getVisibleActionEvents = (
96
+ player: RpgPlayer,
97
+ map: ReturnType<RpgPlayer["getCurrentMap"]> | undefined,
98
+ hitboxes: Array<{ x: number; y: number; width: number; height: number }>
99
+ ) => {
100
+ if (!map) return [];
101
+
102
+ const eventsById = new Map<string, RpgEvent>();
103
+ const addEvent = (event: RpgEvent | undefined) => {
104
+ if (!event) return;
105
+ const isVisible =
106
+ typeof (map as any).isEventVisibleForPlayer === "function"
107
+ ? (map as any).isEventVisibleForPlayer(event, player)
108
+ : true;
109
+ if (!isVisible) return;
110
+ eventsById.set(event.id, event);
111
+ };
112
+
113
+ const collisions = (map as any).getCollisions?.(player.id);
114
+ if (Array.isArray(collisions)) {
115
+ collisions.forEach((id: string) => addEvent(map.getEvent(id)));
116
+ }
117
+
118
+ for (const event of map.getEvents()) {
119
+ const rect = eventRect(event);
120
+ if (hitboxes.some((hitbox) => rectsOverlap(hitbox, rect))) {
121
+ addEvent(event);
122
+ }
123
+ }
124
+
125
+ return Array.from(eventsById.values());
126
+ };
127
+
128
+ const isActionReservedForNormalEvent = (
129
+ player: RpgPlayer,
130
+ map: ReturnType<RpgPlayer["getCurrentMap"]> | undefined,
131
+ hitboxes: Array<{ x: number; y: number; width: number; height: number }>
132
+ ) => {
133
+ const events = getVisibleActionEvents(player, map, hitboxes);
134
+ return events.length > 0 && !events.some(isBattleEvent);
28
135
  };
29
136
 
30
137
  /**
@@ -98,52 +205,97 @@ export function applyPlayerHitToEvent(
98
205
  const ai = (target as any).battleAi as BattleAi;
99
206
  if (!ai) return undefined;
100
207
 
101
- // Get knockback force from player's weapon
102
- const knockbackForce = getPlayerWeaponKnockbackForce(player);
103
-
104
- // Apply damage to AI
105
- const defeated = ai.takeDamage(player);
106
-
107
- // Calculate knockback direction (away from player)
108
- const dx = target.x() - player.x();
109
- const dy = target.y() - player.y();
110
- const distance = Math.sqrt(dx * dx + dy * dy);
111
-
112
- // Create hit result
113
- let hitResult: HitResult = {
114
- damage: 0, // Will be set by takeDamage internally
115
- knockbackForce,
116
- knockbackDuration: DEFAULT_KNOCKBACK.duration,
117
- defeated,
118
- attacker: player,
119
- target
120
- };
121
-
122
- // Call onBeforeHit hook
123
- if (hooks?.onBeforeHit) {
124
- const modified = hooks.onBeforeHit(hitResult);
125
- if (modified) {
126
- hitResult = modified;
208
+ const systems = getActionBattleSystems();
209
+ const result = applyActionBattleHit(
210
+ {
211
+ ...systems.combat,
212
+ hooks: hooks
213
+ ? {
214
+ ...systems.combat.hooks,
215
+ beforeHit(context) {
216
+ const before = systems.combat.hooks?.beforeHit?.(context);
217
+ if (before === false) return false;
218
+ const nextContext = before || context;
219
+ const legacyResult = toLegacyHitResult(nextContext);
220
+ const modified = hooks.onBeforeHit?.(legacyResult);
221
+ if (!modified) return nextContext;
222
+ return {
223
+ ...nextContext,
224
+ damage: {
225
+ damage: modified.damage,
226
+ defeated: modified.defeated,
227
+ raw: nextContext.damage?.raw,
228
+ },
229
+ knockback: {
230
+ force: modified.knockbackForce,
231
+ duration: modified.knockbackDuration,
232
+ direction: nextContext.knockback?.direction,
233
+ },
234
+ };
235
+ },
236
+ afterHit(result) {
237
+ systems.combat.hooks?.afterHit?.(result);
238
+ hooks.onAfterHit?.(result as HitResult);
239
+ },
240
+ }
241
+ : systems.combat.hooks,
242
+ },
243
+ {
244
+ attacker: player,
245
+ target,
127
246
  }
128
- }
129
-
130
- // Apply knockback only if not defeated (entity still exists)
131
- if (!hitResult.defeated && hitResult.knockbackForce > 0 && distance > 0) {
132
- const knockbackDirection = {
133
- x: dx / distance,
134
- y: dy / distance
135
- };
136
- target.knockback(knockbackDirection, hitResult.knockbackForce, hitResult.knockbackDuration);
137
- }
247
+ );
138
248
 
139
- // Call onAfterHit hook
140
- if (hooks?.onAfterHit) {
141
- hooks.onAfterHit(hitResult);
249
+ if (!result.cancelled) {
250
+ ai.handleDamage(player, {
251
+ damage: result.damage,
252
+ defeated: result.defeated,
253
+ raw: result.rawDamage,
254
+ });
142
255
  }
143
256
 
144
- return hitResult;
257
+ return result as HitResult;
145
258
  }
146
259
 
260
+ const toLegacyHitResult = (context: any): HitResult => ({
261
+ damage: context.damage?.damage ?? 0,
262
+ knockbackForce: context.knockback?.force ?? getPlayerWeaponKnockbackForce(context.attacker),
263
+ knockbackDuration: context.knockback?.duration ?? DEFAULT_KNOCKBACK.duration,
264
+ defeated: context.damage?.defeated ?? false,
265
+ attacker: context.attacker,
266
+ target: context.target,
267
+ });
268
+
269
+ const resolvePlayerAttackHitboxes = (
270
+ player: RpgPlayer,
271
+ directionKey: string,
272
+ options: ActionBattleOptions
273
+ ): ActionBattleHitbox[] => {
274
+ const configuredHitboxes = {
275
+ ...DEFAULT_PLAYER_ATTACK_HITBOXES,
276
+ ...options.attack?.hitboxes,
277
+ };
278
+ const hitboxConfig =
279
+ configuredHitboxes[
280
+ directionKey as keyof typeof DEFAULT_PLAYER_ATTACK_HITBOXES
281
+ ] || configuredHitboxes.default;
282
+ const defaultHitboxes = [
283
+ {
284
+ x: player.x() + hitboxConfig.offsetX,
285
+ y: player.y() + hitboxConfig.offsetY,
286
+ width: hitboxConfig.width,
287
+ height: hitboxConfig.height,
288
+ },
289
+ ];
290
+ return (
291
+ options.attack?.resolveHitboxes?.({
292
+ player,
293
+ direction: directionKey,
294
+ defaultHitboxes,
295
+ }) ?? defaultHitboxes
296
+ );
297
+ };
298
+
147
299
  const resolveSignal = (value: any) =>
148
300
  typeof value === "function" ? value() : value;
149
301
 
@@ -404,6 +556,7 @@ export const createActionBattleServer = (
404
556
  ) => {
405
557
  const options = normalizeActionBattleOptions(rawOptions);
406
558
  setActionBattleOptions(options);
559
+ setActionBattleSystems(options);
407
560
  return defineModule<RpgServer>({
408
561
  player: {
409
562
  /**
@@ -419,38 +572,39 @@ export const createActionBattleServer = (
419
572
  */
420
573
  onInput(player: RpgPlayer, input: any) {
421
574
  if (input.action == Control.Action) {
422
- playActionBattleAnimation("attack", player, options.animations);
423
-
424
- // Get player position
425
- const playerX = player.x();
426
- const playerY = player.y();
575
+ const map = player.getCurrentMap();
427
576
  const direction = player.getDirection();
428
577
 
429
578
  // Convert Direction enum to string key
430
579
  const directionKey = direction as string;
431
580
 
432
- // Get hitbox configuration for the direction
433
- const hitboxConfig =
434
- DEFAULT_PLAYER_ATTACK_HITBOXES[
435
- directionKey as keyof typeof DEFAULT_PLAYER_ATTACK_HITBOXES
436
- ] || DEFAULT_PLAYER_ATTACK_HITBOXES.default;
437
-
438
- // Convert relative hitbox to absolute coordinates
439
- const hitboxes: Array<{
440
- x: number;
441
- y: number;
442
- width: number;
443
- height: number;
444
- }> = [
445
- {
446
- x: playerX + hitboxConfig.offsetX,
447
- y: playerY + hitboxConfig.offsetY,
448
- width: hitboxConfig.width,
449
- height: hitboxConfig.height,
450
- },
451
- ];
581
+ const hitboxes = resolvePlayerAttackHitboxes(
582
+ player,
583
+ directionKey,
584
+ options
585
+ );
586
+
587
+ if (isActionReservedForNormalEvent(player, map, hitboxes)) {
588
+ return;
589
+ }
590
+
591
+ const lockMovement = options.attack?.lockMovement !== false;
592
+ const lockDurationMs =
593
+ options.attack?.lockDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS;
594
+ let movementLocked = false;
595
+
596
+ if (
597
+ lockMovement &&
598
+ !beginPlayerAttackLock(player, map, Math.max(0, lockDurationMs))
599
+ ) {
600
+ return;
601
+ }
602
+ movementLocked = lockMovement && lockDurationMs > 0;
452
603
 
453
- const map = player.getCurrentMap();
604
+ playActionBattleAnimation("attack", player, options.animations);
605
+ if (movementLocked) {
606
+ player.animationFixed = true;
607
+ }
454
608
 
455
609
  map?.createMovingHitbox(hitboxes, { speed: 3 }).subscribe({
456
610
  next(hits) {
@@ -507,3 +661,13 @@ export const createActionBattleServer = (
507
661
  };
508
662
 
509
663
  export default createActionBattleServer();
664
+
665
+ export {
666
+ AiDebug,
667
+ AiState,
668
+ AttackPattern,
669
+ BattleAi,
670
+ DEFAULT_KNOCKBACK,
671
+ EnemyType,
672
+ } from "./ai.server";
673
+ export type { ApplyHitHooks, BattleAiOptions, HitResult } from "./ai.server";
@@ -0,0 +1,24 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { manhattanDistance, parseAoeMask } from "./targeting";
3
+
4
+ describe("targeting helpers", () => {
5
+ test("parses an ASCII AoE mask around its center", () => {
6
+ const mask = parseAoeMask([".#.", "###", ".#."]);
7
+
8
+ expect(mask.width).toBe(3);
9
+ expect(mask.height).toBe(3);
10
+ expect(mask.cells).toEqual(
11
+ expect.arrayContaining([
12
+ { dx: 0, dy: -1 },
13
+ { dx: -1, dy: 0 },
14
+ { dx: 0, dy: 0 },
15
+ { dx: 1, dy: 0 },
16
+ { dx: 0, dy: 1 },
17
+ ])
18
+ );
19
+ });
20
+
21
+ test("uses Manhattan distance for tile targeting", () => {
22
+ expect(manhattanDistance({ x: 2, y: 3 }, { x: 5, y: 1 })).toBe(5);
23
+ });
24
+ });
@@ -0,0 +1,4 @@
1
+ declare module "*.ce" {
2
+ const component: any;
3
+ export default component;
4
+ }
package/src/types.ts CHANGED
@@ -1,3 +1,10 @@
1
+ import type {
2
+ ActionBattleAiBehavior,
3
+ ActionBattleCombatSystem,
4
+ ActionBattleHitHooks,
5
+ ActionBattleHitbox,
6
+ } from "./core/contracts";
7
+
1
8
  export type ActionBattleAoeMask = string[] | string;
2
9
 
3
10
  export type ActionBattleActionBarMode = "items" | "skills" | "both";
@@ -8,7 +15,8 @@ export type ActionBattleAnimationKey =
8
15
  | "attack"
9
16
  | "hurt"
10
17
  | "die"
11
- | "castSkill";
18
+ | "castSkill"
19
+ | "castSpell";
12
20
 
13
21
  export type ActionBattleAnimationResult =
14
22
  | string
@@ -91,11 +99,48 @@ export interface ActionBattleTargetingOptions {
91
99
  allowEmptyTarget?: boolean;
92
100
  }
93
101
 
102
+ export interface ActionBattleAttackOptions {
103
+ lockMovement?: boolean;
104
+ lockDurationMs?: number;
105
+ showPreview?: boolean;
106
+ previewDurationMs?: number;
107
+ previewColor?: number;
108
+ previewAccentColor?: number;
109
+ hitboxes?: Partial<
110
+ Record<
111
+ "up" | "down" | "left" | "right" | "default",
112
+ { offsetX: number; offsetY: number; width: number; height: number }
113
+ >
114
+ >;
115
+ resolveHitboxes?: (context: {
116
+ player: any;
117
+ direction: string;
118
+ defaultHitboxes: ActionBattleHitbox[];
119
+ }) => ActionBattleHitbox[];
120
+ }
121
+
122
+ export interface ActionBattleCombatOptions {
123
+ damage?: ActionBattleCombatSystem["resolveDamage"];
124
+ knockback?: ActionBattleCombatSystem["resolveKnockback"];
125
+ hooks?: ActionBattleHitHooks;
126
+ }
127
+
128
+ export interface ActionBattleAiSystemOptions {
129
+ behaviors?: Record<string, ActionBattleAiBehavior>;
130
+ }
131
+
132
+ export interface ActionBattleSystemOptions {
133
+ combat?: ActionBattleCombatOptions;
134
+ ai?: ActionBattleAiSystemOptions;
135
+ }
136
+
94
137
  export interface ActionBattleOptions {
95
138
  ui?: ActionBattleUiOptions;
96
139
  skills?: ActionBattleSkillOptions;
97
140
  targeting?: ActionBattleTargetingOptions;
141
+ attack?: ActionBattleAttackOptions;
98
142
  animations?: ActionBattleAnimationOptions;
143
+ systems?: ActionBattleSystemOptions;
99
144
  }
100
145
 
101
146
  export interface ActionBattleActionBarItem {
package/src/ui/state.ts CHANGED
@@ -2,6 +2,16 @@ import { signal } from "canvasengine";
2
2
  import { ActionBattleActionBarSkill, ActionBattleOptions } from "../types";
3
3
  import { DEFAULT_ACTION_BATTLE_OPTIONS, normalizeActionBattleOptions } from "../config";
4
4
 
5
+ export interface ActionBattleAttackPreviewState {
6
+ active: boolean;
7
+ id: number;
8
+ direction: string;
9
+ startedAt: number;
10
+ durationMs: number;
11
+ color: number;
12
+ accentColor: number;
13
+ }
14
+
5
15
  export interface ActionBattleTargetingState {
6
16
  active: boolean;
7
17
  skill: ActionBattleActionBarSkill | null;
@@ -18,6 +28,16 @@ const defaultTargetingState: ActionBattleTargetingState = {
18
28
  aoeMask: DEFAULT_ACTION_BATTLE_OPTIONS.skills?.defaultAoeMask || ["#"],
19
29
  };
20
30
 
31
+ const defaultAttackPreviewState: ActionBattleAttackPreviewState = {
32
+ active: false,
33
+ id: 0,
34
+ direction: "down",
35
+ startedAt: 0,
36
+ durationMs: 180,
37
+ color: 0xfff3b0,
38
+ accentColor: 0xffffff,
39
+ };
40
+
21
41
  export const actionBattleUiOptions = signal(
22
42
  normalizeActionBattleOptions({}).ui || {}
23
43
  );
@@ -28,6 +48,10 @@ export const actionBattleSkillOptions = signal(
28
48
  export const actionBattleTargetingState = signal<ActionBattleTargetingState>({
29
49
  ...defaultTargetingState,
30
50
  });
51
+ export const actionBattleAttackPreviewState =
52
+ signal<ActionBattleAttackPreviewState>({
53
+ ...defaultAttackPreviewState,
54
+ });
31
55
 
32
56
  export const setActionBattleOptions = (options: ActionBattleOptions = {}) => {
33
57
  const normalized = normalizeActionBattleOptions(options);
@@ -66,3 +90,36 @@ export const moveTargetingOffset = (dx: number, dy: number) => {
66
90
  offset: next,
67
91
  });
68
92
  };
93
+
94
+ export const startAttackPreview = (options: {
95
+ direction: string;
96
+ durationMs?: number;
97
+ color?: number;
98
+ accentColor?: number;
99
+ }) => {
100
+ const current = actionBattleAttackPreviewState();
101
+ const id = current.id + 1;
102
+ const durationMs = Math.max(
103
+ 1,
104
+ options.durationMs ?? defaultAttackPreviewState.durationMs
105
+ );
106
+ actionBattleAttackPreviewState.set({
107
+ active: true,
108
+ id,
109
+ direction: options.direction,
110
+ startedAt: Date.now(),
111
+ durationMs,
112
+ color: options.color ?? defaultAttackPreviewState.color,
113
+ accentColor: options.accentColor ?? defaultAttackPreviewState.accentColor,
114
+ });
115
+ return id;
116
+ };
117
+
118
+ export const stopAttackPreview = (id?: number) => {
119
+ const current = actionBattleAttackPreviewState();
120
+ if (id !== undefined && current.id !== id) return;
121
+ actionBattleAttackPreviewState.set({
122
+ ...current,
123
+ active: false,
124
+ });
125
+ };