@rpgjs/action-battle 5.0.0-beta.5 → 5.0.0-beta.6

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 (63) hide show
  1. package/README.md +115 -0
  2. package/dist/ai.server.d.ts +17 -2
  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 +1281 -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 +6 -1
  29. package/dist/server/index.js +12 -7
  30. package/dist/server/index10.js +1278 -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/index3.js +25 -87
  37. package/dist/server/index4.js +45 -141
  38. package/dist/server/index5.js +104 -21
  39. package/dist/server/index6.js +137 -1215
  40. package/dist/server/index7.js +22 -34
  41. package/dist/server/index8.js +70 -44
  42. package/dist/server/index9.js +44 -437
  43. package/dist/server.d.ts +7 -1
  44. package/package.json +5 -5
  45. package/src/ai.server.ts +172 -43
  46. package/src/client.ts +21 -12
  47. package/src/config.ts +17 -2
  48. package/src/core/attack-profile.spec.ts +118 -0
  49. package/src/core/attack-profile.ts +100 -0
  50. package/src/core/attack-runtime.spec.ts +103 -0
  51. package/src/core/attack-runtime.ts +83 -0
  52. package/src/core/contracts.ts +3 -0
  53. package/src/core/enemy-attack-profiles.spec.ts +35 -0
  54. package/src/core/enemy-attack-profiles.ts +103 -0
  55. package/src/core/equipment.spec.ts +37 -0
  56. package/src/core/equipment.ts +17 -0
  57. package/src/core/hit-reaction.spec.ts +43 -0
  58. package/src/core/hit-reaction.ts +70 -0
  59. package/src/core/hit.spec.ts +54 -1
  60. package/src/core/hit.ts +26 -0
  61. package/src/index.ts +36 -0
  62. package/src/server.ts +180 -33
  63. package/src/types.ts +62 -6
package/dist/server.d.ts CHANGED
@@ -90,11 +90,17 @@ export declare function getPlayerWeaponKnockbackForce(player: RpgPlayer): number
90
90
  * });
91
91
  * ```
92
92
  */
93
- export declare function applyPlayerHitToEvent(player: RpgPlayer, target: RpgEvent, hooks?: ApplyHitHooks): HitResult | undefined;
93
+ export declare function applyPlayerHitToEvent(player: RpgPlayer, target: RpgEvent, hooks?: ApplyHitHooks, metadata?: Record<string, any>): HitResult | undefined;
94
94
  export declare const openActionBattleActionBar: (player: RpgPlayer, rawOptions?: ActionBattleOptions) => void;
95
95
  export declare const updateActionBattleActionBar: (player: RpgPlayer, rawOptions?: ActionBattleOptions) => void;
96
96
  export declare const createActionBattleServer: (rawOptions?: ActionBattleOptions) => RpgServer;
97
97
  declare const _default: RpgServer;
98
98
  export default _default;
99
+ export { ACTION_BATTLE_HITBOX_FRAME_MS, ActionBattleHitTracker, createActionBattleAttackId, getNormalizedActionBattleAttackProfile, resolveActionBattleHitboxSpeed, scheduleActionBattleStartup, } from './core/attack-runtime';
100
+ export { DEFAULT_ACTION_BATTLE_ATTACK_PROFILE, normalizeActionBattleAttackProfile, type ActionBattleAttackProfileFallbacks, } from './core/attack-profile';
101
+ export type { ActionBattleAttackDirection, ActionBattleAttackHitboxConfig, ActionBattleAttackHitboxMap, ActionBattleAttackHitPolicy, ActionBattleAttackProfile, ActionBattleDebugOptions, ActionBattleHitReactionProfile, NormalizedActionBattleHitReactionProfile, NormalizedActionBattleAttackProfile, } from './types';
102
+ export { DEFAULT_ACTION_BATTLE_HIT_REACTION, isActionBattleEntityInvincible, normalizeActionBattleHitReaction, setActionBattleInvincibility, } from './core/hit-reaction';
103
+ export { DEFAULT_ACTION_BATTLE_ENEMY_ATTACK_PROFILES, normalizeActionBattleEnemyAttackProfiles, type ActionBattleEnemyAttackProfileKey, type ActionBattleEnemyAttackProfileMap, type NormalizedActionBattleEnemyAttackProfileMap, } from './core/enemy-attack-profiles';
104
+ export { resolveActionBattleWeaponAttackProfile } from './core/equipment';
99
105
  export { AiDebug, AiState, AttackPattern, BattleAi, DEFAULT_KNOCKBACK, EnemyType, } from './ai.server';
100
106
  export type { ApplyHitHooks, BattleAiOptions, HitResult } from './ai.server';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgjs/action-battle",
3
- "version": "5.0.0-beta.5",
3
+ "version": "5.0.0-beta.6",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "exports": {
@@ -23,10 +23,10 @@
23
23
  "description": "RPGJS is a framework for creating RPG/MMORPG games",
24
24
  "peerDependencies": {
25
25
  "@canvasengine/presets": "*",
26
- "@rpgjs/client": "5.0.0-beta.5",
27
- "@rpgjs/common": "5.0.0-beta.5",
28
- "@rpgjs/server": "5.0.0-beta.5",
29
- "@rpgjs/vite": "5.0.0-beta.5",
26
+ "@rpgjs/client": "5.0.0-beta.6",
27
+ "@rpgjs/common": "5.0.0-beta.6",
28
+ "@rpgjs/server": "5.0.0-beta.6",
29
+ "@rpgjs/vite": "5.0.0-beta.6",
30
30
  "canvasengine": "*"
31
31
  },
32
32
  "publishConfig": {
package/src/ai.server.ts CHANGED
@@ -5,7 +5,24 @@ import {
5
5
  } from "./animations";
6
6
  import { getActionBattleOptions } from "./config";
7
7
  import { getActionBattleSystems } from "./core/context";
8
+ import {
9
+ isActionBattleEntityInvincible,
10
+ setActionBattleInvincibility,
11
+ } from "./core/hit-reaction";
12
+ import {
13
+ normalizeActionBattleEnemyAttackProfiles,
14
+ type ActionBattleEnemyAttackProfileMap,
15
+ type NormalizedActionBattleEnemyAttackProfileMap,
16
+ } from "./core/enemy-attack-profiles";
17
+ import {
18
+ resolveActionBattleHitboxSpeed,
19
+ scheduleActionBattleStartup,
20
+ } from "./core/attack-runtime";
8
21
  import type { ActionBattleDamageResult } from "./core/contracts";
22
+ import type {
23
+ NormalizedActionBattleAttackProfile,
24
+ NormalizedActionBattleHitReactionProfile,
25
+ } from "./types";
9
26
  import type { ActionBattleAnimationOptions } from "./types";
10
27
 
11
28
  type RpgEventWithBattleAi = RpgEvent & {
@@ -22,10 +39,14 @@ export interface BattleAiOptions {
22
39
  fleeThreshold?: number;
23
40
  attackSkill?: any;
24
41
  attackPatterns?: AttackPattern[];
42
+ attackProfiles?: ActionBattleEnemyAttackProfileMap;
25
43
  patrolWaypoints?: Array<{ x: number; y: number }>;
26
44
  groupBehavior?: boolean;
27
45
  moveToCooldown?: number;
28
46
  retreatCooldown?: number;
47
+ poise?: number;
48
+ hitstunMs?: number;
49
+ invincibilityMs?: number;
29
50
  behavior?: {
30
51
  baseScore?: number;
31
52
  updateInterval?: number;
@@ -295,6 +316,7 @@ export class BattleAi {
295
316
  // Attack configuration
296
317
  private attackSkill: any | null; // Skill to use for attacks
297
318
  private attackPatterns: AttackPattern[];
319
+ private attackProfiles: NormalizedActionBattleEnemyAttackProfileMap;
298
320
  private animations?: ActionBattleAnimationOptions;
299
321
  private comboCount: number = 0;
300
322
  private comboMax: number = 3;
@@ -340,6 +362,9 @@ export class BattleAi {
340
362
  private lastRetreatTime: number = 0;
341
363
  private timers: ReturnType<typeof setTimeout>[] = [];
342
364
  private behaviorKey?: string;
365
+ private poise: number = 0;
366
+ private hitstunMs: number = 150;
367
+ private invincibilityMs: number = 250;
343
368
 
344
369
  /**
345
370
  * Create a new Battle AI Controller
@@ -390,6 +415,9 @@ export class BattleAi {
390
415
  AttackPattern.Combo,
391
416
  AttackPattern.DashAttack
392
417
  ];
418
+ this.attackProfiles = normalizeActionBattleEnemyAttackProfiles(
419
+ options.attackProfiles
420
+ );
393
421
 
394
422
  // Initialize group behavior
395
423
  this.groupBehavior = options.groupBehavior || false;
@@ -427,6 +455,15 @@ export class BattleAi {
427
455
  if (options.retreatCooldown !== undefined) {
428
456
  this.retreatCooldown = options.retreatCooldown;
429
457
  }
458
+ if (options.poise !== undefined) {
459
+ this.poise = Math.max(0, options.poise);
460
+ }
461
+ if (options.hitstunMs !== undefined) {
462
+ this.hitstunMs = Math.max(0, options.hitstunMs);
463
+ }
464
+ if (options.invincibilityMs !== undefined) {
465
+ this.invincibilityMs = Math.max(0, options.invincibilityMs);
466
+ }
430
467
 
431
468
  // Setup AI systems
432
469
  this.setupVision();
@@ -896,12 +933,26 @@ export class BattleAi {
896
933
  */
897
934
  private performMeleeAttack() {
898
935
  if (!this.target) return;
936
+ const profile = this.getAttackProfile(AttackPattern.Melee);
899
937
 
900
938
  this.faceTarget();
939
+ this.telegraphAttack(profile);
901
940
  playActionBattleAnimation("attack", this.event, this.animations, {
902
941
  target: this.target,
903
942
  });
904
943
 
944
+ this.scheduleAttackStartup(profile, () => {
945
+ this.executeMeleeAttack(profile, AttackPattern.Melee);
946
+ });
947
+ }
948
+
949
+ private executeMeleeAttack(
950
+ profile: NormalizedActionBattleAttackProfile,
951
+ pattern: AttackPattern
952
+ ) {
953
+ if (!this.target) return;
954
+ this.debugLog('attack', `Applying ${pattern} hit`);
955
+
905
956
  // Use skill if available
906
957
  if (this.attackSkill) {
907
958
  try {
@@ -912,17 +963,22 @@ export class BattleAi {
912
963
  this.event.useSkill(this.attackSkill, this.target);
913
964
  } catch (e) {
914
965
  // Skill failed (no SP, etc.) - fall back to basic attack
915
- this.performBasicHitbox();
966
+ this.performBasicHitbox(profile, pattern);
916
967
  }
917
968
  } else {
918
- this.performBasicHitbox();
969
+ this.performBasicHitbox(profile, pattern);
919
970
  }
920
971
  }
921
972
 
922
973
  /**
923
974
  * Perform basic hitbox attack when no skill is set
924
975
  */
925
- private performBasicHitbox() {
976
+ private performBasicHitbox(
977
+ profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
978
+ AttackPattern.Melee
979
+ ),
980
+ pattern: AttackPattern = AttackPattern.Melee
981
+ ) {
926
982
  if (!this.target) return;
927
983
 
928
984
  const eventX = this.event.x();
@@ -944,11 +1000,13 @@ export class BattleAi {
944
1000
  }];
945
1001
 
946
1002
  const map = this.event.getCurrentMap();
947
- map?.createMovingHitbox(hitboxes, { speed: 5 }).subscribe({
948
- next: (hits) => {
949
- hits.forEach((hit) => {
1003
+ map?.createMovingHitbox(hitboxes, {
1004
+ speed: resolveActionBattleHitboxSpeed(profile, hitboxes.length),
1005
+ }).subscribe({
1006
+ next: (hits: any[]) => {
1007
+ hits.forEach((hit: any) => {
950
1008
  if (hit instanceof RpgPlayer && hit !== this.event) {
951
- this.applyHit(hit);
1009
+ this.applyHit(hit, undefined, profile, pattern);
952
1010
  }
953
1011
  });
954
1012
  },
@@ -983,7 +1041,25 @@ export class BattleAi {
983
1041
  * });
984
1042
  * ```
985
1043
  */
986
- private applyHit(target: RpgPlayer, hooks?: ApplyHitHooks): HitResult {
1044
+ private applyHit(
1045
+ target: RpgPlayer,
1046
+ hooks?: ApplyHitHooks,
1047
+ profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
1048
+ AttackPattern.Melee
1049
+ ),
1050
+ pattern: AttackPattern = AttackPattern.Melee
1051
+ ): HitResult {
1052
+ if (isActionBattleEntityInvincible(target)) {
1053
+ return {
1054
+ damage: 0,
1055
+ knockbackForce: 0,
1056
+ knockbackDuration: 0,
1057
+ defeated: false,
1058
+ attacker: this.event,
1059
+ target
1060
+ };
1061
+ }
1062
+
987
1063
  // Use RPGJS damage formula
988
1064
  const { damage } = target.applyDamage(this.event as any);
989
1065
 
@@ -1017,6 +1093,10 @@ export class BattleAi {
1017
1093
  cycles: 1
1018
1094
  });
1019
1095
  target.showHit(`-${hitResult.damage}`);
1096
+ setActionBattleInvincibility(
1097
+ target,
1098
+ profile.reaction.invincibilityMs
1099
+ );
1020
1100
 
1021
1101
  // Apply knockback
1022
1102
  if (hitResult.knockbackForce > 0) {
@@ -1080,7 +1160,15 @@ export class BattleAi {
1080
1160
  if (!this.target) return;
1081
1161
 
1082
1162
  this.comboCount++;
1083
- this.performMeleeAttack();
1163
+ const profile = this.getAttackProfile(AttackPattern.Combo);
1164
+ this.faceTarget();
1165
+ this.telegraphAttack(profile);
1166
+ playActionBattleAnimation("attack", this.event, this.animations, {
1167
+ target: this.target,
1168
+ });
1169
+ this.scheduleAttackStartup(profile, () => {
1170
+ this.executeMeleeAttack(profile, AttackPattern.Combo);
1171
+ });
1084
1172
 
1085
1173
  if (this.comboCount < this.comboMax) {
1086
1174
  this.schedule(() => {
@@ -1100,9 +1188,11 @@ export class BattleAi {
1100
1188
  */
1101
1189
  private performChargedAttack() {
1102
1190
  if (!this.target) return;
1191
+ const profile = this.getAttackProfile(AttackPattern.Charged);
1103
1192
 
1104
1193
  this.chargingAttack = true;
1105
1194
  this.faceTarget();
1195
+ this.telegraphAttack(profile);
1106
1196
  playActionBattleAnimation(
1107
1197
  "attack",
1108
1198
  this.event,
@@ -1113,35 +1203,24 @@ export class BattleAi {
1113
1203
  { repeat: 2 }
1114
1204
  );
1115
1205
 
1116
- this.schedule(() => {
1206
+ this.scheduleAttackStartup(profile, () => {
1117
1207
  if (!this.target || this.state !== AiState.Combat) {
1118
1208
  this.chargingAttack = false;
1119
1209
  return;
1120
1210
  }
1121
-
1122
- // Charged attacks can use a stronger skill or wider hitbox
1123
- if (this.attackSkill) {
1124
- try {
1125
- playActionBattleAnimation("castSkill", this.event, this.animations, {
1126
- skill: this.attackSkill,
1127
- target: this.target,
1128
- });
1129
- this.event.useSkill(this.attackSkill, this.target);
1130
- } catch (e) {
1131
- this.performBasicHitbox();
1132
- }
1133
- } else {
1134
- this.performBasicHitbox();
1135
- }
1136
-
1211
+ this.executeMeleeAttack(profile, AttackPattern.Charged);
1212
+ });
1213
+ this.schedule(() => {
1137
1214
  this.chargingAttack = false;
1138
- }, 800);
1215
+ }, profile.totalDurationMs);
1139
1216
  }
1140
1217
 
1141
1218
  /**
1142
1219
  * Perform zone attack (360 degrees)
1143
1220
  */
1144
1221
  private performZoneAttack() {
1222
+ const profile = this.getAttackProfile(AttackPattern.Zone);
1223
+ this.telegraphAttack(profile);
1145
1224
  playActionBattleAnimation("attack", this.event, this.animations, {
1146
1225
  target: this.target ?? undefined,
1147
1226
  });
@@ -1163,15 +1242,19 @@ export class BattleAi {
1163
1242
  });
1164
1243
  });
1165
1244
 
1166
- const map = this.event.getCurrentMap();
1167
- map?.createMovingHitbox(hitboxes, { speed: 5 }).subscribe({
1168
- next: (hits) => {
1169
- hits.forEach((hit) => {
1170
- if (hit instanceof RpgPlayer && hit !== this.event) {
1171
- this.applyHit(hit);
1172
- }
1173
- });
1174
- },
1245
+ this.scheduleAttackStartup(profile, () => {
1246
+ const map = this.event.getCurrentMap();
1247
+ map?.createMovingHitbox(hitboxes, {
1248
+ speed: resolveActionBattleHitboxSpeed(profile, hitboxes.length),
1249
+ }).subscribe({
1250
+ next: (hits: any[]) => {
1251
+ hits.forEach((hit: any) => {
1252
+ if (hit instanceof RpgPlayer && hit !== this.event) {
1253
+ this.applyHit(hit, undefined, profile, AttackPattern.Zone);
1254
+ }
1255
+ });
1256
+ },
1257
+ });
1175
1258
  });
1176
1259
  }
1177
1260
 
@@ -1180,6 +1263,7 @@ export class BattleAi {
1180
1263
  */
1181
1264
  private performDashAttack() {
1182
1265
  if (!this.target) return;
1266
+ const profile = this.getAttackProfile(AttackPattern.DashAttack);
1183
1267
 
1184
1268
  const dx = this.target.x() - this.event.x();
1185
1269
  const dy = this.target.y() - this.event.y();
@@ -1191,12 +1275,43 @@ export class BattleAi {
1191
1275
  const dirY = dy / dist;
1192
1276
 
1193
1277
  this.faceTarget();
1194
- this.event.dash({ x: dirX, y: dirY }, 10, 200);
1278
+ this.telegraphAttack(profile);
1195
1279
 
1196
- this.schedule(() => {
1280
+ this.scheduleAttackStartup(profile, () => {
1197
1281
  if (!this.target || this.state !== AiState.Combat) return;
1198
- this.performMeleeAttack();
1199
- }, 200);
1282
+ this.event.dash({ x: dirX, y: dirY }, 10, 200);
1283
+ this.schedule(() => {
1284
+ if (!this.target || this.state !== AiState.Combat) return;
1285
+ this.executeMeleeAttack(profile, AttackPattern.DashAttack);
1286
+ }, 200);
1287
+ });
1288
+ }
1289
+
1290
+ private getAttackProfile(
1291
+ pattern: AttackPattern
1292
+ ): NormalizedActionBattleAttackProfile {
1293
+ return this.attackProfiles[
1294
+ pattern as keyof NormalizedActionBattleEnemyAttackProfileMap
1295
+ ] ?? this.attackProfiles.melee;
1296
+ }
1297
+
1298
+ private telegraphAttack(profile: NormalizedActionBattleAttackProfile) {
1299
+ if (profile.startupMs <= 0) return;
1300
+ this.event.flash({
1301
+ type: 'tint',
1302
+ tint: 'white',
1303
+ duration: Math.min(profile.startupMs, 300),
1304
+ cycles: 1
1305
+ });
1306
+ }
1307
+
1308
+ private scheduleAttackStartup(
1309
+ profile: NormalizedActionBattleAttackProfile,
1310
+ callback: () => void
1311
+ ) {
1312
+ return scheduleActionBattleStartup(profile, callback, (scheduled, delay) =>
1313
+ this.schedule(scheduled, delay)
1314
+ );
1200
1315
  }
1201
1316
 
1202
1317
  /**
@@ -1487,7 +1602,12 @@ export class BattleAi {
1487
1602
  });
1488
1603
  }
1489
1604
 
1490
- handleDamage(attacker: RpgPlayer, damageResult: ActionBattleDamageResult): boolean {
1605
+ handleDamage(
1606
+ attacker: RpgPlayer,
1607
+ damageResult: ActionBattleDamageResult & {
1608
+ reaction?: NormalizedActionBattleHitReactionProfile;
1609
+ }
1610
+ ): boolean {
1491
1611
  const damage = damageResult.damage;
1492
1612
  this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
1493
1613
 
@@ -1506,11 +1626,20 @@ export class BattleAi {
1506
1626
  // Track damage
1507
1627
  this.recentDamageTaken += damage;
1508
1628
 
1629
+ const reaction = damageResult.reaction;
1630
+ const staggerPower = reaction?.staggerPower ?? damage;
1631
+ const hitstunMs = reaction?.hitstunMs ?? this.hitstunMs;
1632
+ const shouldStun = staggerPower >= this.poise && hitstunMs > 0;
1633
+ setActionBattleInvincibility(
1634
+ this.event,
1635
+ reaction?.invincibilityMs ?? this.invincibilityMs
1636
+ );
1637
+
1509
1638
  // Brief stun
1510
- if (this.state !== AiState.Stunned && this.state !== AiState.Flee) {
1639
+ if (shouldStun && this.state !== AiState.Stunned && this.state !== AiState.Flee) {
1511
1640
  this.debugLog('damage', 'Stunned from damage');
1512
1641
  this.isMovingToTarget = false;
1513
- this.stunnedUntil = Date.now() + 150;
1642
+ this.stunnedUntil = Date.now() + hitstunMs;
1514
1643
  this.changeState(AiState.Stunned);
1515
1644
  }
1516
1645
 
package/src/client.ts CHANGED
@@ -14,12 +14,14 @@ import {
14
14
  import { ActionBattleOptions } from "./types";
15
15
  import { normalizeActionBattleOptions } from "./config";
16
16
  import { resolveActionBattleAnimation } from "./animations";
17
+ import { getNormalizedActionBattleAttackProfile } from "./core/attack-runtime";
17
18
 
18
19
  const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
19
20
 
20
21
  const beginLocalPlayerAttackLock = (
21
22
  engine: RpgClientEngine,
22
- durationMs: number
23
+ durationMs: number,
24
+ locks: { movement: boolean; direction: boolean }
23
25
  ): boolean => {
24
26
  if (durationMs <= 0) return true;
25
27
 
@@ -44,13 +46,17 @@ const beginLocalPlayerAttackLock = (
44
46
  const previousDirectionFixed = player.directionFixed;
45
47
  const previousAnimationFixed = player.animationFixed;
46
48
 
47
- if (typeof engine.interruptCurrentPlayerMovement === "function") {
48
- engine.interruptCurrentPlayerMovement(player);
49
- } else {
50
- (engine.scene as any)?.stopMovement?.(player);
49
+ if (locks.movement) {
50
+ if (typeof engine.interruptCurrentPlayerMovement === "function") {
51
+ engine.interruptCurrentPlayerMovement(player);
52
+ } else {
53
+ (engine.scene as any)?.stopMovement?.(player);
54
+ }
55
+ player.canMove.set(false);
56
+ }
57
+ if (locks.direction) {
58
+ player.directionFixed = true;
51
59
  }
52
- player.canMove.set(false);
53
- player.directionFixed = true;
54
60
  player.animationFixed = true;
55
61
 
56
62
  setTimeout(() => {
@@ -154,14 +160,17 @@ export const createActionBattleClient = (
154
160
  if (input !== "action") return;
155
161
  const player = engine.scene?.getCurrentPlayer?.() as any;
156
162
  if (!player) return;
163
+ const attackProfile = getNormalizedActionBattleAttackProfile(normalized);
157
164
  const lockDurationMs = Math.max(
158
165
  0,
159
- normalized.attack?.lockDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS
160
- );
161
- beginLocalPlayerAttackLock(
162
- engine,
163
- normalized.attack?.lockMovement === false ? 0 : lockDurationMs
166
+ attackProfile.totalDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS
164
167
  );
168
+ if (attackProfile.movementLock || attackProfile.directionLock) {
169
+ beginLocalPlayerAttackLock(engine, lockDurationMs, {
170
+ movement: attackProfile.movementLock,
171
+ direction: attackProfile.directionLock,
172
+ });
173
+ }
165
174
  playLocalPlayerAttackAnimation(player, normalized);
166
175
  showLocalAttackPreview(player, normalized);
167
176
  },
package/src/config.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ActionBattleOptions } from "./types";
2
+ import { normalizeActionBattleAttackProfile } from "./core/attack-profile";
2
3
 
3
4
  export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = {
4
5
  ui: {
@@ -41,6 +42,16 @@ let currentActionBattleOptions: ActionBattleOptions =
41
42
  export function normalizeActionBattleOptions(
42
43
  options: ActionBattleOptions = {}
43
44
  ): ActionBattleOptions {
45
+ const attack = {
46
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.attack,
47
+ ...options.attack,
48
+ };
49
+ const attackProfile = normalizeActionBattleAttackProfile(attack.profile, {
50
+ lockMovement: attack.lockMovement,
51
+ lockDurationMs: attack.lockDurationMs,
52
+ hitboxes: attack.hitboxes,
53
+ });
54
+
44
55
  return {
45
56
  ui: {
46
57
  actionBar: {
@@ -64,9 +75,13 @@ export function normalizeActionBattleOptions(
64
75
  ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
65
76
  ...options.targeting,
66
77
  },
78
+ debug: {
79
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.debug,
80
+ ...options.debug,
81
+ },
67
82
  attack: {
68
- ...DEFAULT_ACTION_BATTLE_OPTIONS.attack,
69
- ...options.attack,
83
+ ...attack,
84
+ profile: attackProfile,
70
85
  },
71
86
  animations: {
72
87
  ...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
@@ -0,0 +1,118 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ DEFAULT_ACTION_BATTLE_ATTACK_PROFILE,
4
+ normalizeActionBattleAttackProfile,
5
+ } from "./attack-profile";
6
+ import { normalizeActionBattleOptions } from "../config";
7
+ import type { NormalizedActionBattleAttackProfile } from "../types";
8
+
9
+ describe("normalizeActionBattleAttackProfile", () => {
10
+ test("creates a default profile compatible with the legacy 350ms attack lock", () => {
11
+ const profile = normalizeActionBattleAttackProfile();
12
+
13
+ expect(profile).toEqual(DEFAULT_ACTION_BATTLE_ATTACK_PROFILE);
14
+ });
15
+
16
+ test("derives recovery from the legacy lock duration when recovery is omitted", () => {
17
+ const profile = normalizeActionBattleAttackProfile(
18
+ {
19
+ startupMs: 80,
20
+ activeMs: 90,
21
+ },
22
+ {
23
+ lockDurationMs: 400,
24
+ }
25
+ );
26
+
27
+ expect(profile.recoveryMs).toBe(230);
28
+ expect(profile.totalDurationMs).toBe(400);
29
+ expect(profile.cooldownMs).toBe(400);
30
+ });
31
+
32
+ test("keeps explicit timing, movement, hit policy, animation, and hitboxes", () => {
33
+ const hitboxes = {
34
+ right: { offsetX: 18, offsetY: -18, width: 42, height: 36 },
35
+ };
36
+ const profile = normalizeActionBattleAttackProfile({
37
+ id: "heavy-sword",
38
+ startupMs: 140,
39
+ activeMs: 100,
40
+ recoveryMs: 260,
41
+ cooldownMs: 650,
42
+ movementLock: false,
43
+ directionLock: false,
44
+ animationKey: "castSkill",
45
+ hitPolicy: "allowRepeatHits",
46
+ hitboxes,
47
+ });
48
+
49
+ expect(profile).toMatchObject({
50
+ id: "heavy-sword",
51
+ startupMs: 140,
52
+ activeMs: 100,
53
+ recoveryMs: 260,
54
+ cooldownMs: 650,
55
+ movementLock: false,
56
+ directionLock: false,
57
+ animationKey: "castSkill",
58
+ hitPolicy: "allowRepeatHits",
59
+ totalDurationMs: 500,
60
+ });
61
+ expect(profile.hitboxes).toBe(hitboxes);
62
+ });
63
+
64
+ test("normalizes unsafe timing values to playable bounds", () => {
65
+ const profile = normalizeActionBattleAttackProfile({
66
+ startupMs: -20,
67
+ activeMs: 0,
68
+ recoveryMs: -10,
69
+ cooldownMs: -1,
70
+ });
71
+
72
+ expect(profile.startupMs).toBe(0);
73
+ expect(profile.activeMs).toBe(1);
74
+ expect(profile.recoveryMs).toBe(0);
75
+ expect(profile.cooldownMs).toBe(0);
76
+ expect(profile.totalDurationMs).toBe(1);
77
+ });
78
+
79
+ test("normalizes attack.profile through action battle options", () => {
80
+ const options = normalizeActionBattleOptions({
81
+ attack: {
82
+ lockMovement: false,
83
+ lockDurationMs: 300,
84
+ profile: {
85
+ id: "quick-slash",
86
+ startupMs: 60,
87
+ activeMs: 80,
88
+ },
89
+ },
90
+ });
91
+ const profile = options.attack
92
+ ?.profile as NormalizedActionBattleAttackProfile;
93
+
94
+ expect(profile).toMatchObject({
95
+ id: "quick-slash",
96
+ startupMs: 60,
97
+ activeMs: 80,
98
+ recoveryMs: 160,
99
+ cooldownMs: 300,
100
+ movementLock: false,
101
+ totalDurationMs: 300,
102
+ });
103
+ });
104
+
105
+ test("keeps legacy lockDurationMs when no explicit profile is provided", () => {
106
+ const options = normalizeActionBattleOptions({
107
+ attack: {
108
+ lockDurationMs: 500,
109
+ },
110
+ });
111
+ const profile = options.attack
112
+ ?.profile as NormalizedActionBattleAttackProfile;
113
+
114
+ expect(profile.totalDurationMs).toBe(500);
115
+ expect(profile.recoveryMs).toBe(380);
116
+ expect(profile.cooldownMs).toBe(500);
117
+ });
118
+ });