@rpgjs/action-battle 5.0.0-beta.4 → 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.
- package/README.md +115 -0
- package/dist/ai.server.d.ts +17 -2
- package/dist/client/index.js +13 -8
- package/dist/client/index10.js +54 -136
- package/dist/client/index11.js +52 -23
- package/dist/client/index12.js +101 -1217
- package/dist/client/index13.js +139 -42
- package/dist/client/index14.js +23 -8
- package/dist/client/index15.js +68 -444
- package/dist/client/index16.js +1281 -0
- package/dist/client/index17.js +13 -0
- package/dist/client/index18.js +60 -0
- package/dist/client/index19.js +10 -0
- package/dist/client/index2.js +25 -87
- package/dist/client/index20.js +504 -0
- package/dist/client/index3.js +45 -83
- package/dist/client/index4.js +98 -297
- package/dist/client/index5.js +81 -33
- package/dist/client/index6.js +284 -78
- package/dist/client/index7.js +33 -74
- package/dist/client/index8.js +95 -55
- package/dist/client/index9.js +75 -96
- package/dist/core/attack-profile.d.ts +9 -0
- package/dist/core/attack-runtime.d.ts +20 -0
- package/dist/core/enemy-attack-profiles.d.ts +6 -0
- package/dist/core/equipment.d.ts +2 -0
- package/dist/core/hit-reaction.d.ts +5 -0
- package/dist/index.d.ts +6 -1
- package/dist/server/index.js +12 -7
- package/dist/server/index10.js +1278 -8
- package/dist/server/index11.js +37 -0
- package/dist/server/index12.js +60 -0
- package/dist/server/index13.js +13 -0
- package/dist/server/index14.js +503 -0
- package/dist/server/index15.js +10 -0
- package/dist/server/index3.js +25 -87
- package/dist/server/index4.js +45 -141
- package/dist/server/index5.js +104 -21
- package/dist/server/index6.js +137 -1215
- package/dist/server/index7.js +22 -34
- package/dist/server/index8.js +70 -44
- package/dist/server/index9.js +44 -437
- package/dist/server.d.ts +7 -1
- package/package.json +5 -5
- package/src/ai.server.ts +172 -43
- package/src/client.ts +21 -12
- package/src/config.ts +17 -2
- package/src/core/attack-profile.spec.ts +118 -0
- package/src/core/attack-profile.ts +100 -0
- package/src/core/attack-runtime.spec.ts +103 -0
- package/src/core/attack-runtime.ts +83 -0
- package/src/core/contracts.ts +3 -0
- package/src/core/enemy-attack-profiles.spec.ts +35 -0
- package/src/core/enemy-attack-profiles.ts +103 -0
- package/src/core/equipment.spec.ts +37 -0
- package/src/core/equipment.ts +17 -0
- package/src/core/hit-reaction.spec.ts +43 -0
- package/src/core/hit-reaction.ts +70 -0
- package/src/core/hit.spec.ts +54 -1
- package/src/core/hit.ts +26 -0
- package/src/index.ts +36 -0
- package/src/server.ts +180 -33
- 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.
|
|
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.
|
|
27
|
-
"@rpgjs/common": "5.0.0-beta.
|
|
28
|
-
"@rpgjs/server": "5.0.0-beta.
|
|
29
|
-
"@rpgjs/vite": "5.0.0-beta.
|
|
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, {
|
|
948
|
-
|
|
949
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
-
|
|
1123
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
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.
|
|
1278
|
+
this.telegraphAttack(profile);
|
|
1195
1279
|
|
|
1196
|
-
this.
|
|
1280
|
+
this.scheduleAttackStartup(profile, () => {
|
|
1197
1281
|
if (!this.target || this.state !== AiState.Combat) return;
|
|
1198
|
-
this.
|
|
1199
|
-
|
|
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(
|
|
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() +
|
|
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 (
|
|
48
|
-
engine.interruptCurrentPlayerMovement
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
69
|
-
|
|
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
|
+
});
|