@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
package/src/server.ts CHANGED
@@ -6,10 +6,29 @@ import {
6
6
  ActionBattleActionBarSkill,
7
7
  ActionBattleOptions,
8
8
  } from "./types";
9
- import { normalizeActionBattleOptions } from "./config";
9
+ import { normalizeActionBattleOptions, setActionBattleOptions } from "./config";
10
10
  import { manhattanDistance, parseAoeMask } from "./targeting";
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 {
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";
24
+ import type { ActionBattleHitbox } from "./core/contracts";
25
+ import type {
26
+ ActionBattleAttackProfile,
27
+ NormalizedActionBattleAttackProfile,
28
+ } from "./types";
11
29
 
12
30
  export const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
31
+ const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
13
32
 
14
33
  /**
15
34
  * Default player attack hitboxes offsets for each direction
@@ -19,11 +38,127 @@ export const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
19
38
  * when creating the moving hitbox.
20
39
  */
21
40
  export const DEFAULT_PLAYER_ATTACK_HITBOXES = {
22
- up: { offsetX: -16, offsetY: -48, width: 32, height: 32 },
23
- down: { offsetX: -16, offsetY: 16, width: 32, height: 32 },
24
- left: { offsetX: -48, offsetY: -16, width: 32, height: 32 },
25
- right: { offsetX: 16, offsetY: -16, width: 32, height: 32 },
26
- default: { offsetX: 0, offsetY: -32, width: 32, height: 32 }
41
+ ...DEFAULT_ZELDA_PLAYER_HITBOXES,
42
+ };
43
+
44
+ const beginPlayerAttackLock = (
45
+ player: RpgPlayer,
46
+ map: ReturnType<RpgPlayer["getCurrentMap"]> | undefined,
47
+ durationMs: number,
48
+ locks: { movement: boolean; direction: boolean }
49
+ ): boolean => {
50
+ if (durationMs <= 0) return true;
51
+
52
+ const runtimePlayer = player as any;
53
+ const now = Date.now();
54
+ if (
55
+ typeof runtimePlayer.__actionBattleAttackLockedUntil === "number" &&
56
+ runtimePlayer.__actionBattleAttackLockedUntil > now
57
+ ) {
58
+ return false;
59
+ }
60
+
61
+ const lockId = (runtimePlayer.__actionBattleAttackLockId ?? 0) + 1;
62
+ runtimePlayer.__actionBattleAttackLockId = lockId;
63
+ runtimePlayer.__actionBattleAttackLockedUntil = now + durationMs;
64
+
65
+ const previousCanMove = player.canMove;
66
+ const previousDirectionFixed = player.directionFixed;
67
+ const previousAnimationFixed = player.animationFixed;
68
+
69
+ if (locks.movement) {
70
+ player.pendingInputs = [];
71
+ player.lastProcessedInputTs = 0;
72
+ (map as any)?.stopMovement?.(player);
73
+ player.canMove = false;
74
+ }
75
+ if (locks.direction) {
76
+ player.directionFixed = true;
77
+ }
78
+
79
+ setTimeout(() => {
80
+ if (runtimePlayer.__actionBattleAttackLockId !== lockId) return;
81
+ runtimePlayer.__actionBattleAttackLockedUntil = 0;
82
+ player.canMove = previousCanMove;
83
+ player.directionFixed = previousDirectionFixed;
84
+ player.animationFixed = previousAnimationFixed;
85
+ }, durationMs);
86
+
87
+ return true;
88
+ };
89
+
90
+ const isBattleEvent = (event: RpgEvent) => !!(event as any).battleAi;
91
+
92
+ const rectsOverlap = (
93
+ a: { x: number; y: number; width: number; height: number },
94
+ b: { x: number; y: number; width: number; height: number }
95
+ ) =>
96
+ a.x < b.x + b.width &&
97
+ a.x + a.width > b.x &&
98
+ a.y < b.y + b.height &&
99
+ a.y + a.height > b.y;
100
+
101
+ const eventRect = (event: RpgEvent) => {
102
+ const hitbox =
103
+ typeof event.hitbox === "function" ? event.hitbox() : (event as any).hitbox;
104
+ return {
105
+ x: event.x(),
106
+ y: event.y(),
107
+ width: hitbox?.w ?? 32,
108
+ height: hitbox?.h ?? 32,
109
+ };
110
+ };
111
+
112
+ const getVisibleActionEvents = (
113
+ player: RpgPlayer,
114
+ map: ReturnType<RpgPlayer["getCurrentMap"]> | undefined,
115
+ hitboxes: Array<{ x: number; y: number; width: number; height: number }>
116
+ ) => {
117
+ if (!map) return [];
118
+
119
+ const eventsById = new Map<string, RpgEvent>();
120
+ const addEvent = (event: RpgEvent | undefined) => {
121
+ if (!event) return;
122
+ const isVisible =
123
+ typeof (map as any).isEventVisibleForPlayer === "function"
124
+ ? (map as any).isEventVisibleForPlayer(event, player)
125
+ : true;
126
+ if (!isVisible) return;
127
+ eventsById.set(event.id, event);
128
+ };
129
+
130
+ const collisions = (map as any).getCollisions?.(player.id);
131
+ if (Array.isArray(collisions)) {
132
+ collisions.forEach((id: string) => addEvent(map.getEvent(id)));
133
+ }
134
+
135
+ const direction =
136
+ typeof player.getDirection === "function" ? player.getDirection() : undefined;
137
+ const interactionCollisions = (map as any).getInteractionCollisions?.(
138
+ player.id,
139
+ direction
140
+ );
141
+ if (Array.isArray(interactionCollisions)) {
142
+ interactionCollisions.forEach((id: string) => addEvent(map.getEvent(id)));
143
+ }
144
+
145
+ for (const event of map.getEvents()) {
146
+ const rect = eventRect(event);
147
+ if (hitboxes.some((hitbox) => rectsOverlap(hitbox, rect))) {
148
+ addEvent(event);
149
+ }
150
+ }
151
+
152
+ return Array.from(eventsById.values());
153
+ };
154
+
155
+ const isActionReservedForNormalEvent = (
156
+ player: RpgPlayer,
157
+ map: ReturnType<RpgPlayer["getCurrentMap"]> | undefined,
158
+ hitboxes: Array<{ x: number; y: number; width: number; height: number }>
159
+ ) => {
160
+ const events = getVisibleActionEvents(player, map, hitboxes);
161
+ return events.length > 0 && !events.some(isBattleEvent);
27
162
  };
28
163
 
29
164
  /**
@@ -92,57 +227,141 @@ export function getPlayerWeaponKnockbackForce(player: RpgPlayer): number {
92
227
  export function applyPlayerHitToEvent(
93
228
  player: RpgPlayer,
94
229
  target: RpgEvent,
95
- hooks?: ApplyHitHooks
230
+ hooks?: ApplyHitHooks,
231
+ metadata?: Record<string, any>
96
232
  ): HitResult | undefined {
97
233
  const ai = (target as any).battleAi as BattleAi;
98
234
  if (!ai) return undefined;
99
235
 
100
- // Get knockback force from player's weapon
101
- const knockbackForce = getPlayerWeaponKnockbackForce(player);
102
-
103
- // Apply damage to AI
104
- const defeated = ai.takeDamage(player);
105
-
106
- // Calculate knockback direction (away from player)
107
- const dx = target.x() - player.x();
108
- const dy = target.y() - player.y();
109
- const distance = Math.sqrt(dx * dx + dy * dy);
110
-
111
- // Create hit result
112
- let hitResult: HitResult = {
113
- damage: 0, // Will be set by takeDamage internally
114
- knockbackForce,
115
- knockbackDuration: DEFAULT_KNOCKBACK.duration,
116
- defeated,
117
- attacker: player,
118
- target
119
- };
120
-
121
- // Call onBeforeHit hook
122
- if (hooks?.onBeforeHit) {
123
- const modified = hooks.onBeforeHit(hitResult);
124
- if (modified) {
125
- hitResult = modified;
236
+ const systems = getActionBattleSystems();
237
+ const result = applyActionBattleHit(
238
+ {
239
+ ...systems.combat,
240
+ hooks: hooks
241
+ ? {
242
+ ...systems.combat.hooks,
243
+ beforeHit(context) {
244
+ const before = systems.combat.hooks?.beforeHit?.(context);
245
+ if (before === false) return false;
246
+ const nextContext = before || context;
247
+ const legacyResult = toLegacyHitResult(nextContext);
248
+ const modified = hooks.onBeforeHit?.(legacyResult);
249
+ if (!modified) return nextContext;
250
+ return {
251
+ ...nextContext,
252
+ damage: {
253
+ damage: modified.damage,
254
+ defeated: modified.defeated,
255
+ raw: nextContext.damage?.raw,
256
+ },
257
+ knockback: {
258
+ force: modified.knockbackForce,
259
+ duration: modified.knockbackDuration,
260
+ direction: nextContext.knockback?.direction,
261
+ },
262
+ };
263
+ },
264
+ afterHit(result) {
265
+ systems.combat.hooks?.afterHit?.(result);
266
+ hooks.onAfterHit?.(result as HitResult);
267
+ },
268
+ }
269
+ : systems.combat.hooks,
270
+ },
271
+ {
272
+ attacker: player,
273
+ target,
274
+ metadata,
275
+ reaction: metadata?.reaction,
126
276
  }
127
- }
128
-
129
- // Apply knockback only if not defeated (entity still exists)
130
- if (!hitResult.defeated && hitResult.knockbackForce > 0 && distance > 0) {
131
- const knockbackDirection = {
132
- x: dx / distance,
133
- y: dy / distance
134
- };
135
- target.knockback(knockbackDirection, hitResult.knockbackForce, hitResult.knockbackDuration);
136
- }
277
+ );
137
278
 
138
- // Call onAfterHit hook
139
- if (hooks?.onAfterHit) {
140
- hooks.onAfterHit(hitResult);
279
+ if (!result.cancelled) {
280
+ ai.handleDamage(player, {
281
+ damage: result.damage,
282
+ defeated: result.defeated,
283
+ raw: result.rawDamage,
284
+ reaction: result.reaction,
285
+ });
141
286
  }
142
287
 
143
- return hitResult;
288
+ return result as HitResult;
144
289
  }
145
290
 
291
+ const toLegacyHitResult = (context: any): HitResult => ({
292
+ damage: context.damage?.damage ?? 0,
293
+ knockbackForce: context.knockback?.force ?? getPlayerWeaponKnockbackForce(context.attacker),
294
+ knockbackDuration: context.knockback?.duration ?? DEFAULT_KNOCKBACK.duration,
295
+ defeated: context.damage?.defeated ?? false,
296
+ attacker: context.attacker,
297
+ target: context.target,
298
+ });
299
+
300
+ const resolvePlayerAttackHitboxes = (
301
+ player: RpgPlayer,
302
+ directionKey: string,
303
+ options: ActionBattleOptions,
304
+ profile: NormalizedActionBattleAttackProfile
305
+ ): ActionBattleHitbox[] => {
306
+ const configuredHitboxes = {
307
+ ...DEFAULT_PLAYER_ATTACK_HITBOXES,
308
+ ...options.attack?.hitboxes,
309
+ ...profile.hitboxes,
310
+ };
311
+ const hitboxConfig =
312
+ configuredHitboxes[
313
+ directionKey as keyof typeof DEFAULT_PLAYER_ATTACK_HITBOXES
314
+ ] || configuredHitboxes.default;
315
+ const defaultHitboxes = [
316
+ {
317
+ x: player.x() + hitboxConfig.offsetX,
318
+ y: player.y() + hitboxConfig.offsetY,
319
+ width: hitboxConfig.width,
320
+ height: hitboxConfig.height,
321
+ },
322
+ ];
323
+ return (
324
+ options.attack?.resolveHitboxes?.({
325
+ player,
326
+ direction: directionKey,
327
+ defaultHitboxes,
328
+ }) ?? defaultHitboxes
329
+ );
330
+ };
331
+
332
+ const mergeAttackProfileOverrides = (
333
+ base: NormalizedActionBattleAttackProfile,
334
+ override: ActionBattleAttackProfile
335
+ ): ActionBattleAttackProfile => ({
336
+ ...base,
337
+ ...override,
338
+ reaction: {
339
+ ...base.reaction,
340
+ ...override.reaction,
341
+ },
342
+ hitboxes: {
343
+ ...base.hitboxes,
344
+ ...override.hitboxes,
345
+ },
346
+ });
347
+
348
+ const resolvePlayerAttackProfile = (
349
+ player: RpgPlayer,
350
+ options: ActionBattleOptions
351
+ ): NormalizedActionBattleAttackProfile => {
352
+ const baseProfile = getNormalizedActionBattleAttackProfile(options);
353
+ const weaponProfile = resolveActionBattleWeaponAttackProfile(player);
354
+ if (!weaponProfile) return baseProfile;
355
+ return normalizeActionBattleAttackProfile(
356
+ mergeAttackProfileOverrides(baseProfile, weaponProfile),
357
+ {
358
+ lockMovement: options.attack?.lockMovement,
359
+ lockDurationMs: options.attack?.lockDurationMs,
360
+ hitboxes: options.attack?.hitboxes,
361
+ }
362
+ );
363
+ };
364
+
146
365
  const resolveSignal = (value: any) =>
147
366
  typeof value === "function" ? value() : value;
148
367
 
@@ -270,7 +489,7 @@ const ensureActionBarGui = (
270
489
  const gui = existing || player.gui(ACTION_BATTLE_ACTION_BAR_GUI_ID);
271
490
  if (!(gui as any).__actionBattleReady) {
272
491
  (gui as any).__actionBattleReady = true;
273
- gui.on("useItem", ({ id }) => {
492
+ gui.on("useItem", ({ id }: { id: string }) => {
274
493
  try {
275
494
  player.useItem(id);
276
495
  } catch {
@@ -278,10 +497,13 @@ const ensureActionBarGui = (
278
497
  }
279
498
  gui.update(buildActionBarData(player, options));
280
499
  });
281
- gui.on("useSkill", ({ id, target }) => {
282
- handleActionBattleSkillUse(player, id, target, options);
283
- gui.update(buildActionBarData(player, options));
284
- });
500
+ gui.on(
501
+ "useSkill",
502
+ ({ id, target }: { id: string; target?: { x: number; y: number } }) => {
503
+ handleActionBattleSkillUse(player, id, target, options);
504
+ gui.update(buildActionBarData(player, options));
505
+ }
506
+ );
285
507
  gui.on("refresh", () => {
286
508
  gui.update(buildActionBarData(player, options));
287
509
  });
@@ -330,13 +552,21 @@ const handleActionBattleSkillUse = (
330
552
  target: { x: number; y: number } | undefined,
331
553
  options: ActionBattleOptions
332
554
  ) => {
555
+ const skillData = resolveSkillData(player, skillId);
556
+
333
557
  const map = player.getCurrentMap();
334
558
  if (!map) {
559
+ playActionBattleAnimation("castSkill", player, options.animations, {
560
+ skill: skillData,
561
+ });
335
562
  player.useSkill(skillId);
336
563
  return;
337
564
  }
338
565
  const targeting = resolveSkillTargeting(player, skillId, options);
339
566
  if (!targeting || !target) {
567
+ playActionBattleAnimation("castSkill", player, options.animations, {
568
+ skill: skillData,
569
+ });
340
570
  player.useSkill(skillId);
341
571
  return;
342
572
  }
@@ -362,7 +592,7 @@ const handleActionBattleSkillUse = (
362
592
  const targets: any[] = [];
363
593
  const affects = options.targeting?.affects || "events";
364
594
  if (affects === "events" || affects === "both") {
365
- map.getEvents().forEach((event) => {
595
+ map.getEvents().forEach((event: RpgEvent) => {
366
596
  const tile = getEntityTile(event, tileSize);
367
597
  if (affected.has(`${tile.x},${tile.y}`)) {
368
598
  targets.push(event);
@@ -370,7 +600,7 @@ const handleActionBattleSkillUse = (
370
600
  });
371
601
  }
372
602
  if (affects === "players" || affects === "both") {
373
- map.getPlayers().forEach((other) => {
603
+ map.getPlayers().forEach((other: RpgPlayer) => {
374
604
  if (other.id === player.id) return;
375
605
  const tile = getEntityTile(other, tileSize);
376
606
  if (affected.has(`${tile.x},${tile.y}`)) {
@@ -383,6 +613,10 @@ const handleActionBattleSkillUse = (
383
613
  return;
384
614
  }
385
615
 
616
+ playActionBattleAnimation("castSkill", player, options.animations, {
617
+ skill: skillData,
618
+ target: targets[0],
619
+ });
386
620
  player.useSkill(skillId, targets as any);
387
621
  };
388
622
 
@@ -390,6 +624,8 @@ export const createActionBattleServer = (
390
624
  rawOptions: ActionBattleOptions = {}
391
625
  ) => {
392
626
  const options = normalizeActionBattleOptions(rawOptions);
627
+ setActionBattleOptions(options);
628
+ setActionBattleSystems(options);
393
629
  return defineModule<RpgServer>({
394
630
  player: {
395
631
  /**
@@ -405,51 +641,90 @@ export const createActionBattleServer = (
405
641
  */
406
642
  onInput(player: RpgPlayer, input: any) {
407
643
  if (input.action == Control.Action) {
408
- // Trigger attack animation
409
- player.setGraphicAnimation("attack", 1);
410
-
411
- // Get player position
412
- const playerX = player.x();
413
- const playerY = player.y();
644
+ const map = player.getCurrentMap();
414
645
  const direction = player.getDirection();
646
+ const attackProfile = resolvePlayerAttackProfile(player, options);
415
647
 
416
648
  // Convert Direction enum to string key
417
649
  const directionKey = direction as string;
418
650
 
419
- // Get hitbox configuration for the direction
420
- const hitboxConfig =
421
- DEFAULT_PLAYER_ATTACK_HITBOXES[
422
- directionKey as keyof typeof DEFAULT_PLAYER_ATTACK_HITBOXES
423
- ] || DEFAULT_PLAYER_ATTACK_HITBOXES.default;
424
-
425
- // Convert relative hitbox to absolute coordinates
426
- const hitboxes: Array<{
427
- x: number;
428
- y: number;
429
- width: number;
430
- height: number;
431
- }> = [
432
- {
433
- x: playerX + hitboxConfig.offsetX,
434
- y: playerY + hitboxConfig.offsetY,
435
- width: hitboxConfig.width,
436
- height: hitboxConfig.height,
437
- },
438
- ];
439
-
440
- const map = player.getCurrentMap();
441
-
442
- map?.createMovingHitbox(hitboxes, { speed: 3 }).subscribe({
443
- next(hits) {
444
- hits.forEach((hit) => {
445
- if (hit instanceof RpgEvent) {
446
- const result = applyPlayerHitToEvent(player, hit);
447
- if (result?.defeated) {
448
- console.log(`Player ${player.id} defeated AI ${hit.id}`);
449
- }
450
- }
651
+ const hitboxes = resolvePlayerAttackHitboxes(
652
+ player,
653
+ directionKey,
654
+ options,
655
+ attackProfile
656
+ );
657
+
658
+ if (isActionReservedForNormalEvent(player, map, hitboxes)) {
659
+ return;
660
+ }
661
+
662
+ const lockMovement = attackProfile.movementLock;
663
+ const lockDirection = attackProfile.directionLock;
664
+ const lockDurationMs =
665
+ attackProfile.totalDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS;
666
+ const actionLocked = (lockMovement || lockDirection) && lockDurationMs > 0;
667
+
668
+ if (
669
+ actionLocked &&
670
+ !beginPlayerAttackLock(player, map, Math.max(0, lockDurationMs), {
671
+ movement: lockMovement,
672
+ direction: lockDirection,
673
+ })
674
+ ) {
675
+ return;
676
+ }
677
+
678
+ playActionBattleAnimation("attack", player, options.animations);
679
+ if (actionLocked) {
680
+ player.animationFixed = true;
681
+ }
682
+ const attackId = createActionBattleAttackId(
683
+ player.id,
684
+ attackProfile.id
685
+ );
686
+ const hitTracker = new ActionBattleHitTracker(
687
+ attackProfile.hitPolicy
688
+ );
689
+ if (options.debug?.attacks) {
690
+ console.log("[ActionBattle] player attack", {
691
+ attackId,
692
+ playerId: player.id,
693
+ profile: attackProfile.id,
694
+ hitboxes,
695
+ });
696
+ }
697
+
698
+ scheduleActionBattleStartup(attackProfile, () => {
699
+ map
700
+ ?.createMovingHitbox(hitboxes, {
701
+ speed: resolveActionBattleHitboxSpeed(
702
+ attackProfile,
703
+ hitboxes.length
704
+ ),
705
+ })
706
+ .subscribe({
707
+ next(hits: any[]) {
708
+ hits.forEach((hit: any) => {
709
+ if (hit instanceof RpgEvent) {
710
+ if (!hitTracker.tryHit(hit)) return;
711
+ const result = applyPlayerHitToEvent(
712
+ player,
713
+ hit,
714
+ undefined,
715
+ {
716
+ attackId,
717
+ attackProfileId: attackProfile.id,
718
+ reaction: attackProfile.reaction,
719
+ }
720
+ );
721
+ if (result?.defeated) {
722
+ console.log(`Player ${player.id} defeated AI ${hit.id}`);
723
+ }
724
+ }
725
+ });
726
+ },
451
727
  });
452
- },
453
728
  });
454
729
  }
455
730
  },
@@ -494,3 +769,62 @@ export const createActionBattleServer = (
494
769
  };
495
770
 
496
771
  export default createActionBattleServer();
772
+
773
+ export {
774
+ ACTION_BATTLE_HITBOX_FRAME_MS,
775
+ ActionBattleHitTracker,
776
+ createActionBattleAttackId,
777
+ getNormalizedActionBattleAttackProfile,
778
+ resolveActionBattleHitboxSpeed,
779
+ scheduleActionBattleStartup,
780
+ } from "./core/attack-runtime";
781
+ export {
782
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE,
783
+ normalizeActionBattleAttackProfile,
784
+ type ActionBattleAttackProfileFallbacks,
785
+ } from "./core/attack-profile";
786
+ export type {
787
+ ActionBattleAttackDirection,
788
+ ActionBattleAttackHitboxConfig,
789
+ ActionBattleAttackHitboxMap,
790
+ ActionBattleAttackHitPolicy,
791
+ ActionBattleAttackProfile,
792
+ ActionBattleDebugOptions,
793
+ ActionBattleHitReactionProfile,
794
+ NormalizedActionBattleHitReactionProfile,
795
+ NormalizedActionBattleAttackProfile,
796
+ } from "./types";
797
+ export {
798
+ DEFAULT_ACTION_BATTLE_HIT_REACTION,
799
+ isActionBattleEntityInvincible,
800
+ normalizeActionBattleHitReaction,
801
+ setActionBattleInvincibility,
802
+ } from "./core/hit-reaction";
803
+ export {
804
+ DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES,
805
+ normalizeActionBattleEnemyAttackProfiles,
806
+ type ActionBattleEnemyAttackProfileKey,
807
+ type ActionBattleEnemyAttackProfileMap,
808
+ type NormalizedActionBattleEnemyAttackProfileMap,
809
+ } from "./core/enemy-attack-profiles";
810
+ export { resolveActionBattleWeaponAttackProfile } from "./core/equipment";
811
+ export {
812
+ AiDebug,
813
+ AiState,
814
+ AttackPattern,
815
+ BattleAi,
816
+ DEFAULT_KNOCKBACK,
817
+ EnemyType,
818
+ } from "./ai.server";
819
+ export type {
820
+ ApplyHitHooks,
821
+ BattleAiDefeatedCallback,
822
+ BattleAiDefeatedContext,
823
+ BattleAiDefeatReward,
824
+ BattleAiLegacyDefeatedCallback,
825
+ BattleAiLegacyOptions,
826
+ BattleAiOptions,
827
+ BattleAiRewardItem,
828
+ BattleAiRewards,
829
+ HitResult,
830
+ } 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
+ }