@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.
Files changed (66) hide show
  1. package/README.md +161 -22
  2. package/dist/ai.server.d.ts +55 -4
  3. package/dist/client/index.js +13 -8
  4. package/dist/client/index10.js +54 -136
  5. package/dist/client/index11.js +52 -23
  6. package/dist/client/index12.js +101 -1217
  7. package/dist/client/index13.js +139 -42
  8. package/dist/client/index14.js +23 -8
  9. package/dist/client/index15.js +68 -444
  10. package/dist/client/index16.js +1343 -0
  11. package/dist/client/index17.js +13 -0
  12. package/dist/client/index18.js +60 -0
  13. package/dist/client/index19.js +10 -0
  14. package/dist/client/index2.js +25 -87
  15. package/dist/client/index20.js +504 -0
  16. package/dist/client/index3.js +45 -83
  17. package/dist/client/index4.js +98 -297
  18. package/dist/client/index5.js +81 -33
  19. package/dist/client/index6.js +284 -78
  20. package/dist/client/index7.js +33 -74
  21. package/dist/client/index8.js +95 -55
  22. package/dist/client/index9.js +75 -96
  23. package/dist/core/attack-profile.d.ts +9 -0
  24. package/dist/core/attack-runtime.d.ts +20 -0
  25. package/dist/core/enemy-attack-profiles.d.ts +6 -0
  26. package/dist/core/equipment.d.ts +2 -0
  27. package/dist/core/hit-reaction.d.ts +5 -0
  28. package/dist/index.d.ts +7 -2
  29. package/dist/server/index.js +12 -7
  30. package/dist/server/index10.js +1340 -8
  31. package/dist/server/index11.js +37 -0
  32. package/dist/server/index12.js +60 -0
  33. package/dist/server/index13.js +13 -0
  34. package/dist/server/index14.js +503 -0
  35. package/dist/server/index15.js +10 -0
  36. package/dist/server/index2.js +1 -1
  37. package/dist/server/index3.js +25 -87
  38. package/dist/server/index4.js +45 -141
  39. package/dist/server/index5.js +104 -21
  40. package/dist/server/index6.js +137 -1215
  41. package/dist/server/index7.js +22 -34
  42. package/dist/server/index8.js +70 -44
  43. package/dist/server/index9.js +44 -437
  44. package/dist/server.d.ts +8 -2
  45. package/dist/ui/state.d.ts +5 -5
  46. package/package.json +5 -5
  47. package/src/ai.server.spec.ts +120 -0
  48. package/src/ai.server.ts +362 -56
  49. package/src/client.ts +21 -12
  50. package/src/config.ts +17 -2
  51. package/src/core/attack-profile.spec.ts +118 -0
  52. package/src/core/attack-profile.ts +100 -0
  53. package/src/core/attack-runtime.spec.ts +103 -0
  54. package/src/core/attack-runtime.ts +83 -0
  55. package/src/core/contracts.ts +3 -0
  56. package/src/core/enemy-attack-profiles.spec.ts +35 -0
  57. package/src/core/enemy-attack-profiles.ts +103 -0
  58. package/src/core/equipment.spec.ts +37 -0
  59. package/src/core/equipment.ts +17 -0
  60. package/src/core/hit-reaction.spec.ts +43 -0
  61. package/src/core/hit-reaction.ts +70 -0
  62. package/src/core/hit.spec.ts +54 -1
  63. package/src/core/hit.ts +26 -0
  64. package/src/index.ts +48 -1
  65. package/src/server.ts +192 -34
  66. package/src/types.ts +62 -6
@@ -0,0 +1,17 @@
1
+ import type { ActionBattleAttackProfile } from "../types";
2
+
3
+ const resolveItemId = (item: any) => item?.id?.() ?? item?.id;
4
+
5
+ export function resolveActionBattleWeaponAttackProfile(
6
+ entity: any
7
+ ): ActionBattleAttackProfile | null {
8
+ const equipments = entity?.equipments?.() || [];
9
+ for (const item of equipments) {
10
+ const itemId = resolveItemId(item);
11
+ const itemData = entity?.databaseById?.(itemId);
12
+ if (itemData?._type === "weapon" && itemData.attackProfile) {
13
+ return itemData.attackProfile;
14
+ }
15
+ }
16
+ return null;
17
+ }
@@ -0,0 +1,43 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import {
3
+ DEFAULT_ACTION_BATTLE_HIT_REACTION,
4
+ isActionBattleEntityInvincible,
5
+ normalizeActionBattleHitReaction,
6
+ setActionBattleInvincibility,
7
+ } from "./hit-reaction";
8
+
9
+ describe("hit reaction helpers", () => {
10
+ afterEach(() => {
11
+ vi.restoreAllMocks();
12
+ });
13
+
14
+ test("normalizes hit reaction defaults", () => {
15
+ expect(normalizeActionBattleHitReaction(undefined)).toEqual(
16
+ DEFAULT_ACTION_BATTLE_HIT_REACTION
17
+ );
18
+ });
19
+
20
+ test("normalizes unsafe reaction values", () => {
21
+ expect(
22
+ normalizeActionBattleHitReaction({
23
+ invincibilityMs: -10,
24
+ hitstunMs: -20,
25
+ staggerPower: -1,
26
+ })
27
+ ).toEqual({
28
+ invincibilityMs: 0,
29
+ hitstunMs: 0,
30
+ staggerPower: 0,
31
+ });
32
+ });
33
+
34
+ test("tracks invincibility windows on entities", () => {
35
+ const entity = {};
36
+ vi.spyOn(Date, "now").mockReturnValue(1000);
37
+
38
+ setActionBattleInvincibility(entity, 250);
39
+
40
+ expect(isActionBattleEntityInvincible(entity, 1100)).toBe(true);
41
+ expect(isActionBattleEntityInvincible(entity, 1300)).toBe(false);
42
+ });
43
+ });
@@ -0,0 +1,70 @@
1
+ import type {
2
+ ActionBattleHitReactionProfile,
3
+ NormalizedActionBattleHitReactionProfile,
4
+ } from "../types";
5
+
6
+ export const DEFAULT_ACTION_BATTLE_HIT_REACTION: NormalizedActionBattleHitReactionProfile = {
7
+ invincibilityMs: 250,
8
+ hitstunMs: 150,
9
+ staggerPower: 1,
10
+ };
11
+
12
+ const STATE_KEY = "__actionBattleHitReaction";
13
+
14
+ interface ActionBattleHitReactionRuntimeState {
15
+ invincibleUntil: number;
16
+ }
17
+
18
+ const isFiniteNumber = (value: unknown): value is number =>
19
+ typeof value === "number" && Number.isFinite(value);
20
+
21
+ const nonNegativeMs = (value: unknown, fallback: number) =>
22
+ isFiniteNumber(value) ? Math.max(0, value) : fallback;
23
+
24
+ const nonNegativeValue = (value: unknown, fallback: number) =>
25
+ isFiniteNumber(value) ? Math.max(0, value) : fallback;
26
+
27
+ const getRuntimeState = (entity: any): ActionBattleHitReactionRuntimeState => {
28
+ if (!entity[STATE_KEY]) {
29
+ entity[STATE_KEY] = {
30
+ invincibleUntil: 0,
31
+ };
32
+ }
33
+ return entity[STATE_KEY];
34
+ };
35
+
36
+ export function normalizeActionBattleHitReaction(
37
+ reaction: ActionBattleHitReactionProfile | undefined,
38
+ defaults: NormalizedActionBattleHitReactionProfile =
39
+ DEFAULT_ACTION_BATTLE_HIT_REACTION
40
+ ): NormalizedActionBattleHitReactionProfile {
41
+ return {
42
+ invincibilityMs: nonNegativeMs(
43
+ reaction?.invincibilityMs,
44
+ defaults.invincibilityMs
45
+ ),
46
+ hitstunMs: nonNegativeMs(reaction?.hitstunMs, defaults.hitstunMs),
47
+ staggerPower: nonNegativeValue(
48
+ reaction?.staggerPower,
49
+ defaults.staggerPower
50
+ ),
51
+ };
52
+ }
53
+
54
+ export function isActionBattleEntityInvincible(
55
+ entity: any,
56
+ now = Date.now()
57
+ ): boolean {
58
+ if (!entity) return false;
59
+ return getRuntimeState(entity).invincibleUntil > now;
60
+ }
61
+
62
+ export function setActionBattleInvincibility(
63
+ entity: any,
64
+ durationMs: number,
65
+ now = Date.now()
66
+ ): void {
67
+ if (!entity || durationMs <= 0) return;
68
+ const state = getRuntimeState(entity);
69
+ state.invincibleUntil = Math.max(state.invincibleUntil, now + durationMs);
70
+ }
@@ -1,6 +1,7 @@
1
- import { describe, expect, test, vi } from "vitest";
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
2
  import { applyActionBattleHit } from "./hit";
3
3
  import type { ActionBattleCombatSystem } from "./contracts";
4
+ import { setActionBattleInvincibility } from "./hit-reaction";
4
5
 
5
6
  const entity = (hp = 100) => ({
6
7
  hp,
@@ -10,6 +11,10 @@ const entity = (hp = 100) => ({
10
11
  });
11
12
 
12
13
  describe("applyActionBattleHit", () => {
14
+ afterEach(() => {
15
+ vi.restoreAllMocks();
16
+ });
17
+
13
18
  test("runs beforeHit before resolving damage", () => {
14
19
  const calls: string[] = [];
15
20
  const attacker = entity();
@@ -55,4 +60,52 @@ describe("applyActionBattleHit", () => {
55
60
  expect(result.cancelled).toBe(true);
56
61
  expect(resolveDamage).not.toHaveBeenCalled();
57
62
  });
63
+
64
+ test("cancels a hit when the target is invincible", () => {
65
+ const resolveDamage = vi.fn();
66
+ const attacker = entity();
67
+ const target = entity();
68
+ setActionBattleInvincibility(target, 500, 1000);
69
+ vi.spyOn(Date, "now").mockReturnValue(1100);
70
+ const system: ActionBattleCombatSystem = {
71
+ resolveHitboxes: () => [],
72
+ resolveDamage,
73
+ resolveKnockback: () => ({ force: 0, duration: 0 }),
74
+ };
75
+
76
+ const result = applyActionBattleHit(system, {
77
+ attacker: attacker as any,
78
+ target: target as any,
79
+ });
80
+
81
+ expect(result.cancelled).toBe(true);
82
+ expect(resolveDamage).not.toHaveBeenCalled();
83
+ });
84
+
85
+ test("applies invincibility from hit reaction after damage", () => {
86
+ vi.spyOn(Date, "now").mockReturnValue(2000);
87
+ const attacker = entity();
88
+ const target = entity();
89
+ const system: ActionBattleCombatSystem = {
90
+ resolveHitboxes: () => [],
91
+ resolveDamage: () => ({ damage: 12, defeated: false }),
92
+ resolveKnockback: () => ({ force: 0, duration: 0 }),
93
+ };
94
+
95
+ const result = applyActionBattleHit(system, {
96
+ attacker: attacker as any,
97
+ target: target as any,
98
+ reaction: {
99
+ invincibilityMs: 300,
100
+ hitstunMs: 120,
101
+ staggerPower: 1,
102
+ },
103
+ });
104
+
105
+ expect(result.cancelled).toBeUndefined();
106
+ expect(applyActionBattleHit(system, {
107
+ attacker: attacker as any,
108
+ target: target as any,
109
+ }).cancelled).toBe(true);
110
+ });
58
111
  });
package/src/core/hit.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  import type { ActionBattleCombatSystem, ActionBattleHitContext, ActionBattleHitResult } from "./contracts";
2
+ import {
3
+ isActionBattleEntityInvincible,
4
+ setActionBattleInvincibility,
5
+ } from "./hit-reaction";
2
6
 
3
7
  export const applyActionBattleHit = (
4
8
  system: ActionBattleCombatSystem,
@@ -20,6 +24,20 @@ export const applyActionBattleHit = (
20
24
  }
21
25
  if (before) hitContext = before;
22
26
 
27
+ if (isActionBattleEntityInvincible(hitContext.target)) {
28
+ return {
29
+ damage: 0,
30
+ knockbackForce: 0,
31
+ knockbackDuration: 0,
32
+ defeated: false,
33
+ attacker: hitContext.attacker,
34
+ target: hitContext.target,
35
+ cancelled: true,
36
+ metadata: hitContext.metadata,
37
+ reaction: hitContext.reaction,
38
+ };
39
+ }
40
+
23
41
  const damage =
24
42
  hitContext.damage ??
25
43
  system.resolveDamage({
@@ -50,6 +68,13 @@ export const applyActionBattleHit = (
50
68
  );
51
69
  }
52
70
 
71
+ if (!damage.defeated && hitContext.reaction?.invincibilityMs) {
72
+ setActionBattleInvincibility(
73
+ hitContext.target,
74
+ hitContext.reaction.invincibilityMs
75
+ );
76
+ }
77
+
53
78
  const result: ActionBattleHitResult = {
54
79
  damage: damage.damage,
55
80
  knockbackForce: knockback.force,
@@ -58,6 +83,7 @@ export const applyActionBattleHit = (
58
83
  attacker: hitContext.attacker,
59
84
  target: hitContext.target,
60
85
  rawDamage: damage.raw,
86
+ reaction: hitContext.reaction,
61
87
  metadata: hitContext.metadata,
62
88
  };
63
89
 
package/src/index.ts CHANGED
@@ -7,7 +7,18 @@ import type { ActionBattleOptions } from "./types";
7
7
  export { BattleAi, AiState, EnemyType, AttackPattern, AiDebug, DEFAULT_KNOCKBACK } from "./ai.server";
8
8
 
9
9
  // Types exports
10
- export type { HitResult, ApplyHitHooks, BattleAiOptions } from "./ai.server";
10
+ export type {
11
+ HitResult,
12
+ ApplyHitHooks,
13
+ BattleAiOptions,
14
+ BattleAiDefeatedCallback,
15
+ BattleAiDefeatedContext,
16
+ BattleAiDefeatReward,
17
+ BattleAiLegacyDefeatedCallback,
18
+ BattleAiLegacyOptions,
19
+ BattleAiRewardItem,
20
+ BattleAiRewards,
21
+ } from "./ai.server";
11
22
  export type {
12
23
  ActionBattleAnimationContext,
13
24
  ActionBattleAnimationEntity,
@@ -25,6 +36,15 @@ export type {
25
36
  ActionBattleUiOptions,
26
37
  ActionBattleUiActionBarOptions,
27
38
  ActionBattleUiTargetingOptions,
39
+ ActionBattleAttackDirection,
40
+ ActionBattleAttackHitboxConfig,
41
+ ActionBattleAttackHitboxMap,
42
+ ActionBattleAttackHitPolicy,
43
+ ActionBattleAttackProfile,
44
+ ActionBattleDebugOptions,
45
+ ActionBattleHitReactionProfile,
46
+ NormalizedActionBattleHitReactionProfile,
47
+ NormalizedActionBattleAttackProfile,
28
48
  ActionBattleCombatOptions,
29
49
  ActionBattleSystemOptions,
30
50
  ActionBattleAiSystemOptions,
@@ -47,6 +67,33 @@ export type {
47
67
  ActionBattleKnockbackResult,
48
68
  ActionBattleSystems,
49
69
  } from "./core/contracts";
70
+ export {
71
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE,
72
+ normalizeActionBattleAttackProfile,
73
+ type ActionBattleAttackProfileFallbacks,
74
+ } from "./core/attack-profile";
75
+ export {
76
+ ACTION_BATTLE_HITBOX_FRAME_MS,
77
+ ActionBattleHitTracker,
78
+ createActionBattleAttackId,
79
+ getNormalizedActionBattleAttackProfile,
80
+ resolveActionBattleHitboxSpeed,
81
+ scheduleActionBattleStartup,
82
+ } from "./core/attack-runtime";
83
+ export {
84
+ DEFAULT_ACTION_BATTLE_HIT_REACTION,
85
+ isActionBattleEntityInvincible,
86
+ normalizeActionBattleHitReaction,
87
+ setActionBattleInvincibility,
88
+ } from "./core/hit-reaction";
89
+ export {
90
+ DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES,
91
+ normalizeActionBattleEnemyAttackProfiles,
92
+ type ActionBattleEnemyAttackProfileKey,
93
+ type ActionBattleEnemyAttackProfileMap,
94
+ type NormalizedActionBattleEnemyAttackProfileMap,
95
+ } from "./core/enemy-attack-profiles";
96
+ export { resolveActionBattleWeaponAttackProfile } from "./core/equipment";
50
97
  export {
51
98
  DEFAULT_ZELDA_PLAYER_HITBOXES,
52
99
  createDefaultPlayerHitboxResolver,
package/src/server.ts CHANGED
@@ -12,7 +12,20 @@ import { playActionBattleAnimation } from "./animations";
12
12
  import { getActionBattleSystems, setActionBattleSystems } from "./core/context";
13
13
  import { applyActionBattleHit } from "./core/hit";
14
14
  import { DEFAULT_ZELDA_PLAYER_HITBOXES } from "./core/defaults";
15
+ import {
16
+ ActionBattleHitTracker,
17
+ createActionBattleAttackId,
18
+ getNormalizedActionBattleAttackProfile,
19
+ resolveActionBattleHitboxSpeed,
20
+ scheduleActionBattleStartup,
21
+ } from "./core/attack-runtime";
22
+ import { normalizeActionBattleAttackProfile } from "./core/attack-profile";
23
+ import { resolveActionBattleWeaponAttackProfile } from "./core/equipment";
15
24
  import type { ActionBattleHitbox } from "./core/contracts";
25
+ import type {
26
+ ActionBattleAttackProfile,
27
+ NormalizedActionBattleAttackProfile,
28
+ } from "./types";
16
29
 
17
30
  export const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
18
31
  const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
@@ -31,7 +44,8 @@ export const DEFAULT_PLAYER_ATTACK_HITBOXES = {
31
44
  const beginPlayerAttackLock = (
32
45
  player: RpgPlayer,
33
46
  map: ReturnType<RpgPlayer["getCurrentMap"]> | undefined,
34
- durationMs: number
47
+ durationMs: number,
48
+ locks: { movement: boolean; direction: boolean }
35
49
  ): boolean => {
36
50
  if (durationMs <= 0) return true;
37
51
 
@@ -53,11 +67,15 @@ const beginPlayerAttackLock = (
53
67
  const previousDirectionFixed = player.directionFixed;
54
68
  const previousAnimationFixed = player.animationFixed;
55
69
 
56
- player.pendingInputs = [];
57
- player.lastProcessedInputTs = 0;
58
- (map as any)?.stopMovement?.(player);
59
- player.canMove.set(false);
60
- player.directionFixed = true;
70
+ if (locks.movement) {
71
+ player.pendingInputs = [];
72
+ player.lastProcessedInputTs = 0;
73
+ (map as any)?.stopMovement?.(player);
74
+ player.canMove.set(false);
75
+ }
76
+ if (locks.direction) {
77
+ player.directionFixed = true;
78
+ }
61
79
 
62
80
  setTimeout(() => {
63
81
  if (runtimePlayer.__actionBattleAttackLockId !== lockId) return;
@@ -115,6 +133,16 @@ const getVisibleActionEvents = (
115
133
  collisions.forEach((id: string) => addEvent(map.getEvent(id)));
116
134
  }
117
135
 
136
+ const direction =
137
+ typeof player.getDirection === "function" ? player.getDirection() : undefined;
138
+ const interactionCollisions = (map as any).getInteractionCollisions?.(
139
+ player.id,
140
+ direction
141
+ );
142
+ if (Array.isArray(interactionCollisions)) {
143
+ interactionCollisions.forEach((id: string) => addEvent(map.getEvent(id)));
144
+ }
145
+
118
146
  for (const event of map.getEvents()) {
119
147
  const rect = eventRect(event);
120
148
  if (hitboxes.some((hitbox) => rectsOverlap(hitbox, rect))) {
@@ -200,7 +228,8 @@ export function getPlayerWeaponKnockbackForce(player: RpgPlayer): number {
200
228
  export function applyPlayerHitToEvent(
201
229
  player: RpgPlayer,
202
230
  target: RpgEvent,
203
- hooks?: ApplyHitHooks
231
+ hooks?: ApplyHitHooks,
232
+ metadata?: Record<string, any>
204
233
  ): HitResult | undefined {
205
234
  const ai = (target as any).battleAi as BattleAi;
206
235
  if (!ai) return undefined;
@@ -243,6 +272,8 @@ export function applyPlayerHitToEvent(
243
272
  {
244
273
  attacker: player,
245
274
  target,
275
+ metadata,
276
+ reaction: metadata?.reaction,
246
277
  }
247
278
  );
248
279
 
@@ -251,6 +282,7 @@ export function applyPlayerHitToEvent(
251
282
  damage: result.damage,
252
283
  defeated: result.defeated,
253
284
  raw: result.rawDamage,
285
+ reaction: result.reaction,
254
286
  });
255
287
  }
256
288
 
@@ -269,11 +301,13 @@ const toLegacyHitResult = (context: any): HitResult => ({
269
301
  const resolvePlayerAttackHitboxes = (
270
302
  player: RpgPlayer,
271
303
  directionKey: string,
272
- options: ActionBattleOptions
304
+ options: ActionBattleOptions,
305
+ profile: NormalizedActionBattleAttackProfile
273
306
  ): ActionBattleHitbox[] => {
274
307
  const configuredHitboxes = {
275
308
  ...DEFAULT_PLAYER_ATTACK_HITBOXES,
276
309
  ...options.attack?.hitboxes,
310
+ ...profile.hitboxes,
277
311
  };
278
312
  const hitboxConfig =
279
313
  configuredHitboxes[
@@ -296,6 +330,39 @@ const resolvePlayerAttackHitboxes = (
296
330
  );
297
331
  };
298
332
 
333
+ const mergeAttackProfileOverrides = (
334
+ base: NormalizedActionBattleAttackProfile,
335
+ override: ActionBattleAttackProfile
336
+ ): ActionBattleAttackProfile => ({
337
+ ...base,
338
+ ...override,
339
+ reaction: {
340
+ ...base.reaction,
341
+ ...override.reaction,
342
+ },
343
+ hitboxes: {
344
+ ...base.hitboxes,
345
+ ...override.hitboxes,
346
+ },
347
+ });
348
+
349
+ const resolvePlayerAttackProfile = (
350
+ player: RpgPlayer,
351
+ options: ActionBattleOptions
352
+ ): NormalizedActionBattleAttackProfile => {
353
+ const baseProfile = getNormalizedActionBattleAttackProfile(options);
354
+ const weaponProfile = resolveActionBattleWeaponAttackProfile(player);
355
+ if (!weaponProfile) return baseProfile;
356
+ return normalizeActionBattleAttackProfile(
357
+ mergeAttackProfileOverrides(baseProfile, weaponProfile),
358
+ {
359
+ lockMovement: options.attack?.lockMovement,
360
+ lockDurationMs: options.attack?.lockDurationMs,
361
+ hitboxes: options.attack?.hitboxes,
362
+ }
363
+ );
364
+ };
365
+
299
366
  const resolveSignal = (value: any) =>
300
367
  typeof value === "function" ? value() : value;
301
368
 
@@ -423,7 +490,7 @@ const ensureActionBarGui = (
423
490
  const gui = existing || player.gui(ACTION_BATTLE_ACTION_BAR_GUI_ID);
424
491
  if (!(gui as any).__actionBattleReady) {
425
492
  (gui as any).__actionBattleReady = true;
426
- gui.on("useItem", ({ id }) => {
493
+ gui.on("useItem", ({ id }: { id: string }) => {
427
494
  try {
428
495
  player.useItem(id);
429
496
  } catch {
@@ -431,10 +498,13 @@ const ensureActionBarGui = (
431
498
  }
432
499
  gui.update(buildActionBarData(player, options));
433
500
  });
434
- gui.on("useSkill", ({ id, target }) => {
435
- handleActionBattleSkillUse(player, id, target, options);
436
- gui.update(buildActionBarData(player, options));
437
- });
501
+ gui.on(
502
+ "useSkill",
503
+ ({ id, target }: { id: string; target?: { x: number; y: number } }) => {
504
+ handleActionBattleSkillUse(player, id, target, options);
505
+ gui.update(buildActionBarData(player, options));
506
+ }
507
+ );
438
508
  gui.on("refresh", () => {
439
509
  gui.update(buildActionBarData(player, options));
440
510
  });
@@ -523,7 +593,7 @@ const handleActionBattleSkillUse = (
523
593
  const targets: any[] = [];
524
594
  const affects = options.targeting?.affects || "events";
525
595
  if (affects === "events" || affects === "both") {
526
- map.getEvents().forEach((event) => {
596
+ map.getEvents().forEach((event: RpgEvent) => {
527
597
  const tile = getEntityTile(event, tileSize);
528
598
  if (affected.has(`${tile.x},${tile.y}`)) {
529
599
  targets.push(event);
@@ -531,7 +601,7 @@ const handleActionBattleSkillUse = (
531
601
  });
532
602
  }
533
603
  if (affects === "players" || affects === "both") {
534
- map.getPlayers().forEach((other) => {
604
+ map.getPlayers().forEach((other: RpgPlayer) => {
535
605
  if (other.id === player.id) return;
536
606
  const tile = getEntityTile(other, tileSize);
537
607
  if (affected.has(`${tile.x},${tile.y}`)) {
@@ -574,6 +644,7 @@ export const createActionBattleServer = (
574
644
  if (input.action == Control.Action) {
575
645
  const map = player.getCurrentMap();
576
646
  const direction = player.getDirection();
647
+ const attackProfile = resolvePlayerAttackProfile(player, options);
577
648
 
578
649
  // Convert Direction enum to string key
579
650
  const directionKey = direction as string;
@@ -581,42 +652,80 @@ export const createActionBattleServer = (
581
652
  const hitboxes = resolvePlayerAttackHitboxes(
582
653
  player,
583
654
  directionKey,
584
- options
655
+ options,
656
+ attackProfile
585
657
  );
586
658
 
587
659
  if (isActionReservedForNormalEvent(player, map, hitboxes)) {
588
660
  return;
589
661
  }
590
662
 
591
- const lockMovement = options.attack?.lockMovement !== false;
663
+ const lockMovement = attackProfile.movementLock;
664
+ const lockDirection = attackProfile.directionLock;
592
665
  const lockDurationMs =
593
- options.attack?.lockDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS;
594
- let movementLocked = false;
666
+ attackProfile.totalDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS;
667
+ const actionLocked = (lockMovement || lockDirection) && lockDurationMs > 0;
595
668
 
596
669
  if (
597
- lockMovement &&
598
- !beginPlayerAttackLock(player, map, Math.max(0, lockDurationMs))
670
+ actionLocked &&
671
+ !beginPlayerAttackLock(player, map, Math.max(0, lockDurationMs), {
672
+ movement: lockMovement,
673
+ direction: lockDirection,
674
+ })
599
675
  ) {
600
676
  return;
601
677
  }
602
- movementLocked = lockMovement && lockDurationMs > 0;
603
678
 
604
679
  playActionBattleAnimation("attack", player, options.animations);
605
- if (movementLocked) {
680
+ if (actionLocked) {
606
681
  player.animationFixed = true;
607
682
  }
683
+ const attackId = createActionBattleAttackId(
684
+ player.id,
685
+ attackProfile.id
686
+ );
687
+ const hitTracker = new ActionBattleHitTracker(
688
+ attackProfile.hitPolicy
689
+ );
690
+ if (options.debug?.attacks) {
691
+ console.log("[ActionBattle] player attack", {
692
+ attackId,
693
+ playerId: player.id,
694
+ profile: attackProfile.id,
695
+ hitboxes,
696
+ });
697
+ }
608
698
 
609
- map?.createMovingHitbox(hitboxes, { speed: 3 }).subscribe({
610
- next(hits) {
611
- hits.forEach((hit) => {
612
- if (hit instanceof RpgEvent) {
613
- const result = applyPlayerHitToEvent(player, hit);
614
- if (result?.defeated) {
615
- console.log(`Player ${player.id} defeated AI ${hit.id}`);
616
- }
617
- }
699
+ scheduleActionBattleStartup(attackProfile, () => {
700
+ map
701
+ ?.createMovingHitbox(hitboxes, {
702
+ speed: resolveActionBattleHitboxSpeed(
703
+ attackProfile,
704
+ hitboxes.length
705
+ ),
706
+ })
707
+ .subscribe({
708
+ next(hits: any[]) {
709
+ hits.forEach((hit: any) => {
710
+ if (hit instanceof RpgEvent) {
711
+ if (!hitTracker.tryHit(hit)) return;
712
+ const result = applyPlayerHitToEvent(
713
+ player,
714
+ hit,
715
+ undefined,
716
+ {
717
+ attackId,
718
+ attackProfileId: attackProfile.id,
719
+ reaction: attackProfile.reaction,
720
+ }
721
+ );
722
+ if (result?.defeated) {
723
+ console.log(`Player ${player.id} defeated AI ${hit.id}`);
724
+ }
725
+ }
726
+ });
727
+ },
618
728
  });
619
- },
620
729
  });
621
730
  }
622
731
  },
@@ -662,6 +771,44 @@ export const createActionBattleServer = (
662
771
 
663
772
  export default createActionBattleServer();
664
773
 
774
+ export {
775
+ ACTION_BATTLE_HITBOX_FRAME_MS,
776
+ ActionBattleHitTracker,
777
+ createActionBattleAttackId,
778
+ getNormalizedActionBattleAttackProfile,
779
+ resolveActionBattleHitboxSpeed,
780
+ scheduleActionBattleStartup,
781
+ } from "./core/attack-runtime";
782
+ export {
783
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE,
784
+ normalizeActionBattleAttackProfile,
785
+ type ActionBattleAttackProfileFallbacks,
786
+ } from "./core/attack-profile";
787
+ export type {
788
+ ActionBattleAttackDirection,
789
+ ActionBattleAttackHitboxConfig,
790
+ ActionBattleAttackHitboxMap,
791
+ ActionBattleAttackHitPolicy,
792
+ ActionBattleAttackProfile,
793
+ ActionBattleDebugOptions,
794
+ ActionBattleHitReactionProfile,
795
+ NormalizedActionBattleHitReactionProfile,
796
+ NormalizedActionBattleAttackProfile,
797
+ } from "./types";
798
+ export {
799
+ DEFAULT_ACTION_BATTLE_HIT_REACTION,
800
+ isActionBattleEntityInvincible,
801
+ normalizeActionBattleHitReaction,
802
+ setActionBattleInvincibility,
803
+ } from "./core/hit-reaction";
804
+ export {
805
+ DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES,
806
+ normalizeActionBattleEnemyAttackProfiles,
807
+ type ActionBattleEnemyAttackProfileKey,
808
+ type ActionBattleEnemyAttackProfileMap,
809
+ type NormalizedActionBattleEnemyAttackProfileMap,
810
+ } from "./core/enemy-attack-profiles";
811
+ export { resolveActionBattleWeaponAttackProfile } from "./core/equipment";
665
812
  export {
666
813
  AiDebug,
667
814
  AiState,
@@ -670,4 +817,15 @@ export {
670
817
  DEFAULT_KNOCKBACK,
671
818
  EnemyType,
672
819
  } from "./ai.server";
673
- export type { ApplyHitHooks, BattleAiOptions, HitResult } from "./ai.server";
820
+ export type {
821
+ ApplyHitHooks,
822
+ BattleAiDefeatedCallback,
823
+ BattleAiDefeatedContext,
824
+ BattleAiDefeatReward,
825
+ BattleAiLegacyDefeatedCallback,
826
+ BattleAiLegacyOptions,
827
+ BattleAiOptions,
828
+ BattleAiRewardItem,
829
+ BattleAiRewards,
830
+ HitResult,
831
+ } from "./ai.server";