@rpgjs/action-battle 5.0.0-beta.11 → 5.0.0-beta.12
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/CHANGELOG.md +11 -0
- package/dist/client/ai.server.d.ts +45 -8
- package/dist/client/attack-input.d.ts +3 -0
- package/dist/client/core/action-use.d.ts +18 -0
- package/dist/client/core/ai-behavior-tree.d.ts +99 -0
- package/dist/client/core/attack-runtime.d.ts +2 -0
- package/dist/client/core/defaults.d.ts +2 -1
- package/dist/client/core/equipment.d.ts +1 -0
- package/dist/client/core/targets.d.ts +15 -0
- package/dist/client/enemies/factory.d.ts +2 -0
- package/dist/client/index.d.ts +12 -7
- package/dist/client/index.js +16 -11
- package/dist/client/index10.js +32 -56
- package/dist/client/index11.js +99 -52
- package/dist/client/index12.js +76 -103
- package/dist/client/index13.js +72 -135
- package/dist/client/index14.js +67 -23
- package/dist/client/index15.js +197 -63
- package/dist/client/index16.js +112 -1337
- package/dist/client/index17.js +193 -7
- package/dist/client/index18.js +32 -58
- package/dist/client/index19.js +70 -8
- package/dist/client/index20.js +57 -501
- package/dist/client/index21.js +69 -0
- package/dist/client/index22.js +225 -0
- package/dist/client/index23.js +16 -0
- package/dist/client/index24.js +25 -0
- package/dist/client/index25.js +107 -0
- package/dist/client/index26.js +1707 -0
- package/dist/client/index27.js +12 -0
- package/dist/client/index28.js +589 -0
- package/dist/client/index4.js +79 -38
- package/dist/client/index6.js +65 -306
- package/dist/client/index7.js +33 -33
- package/dist/client/index8.js +24 -100
- package/dist/client/index9.js +293 -61
- package/dist/client/locomotion.d.ts +16 -0
- package/dist/client/movement.d.ts +14 -0
- package/dist/client/server.d.ts +7 -3
- package/dist/client/ui.d.ts +22 -0
- package/dist/client/visual.d.ts +15 -0
- package/dist/server/ai.server.d.ts +45 -8
- package/dist/server/attack-input.d.ts +3 -0
- package/dist/server/core/action-use.d.ts +18 -0
- package/dist/server/core/ai-behavior-tree.d.ts +99 -0
- package/dist/server/core/attack-runtime.d.ts +2 -0
- package/dist/server/core/defaults.d.ts +2 -1
- package/dist/server/core/equipment.d.ts +1 -0
- package/dist/server/core/targets.d.ts +15 -0
- package/dist/server/enemies/factory.d.ts +2 -0
- package/dist/server/index.d.ts +12 -7
- package/dist/server/index.js +14 -9
- package/dist/server/index10.js +64 -1336
- package/dist/server/index11.js +33 -33
- package/dist/server/index13.js +66 -11
- package/dist/server/index14.js +206 -484
- package/dist/server/index15.js +15 -9
- package/dist/server/index16.js +26 -0
- package/dist/server/index17.js +25 -0
- package/dist/server/index18.js +107 -0
- package/dist/server/index19.js +1707 -0
- package/dist/server/index2.js +10 -2
- package/dist/server/index20.js +37 -0
- package/dist/server/index21.js +588 -0
- package/dist/server/index22.js +78 -0
- package/dist/server/index23.js +12 -0
- package/dist/server/index5.js +79 -38
- package/dist/server/index6.js +192 -129
- package/dist/server/index7.js +198 -24
- package/dist/server/index8.js +28 -66
- package/dist/server/index9.js +68 -51
- package/dist/server/locomotion.d.ts +16 -0
- package/dist/server/movement.d.ts +14 -0
- package/dist/server/server.d.ts +7 -3
- package/dist/server/ui.d.ts +22 -0
- package/dist/server/visual.d.ts +15 -0
- package/package.json +5 -5
- package/src/ai.server.spec.ts +233 -0
- package/src/ai.server.ts +627 -108
- package/src/animations.spec.ts +40 -0
- package/src/animations.ts +31 -9
- package/src/attack-input.spec.ts +51 -0
- package/src/attack-input.ts +59 -0
- package/src/client.ts +75 -62
- package/src/config.ts +84 -37
- package/src/core/action-use.spec.ts +317 -0
- package/src/core/action-use.ts +386 -0
- package/src/core/ai-behavior-tree.spec.ts +116 -0
- package/src/core/ai-behavior-tree.ts +272 -0
- package/src/core/attack-profile.spec.ts +46 -0
- package/src/core/attack-runtime.spec.ts +35 -0
- package/src/core/attack-runtime.ts +32 -0
- package/src/core/context.ts +9 -0
- package/src/core/contracts.ts +146 -1
- package/src/core/defaults.ts +56 -0
- package/src/core/equipment.ts +9 -5
- package/src/core/targets.spec.ts +112 -0
- package/src/core/targets.ts +147 -0
- package/src/enemies/factory.ts +8 -0
- package/src/index.ts +111 -2
- package/src/locomotion.spec.ts +51 -0
- package/src/locomotion.ts +48 -0
- package/src/movement.spec.ts +78 -0
- package/src/movement.ts +46 -0
- package/src/server.ts +242 -66
- package/src/types.ts +105 -35
- package/src/ui.ts +113 -0
- package/src/visual.spec.ts +166 -0
- package/src/visual.ts +285 -0
- package/README.md +0 -1242
package/src/ai.server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { MAXHP, RpgEvent, RpgPlayer } from "@rpgjs/server";
|
|
2
2
|
import {
|
|
3
3
|
getActionBattleAnimationRemovalDelay,
|
|
4
|
-
playActionBattleAnimation,
|
|
5
4
|
resolveActionBattleAnimation,
|
|
6
5
|
} from "./animations";
|
|
6
|
+
import { emitActionBattleClientVisual } from "./visual";
|
|
7
7
|
import { getActionBattleOptions } from "./config";
|
|
8
8
|
import { getActionBattleSystems } from "./core/context";
|
|
9
9
|
import {
|
|
@@ -16,10 +16,40 @@ import {
|
|
|
16
16
|
type NormalizedActionBattleEnemyAttackProfileMap,
|
|
17
17
|
} from "./core/enemy-attack-profiles";
|
|
18
18
|
import {
|
|
19
|
-
|
|
19
|
+
ActionBattleHitTracker,
|
|
20
|
+
runActionBattleActiveHitbox,
|
|
20
21
|
scheduleActionBattleStartup,
|
|
21
22
|
} from "./core/attack-runtime";
|
|
22
|
-
import
|
|
23
|
+
import { applyActionBattleAttackDirection } from "./attack-input";
|
|
24
|
+
import {
|
|
25
|
+
executeActionBattleUse,
|
|
26
|
+
getActionBattleActionRange,
|
|
27
|
+
} from "./core/action-use";
|
|
28
|
+
import { resolveActionBattleWeapon } from "./core/equipment";
|
|
29
|
+
import { withActionBattleAnimationUnlocked } from "./locomotion";
|
|
30
|
+
import { safeActionBattleDash } from "./movement";
|
|
31
|
+
import {
|
|
32
|
+
defineAiBehavior,
|
|
33
|
+
defineAiTree,
|
|
34
|
+
type ActionBattleAiIntent,
|
|
35
|
+
type ActionBattleAiMemory,
|
|
36
|
+
type ActionBattleAiSimpleBehavior,
|
|
37
|
+
type ActionBattleAiTreeInput,
|
|
38
|
+
type ActionBattleAiTreeNode,
|
|
39
|
+
} from "./core/ai-behavior-tree";
|
|
40
|
+
import type {
|
|
41
|
+
ActionBattleAiBehavior,
|
|
42
|
+
ActionBattleAiDecision,
|
|
43
|
+
ActionBattleAiPreset,
|
|
44
|
+
ActionBattleDamageResult,
|
|
45
|
+
ActionBattleEntity,
|
|
46
|
+
ActionBattleHitbox,
|
|
47
|
+
ActionBattleTargetSelector,
|
|
48
|
+
} from "./core/contracts";
|
|
49
|
+
import {
|
|
50
|
+
canActionBattleTarget,
|
|
51
|
+
isActionBattlePlayer,
|
|
52
|
+
} from "./core/targets";
|
|
23
53
|
import type {
|
|
24
54
|
NormalizedActionBattleAttackProfile,
|
|
25
55
|
NormalizedActionBattleHitReactionProfile,
|
|
@@ -51,7 +81,7 @@ export interface BattleAiDefeatReward {
|
|
|
51
81
|
|
|
52
82
|
export interface BattleAiDefeatedContext {
|
|
53
83
|
event: RpgEvent;
|
|
54
|
-
attacker?:
|
|
84
|
+
attacker?: ActionBattleEntity;
|
|
55
85
|
reward: BattleAiDefeatReward;
|
|
56
86
|
remove: () => void;
|
|
57
87
|
}
|
|
@@ -62,10 +92,13 @@ export type BattleAiDefeatedCallback = (
|
|
|
62
92
|
|
|
63
93
|
export type BattleAiLegacyDefeatedCallback = (
|
|
64
94
|
event: RpgEvent,
|
|
65
|
-
attacker?:
|
|
95
|
+
attacker?: ActionBattleEntity
|
|
66
96
|
) => void;
|
|
67
97
|
|
|
68
98
|
export interface BattleAiBaseOptions {
|
|
99
|
+
preset?: string | ActionBattleAiPreset;
|
|
100
|
+
faction?: string;
|
|
101
|
+
targets?: ActionBattleTargetSelector;
|
|
69
102
|
enemyType?: EnemyType;
|
|
70
103
|
attackCooldown?: number;
|
|
71
104
|
visionRange?: number;
|
|
@@ -91,6 +124,9 @@ export interface BattleAiBaseOptions {
|
|
|
91
124
|
retreatThreshold?: number;
|
|
92
125
|
};
|
|
93
126
|
behaviorKey?: string;
|
|
127
|
+
tree?: ActionBattleAiTreeInput;
|
|
128
|
+
behaviorTree?: ActionBattleAiTreeInput;
|
|
129
|
+
simpleBehavior?: ActionBattleAiSimpleBehavior;
|
|
94
130
|
animations?: ActionBattleAnimationOptions;
|
|
95
131
|
rewards?: BattleAiRewards;
|
|
96
132
|
autoAwardRewards?: boolean;
|
|
@@ -363,6 +399,49 @@ export const DEFAULT_KNOCKBACK = {
|
|
|
363
399
|
duration: 300
|
|
364
400
|
};
|
|
365
401
|
|
|
402
|
+
const mergeBattleAiPresetOptions = (
|
|
403
|
+
options: BattleAiOptions | BattleAiLegacyOptions,
|
|
404
|
+
seen: Set<string> = new Set()
|
|
405
|
+
): BattleAiOptions | BattleAiLegacyOptions => {
|
|
406
|
+
if (!options.preset) return options;
|
|
407
|
+
|
|
408
|
+
if (typeof options.preset === "string") {
|
|
409
|
+
if (seen.has(options.preset)) {
|
|
410
|
+
throw new Error(`Circular action battle AI preset: ${options.preset}`);
|
|
411
|
+
}
|
|
412
|
+
seen.add(options.preset);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const preset =
|
|
416
|
+
typeof options.preset === "string"
|
|
417
|
+
? getActionBattleSystems().ai.presets[options.preset]
|
|
418
|
+
: options.preset;
|
|
419
|
+
|
|
420
|
+
if (!preset) {
|
|
421
|
+
throw new Error(`Action battle AI preset not found: ${options.preset}`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const resolvedPreset = mergeBattleAiPresetOptions(preset, seen);
|
|
425
|
+
const { preset: _preset, ...overrides } = options;
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
...resolvedPreset,
|
|
429
|
+
...overrides,
|
|
430
|
+
behavior: {
|
|
431
|
+
...resolvedPreset.behavior,
|
|
432
|
+
...overrides.behavior,
|
|
433
|
+
},
|
|
434
|
+
animations: {
|
|
435
|
+
...resolvedPreset.animations,
|
|
436
|
+
...overrides.animations,
|
|
437
|
+
},
|
|
438
|
+
rewards: {
|
|
439
|
+
...resolvedPreset.rewards,
|
|
440
|
+
...overrides.rewards,
|
|
441
|
+
},
|
|
442
|
+
} as BattleAiOptions | BattleAiLegacyOptions;
|
|
443
|
+
};
|
|
444
|
+
|
|
366
445
|
/**
|
|
367
446
|
* Advanced Battle AI Controller for events
|
|
368
447
|
*
|
|
@@ -407,7 +486,7 @@ export const DEFAULT_KNOCKBACK = {
|
|
|
407
486
|
*/
|
|
408
487
|
export class BattleAi {
|
|
409
488
|
private event: RpgEvent;
|
|
410
|
-
private target:
|
|
489
|
+
private target: ActionBattleEntity | null = null;
|
|
411
490
|
private lastAttackTime: number = 0;
|
|
412
491
|
private updateInterval?: any;
|
|
413
492
|
|
|
@@ -425,6 +504,8 @@ export class BattleAi {
|
|
|
425
504
|
|
|
426
505
|
// Enemy type and behavior
|
|
427
506
|
private enemyType: EnemyType;
|
|
507
|
+
private faction?: string;
|
|
508
|
+
private targets: ActionBattleTargetSelector = "players";
|
|
428
509
|
private attackCooldown: number = 1000;
|
|
429
510
|
private visionRange: number = 150;
|
|
430
511
|
private attackRange: number = 60;
|
|
@@ -491,9 +572,15 @@ export class BattleAi {
|
|
|
491
572
|
private lastRetreatTime: number = 0;
|
|
492
573
|
private timers: ReturnType<typeof setTimeout>[] = [];
|
|
493
574
|
private behaviorKey?: string;
|
|
575
|
+
private behaviorTree?: ActionBattleAiTreeNode;
|
|
576
|
+
private aiMemory: ActionBattleAiMemory = {};
|
|
494
577
|
private poise: number = 0;
|
|
495
578
|
private hitstunMs: number = 150;
|
|
496
579
|
private invincibilityMs: number = 250;
|
|
580
|
+
private visionShape?: any;
|
|
581
|
+
private visionSetupRetries: number = 0;
|
|
582
|
+
private maxVisionSetupRetries: number = 20;
|
|
583
|
+
private destroyed: boolean = false;
|
|
497
584
|
|
|
498
585
|
/**
|
|
499
586
|
* Create a new Battle AI Controller
|
|
@@ -525,11 +612,14 @@ export class BattleAi {
|
|
|
525
612
|
event: RpgEventWithBattleAi,
|
|
526
613
|
options: BattleAiOptions | BattleAiLegacyOptions = {}
|
|
527
614
|
) {
|
|
615
|
+
options = mergeBattleAiPresetOptions(options);
|
|
528
616
|
event.battleAi = this;
|
|
529
617
|
this.event = event;
|
|
530
618
|
|
|
531
619
|
// Set enemy type and apply behavior modifiers
|
|
532
620
|
this.enemyType = options.enemyType || EnemyType.Aggressive;
|
|
621
|
+
this.faction = options.faction;
|
|
622
|
+
this.targets = options.targets ?? "players";
|
|
533
623
|
this.behaviorKey = options.behaviorKey ?? this.enemyType;
|
|
534
624
|
this.applyEnemyTypeBehavior(options);
|
|
535
625
|
|
|
@@ -597,9 +687,21 @@ export class BattleAi {
|
|
|
597
687
|
if (options.invincibilityMs !== undefined) {
|
|
598
688
|
this.invincibilityMs = Math.max(0, options.invincibilityMs);
|
|
599
689
|
}
|
|
690
|
+
if (options.tree || options.behaviorTree) {
|
|
691
|
+
this.behaviorTree = defineAiTree(options.tree ?? options.behaviorTree!);
|
|
692
|
+
} else if (options.simpleBehavior) {
|
|
693
|
+
this.behaviorTree = defineAiBehavior(options.simpleBehavior);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
if (options.attackRange === undefined) {
|
|
697
|
+
const actionRange = this.getCurrentActionRange();
|
|
698
|
+
if (actionRange !== undefined) {
|
|
699
|
+
this.attackRange = actionRange;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
600
702
|
|
|
601
703
|
// Setup AI systems
|
|
602
|
-
this.
|
|
704
|
+
this.scheduleVisionSetup();
|
|
603
705
|
this.startAiBehaviorLoop();
|
|
604
706
|
if (this.patrolWaypoints.length > 0) {
|
|
605
707
|
this.startPatrol();
|
|
@@ -681,14 +783,37 @@ export class BattleAi {
|
|
|
681
783
|
/**
|
|
682
784
|
* Setup vision detection
|
|
683
785
|
*/
|
|
684
|
-
private setupVision() {
|
|
786
|
+
private setupVision(): boolean {
|
|
787
|
+
if (this.visionShape) return true;
|
|
788
|
+
const map = this.event.getCurrentMap?.();
|
|
789
|
+
if (
|
|
790
|
+
map?.physic?.getEntityByUUID &&
|
|
791
|
+
!map.physic.getEntityByUUID(this.event.id)
|
|
792
|
+
) {
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
|
|
685
796
|
const diameter = this.visionRange * 2;
|
|
686
|
-
this.event.attachShape(`vision_${this.event.id}`, {
|
|
797
|
+
const shape = this.event.attachShape(`vision_${this.event.id}`, {
|
|
687
798
|
radius: this.visionRange,
|
|
688
799
|
width: diameter,
|
|
689
800
|
height: diameter,
|
|
690
801
|
angle: 360,
|
|
691
802
|
});
|
|
803
|
+
if (!shape) return false;
|
|
804
|
+
this.visionShape = shape;
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private scheduleVisionSetup() {
|
|
809
|
+
if (this.destroyed || this.setupVision()) return;
|
|
810
|
+
if (this.visionSetupRetries >= this.maxVisionSetupRetries) return;
|
|
811
|
+
|
|
812
|
+
this.visionSetupRetries++;
|
|
813
|
+
this.schedule(() => {
|
|
814
|
+
if (this.destroyed || !this.event.getCurrentMap()) return;
|
|
815
|
+
this.scheduleVisionSetup();
|
|
816
|
+
}, 50);
|
|
692
817
|
}
|
|
693
818
|
|
|
694
819
|
/**
|
|
@@ -759,6 +884,14 @@ export class BattleAi {
|
|
|
759
884
|
private updateAiBehavior() {
|
|
760
885
|
const currentTime = Date.now();
|
|
761
886
|
|
|
887
|
+
if (this.target && this.isTargetDefeated(this.target)) {
|
|
888
|
+
this.debugLog('combat', `Target ${this.target.id} is defeated, returning to idle`);
|
|
889
|
+
this.clearTarget();
|
|
890
|
+
this.changeState(AiState.Idle);
|
|
891
|
+
this.checkDamageTaken();
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
762
895
|
// Update group behavior
|
|
763
896
|
if (this.groupBehavior) {
|
|
764
897
|
this.updateGroupBehavior();
|
|
@@ -784,7 +917,18 @@ export class BattleAi {
|
|
|
784
917
|
this.updateBehavior(currentTime);
|
|
785
918
|
}
|
|
786
919
|
|
|
787
|
-
this.
|
|
920
|
+
if (!this.target && this.state === AiState.Idle) {
|
|
921
|
+
const target = this.findNearestTarget();
|
|
922
|
+
if (target) {
|
|
923
|
+
this.engageTarget(target);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
const customBehaviorHandled = this.applyCustomBehavior(currentTime);
|
|
928
|
+
if (customBehaviorHandled) {
|
|
929
|
+
this.checkDamageTaken();
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
788
932
|
|
|
789
933
|
// State-specific behavior
|
|
790
934
|
switch (this.state) {
|
|
@@ -810,6 +954,12 @@ export class BattleAi {
|
|
|
810
954
|
* Update idle behavior (patrolling)
|
|
811
955
|
*/
|
|
812
956
|
private updateIdleBehavior() {
|
|
957
|
+
const target = this.findNearestTarget();
|
|
958
|
+
if (target) {
|
|
959
|
+
this.engageTarget(target);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
|
|
813
963
|
if (this.patrolWaypoints.length > 0) {
|
|
814
964
|
const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
|
|
815
965
|
const distance = this.getDistance(this.event, {
|
|
@@ -855,9 +1005,7 @@ export class BattleAi {
|
|
|
855
1005
|
// Check if target is still in range
|
|
856
1006
|
if (distance > this.visionRange * 1.5) {
|
|
857
1007
|
this.debugLog('combat', `Target out of range (dist=${distance.toFixed(1)}, maxRange=${(this.visionRange * 1.5).toFixed(1)})`);
|
|
858
|
-
this.
|
|
859
|
-
this.isMovingToTarget = false;
|
|
860
|
-
this.event.stopMoveTo();
|
|
1008
|
+
this.clearTarget();
|
|
861
1009
|
this.changeState(AiState.Idle);
|
|
862
1010
|
return;
|
|
863
1011
|
}
|
|
@@ -1068,10 +1216,15 @@ export class BattleAi {
|
|
|
1068
1216
|
if (!this.target) return;
|
|
1069
1217
|
const profile = this.getAttackProfile(AttackPattern.Melee);
|
|
1070
1218
|
|
|
1071
|
-
this.faceTarget();
|
|
1219
|
+
this.faceTarget({ force: true });
|
|
1072
1220
|
this.telegraphAttack(profile);
|
|
1073
|
-
|
|
1074
|
-
|
|
1221
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
1222
|
+
emitActionBattleClientVisual({
|
|
1223
|
+
moment: "attack",
|
|
1224
|
+
entity: this.event,
|
|
1225
|
+
target: this.target,
|
|
1226
|
+
animations: this.animations,
|
|
1227
|
+
});
|
|
1075
1228
|
});
|
|
1076
1229
|
|
|
1077
1230
|
this.scheduleAttackStartup(profile, () => {
|
|
@@ -1083,24 +1236,43 @@ export class BattleAi {
|
|
|
1083
1236
|
profile: NormalizedActionBattleAttackProfile,
|
|
1084
1237
|
pattern: AttackPattern
|
|
1085
1238
|
) {
|
|
1086
|
-
if (!this.target) return;
|
|
1239
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
1087
1240
|
this.debugLog('attack', `Applying ${pattern} hit`);
|
|
1088
1241
|
|
|
1089
|
-
// Use skill if available
|
|
1090
1242
|
if (this.attackSkill) {
|
|
1243
|
+
const resolvedSkill = this.resolveUsable(this.attackSkill);
|
|
1091
1244
|
try {
|
|
1092
|
-
|
|
1093
|
-
|
|
1245
|
+
executeActionBattleUse({
|
|
1246
|
+
attacker: this.event,
|
|
1094
1247
|
target: this.target,
|
|
1248
|
+
usable: resolvedSkill,
|
|
1249
|
+
skill: resolvedSkill,
|
|
1250
|
+
pattern,
|
|
1251
|
+
profile,
|
|
1095
1252
|
});
|
|
1096
|
-
this.event.useSkill(this.attackSkill, this.target);
|
|
1097
1253
|
} catch (e) {
|
|
1098
1254
|
// Skill failed (no SP, etc.) - fall back to basic attack
|
|
1099
1255
|
this.performBasicHitbox(profile, pattern);
|
|
1100
1256
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const weapon = resolveActionBattleWeapon(this.event);
|
|
1261
|
+
if (
|
|
1262
|
+
weapon &&
|
|
1263
|
+
executeActionBattleUse({
|
|
1264
|
+
attacker: this.event,
|
|
1265
|
+
target: this.target,
|
|
1266
|
+
usable: weapon,
|
|
1267
|
+
weapon,
|
|
1268
|
+
pattern,
|
|
1269
|
+
profile,
|
|
1270
|
+
})
|
|
1271
|
+
) {
|
|
1272
|
+
return;
|
|
1103
1273
|
}
|
|
1274
|
+
|
|
1275
|
+
this.performBasicHitbox(profile, pattern);
|
|
1104
1276
|
}
|
|
1105
1277
|
|
|
1106
1278
|
/**
|
|
@@ -1112,7 +1284,21 @@ export class BattleAi {
|
|
|
1112
1284
|
),
|
|
1113
1285
|
pattern: AttackPattern = AttackPattern.Melee
|
|
1114
1286
|
) {
|
|
1115
|
-
if (!this.target) return;
|
|
1287
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
1288
|
+
|
|
1289
|
+
const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
|
|
1290
|
+
runActionBattleActiveHitbox(
|
|
1291
|
+
{ ...profile, startupMs: 0 },
|
|
1292
|
+
() => this.resolveBasicHitboxes(),
|
|
1293
|
+
(hitboxes) => {
|
|
1294
|
+
this.processHitboxHits(hitboxes, hitTracker, profile, pattern);
|
|
1295
|
+
},
|
|
1296
|
+
(scheduled, delay) => this.schedule(scheduled, delay)
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
private resolveBasicHitboxes(): ActionBattleHitbox[] {
|
|
1301
|
+
if (!this.target || this.isTargetDefeated(this.target)) return [];
|
|
1116
1302
|
|
|
1117
1303
|
const eventX = this.event.x();
|
|
1118
1304
|
const eventY = this.event.y();
|
|
@@ -1120,30 +1306,51 @@ export class BattleAi {
|
|
|
1120
1306
|
const dy = this.target.y() - eventY;
|
|
1121
1307
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1122
1308
|
|
|
1123
|
-
if (dist === 0) return;
|
|
1309
|
+
if (dist === 0) return [];
|
|
1124
1310
|
|
|
1125
1311
|
const dirX = dx / dist;
|
|
1126
1312
|
const dirY = dy / dist;
|
|
1127
1313
|
|
|
1128
|
-
|
|
1314
|
+
return [{
|
|
1129
1315
|
x: eventX + dirX * 30,
|
|
1130
1316
|
y: eventY + dirY * 30,
|
|
1131
1317
|
width: 40,
|
|
1132
1318
|
height: 40,
|
|
1133
1319
|
}];
|
|
1320
|
+
}
|
|
1134
1321
|
|
|
1322
|
+
private queryHitboxCandidates(hitboxes: ActionBattleHitbox[]) {
|
|
1135
1323
|
const map = this.event.getCurrentMap();
|
|
1136
|
-
map
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1324
|
+
if (!map || typeof (map as any).queryHitbox !== "function") return [];
|
|
1325
|
+
|
|
1326
|
+
const candidates = new Map<string, ActionBattleEntity>();
|
|
1327
|
+
for (const hitbox of hitboxes) {
|
|
1328
|
+
for (const hit of (map as any).queryHitbox(hitbox, {
|
|
1329
|
+
excludeIds: [this.event.id],
|
|
1330
|
+
kinds: ["players", "events"],
|
|
1331
|
+
})) {
|
|
1332
|
+
if (hit?.id) candidates.set(hit.id, hit);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
return Array.from(candidates.values());
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
private processHitboxHits(
|
|
1339
|
+
hitboxes: ActionBattleHitbox[],
|
|
1340
|
+
hitTracker: ActionBattleHitTracker,
|
|
1341
|
+
profile: NormalizedActionBattleAttackProfile,
|
|
1342
|
+
pattern: AttackPattern
|
|
1343
|
+
) {
|
|
1344
|
+
for (const hit of this.queryHitboxCandidates(hitboxes)) {
|
|
1345
|
+
if (
|
|
1346
|
+
hit !== this.event &&
|
|
1347
|
+
this.canTarget(hit) &&
|
|
1348
|
+
!this.isTargetDefeated(hit) &&
|
|
1349
|
+
hitTracker.tryHit(hit)
|
|
1350
|
+
) {
|
|
1351
|
+
this.applyHit(hit, undefined, profile, pattern);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1147
1354
|
}
|
|
1148
1355
|
|
|
1149
1356
|
/**
|
|
@@ -1175,13 +1382,24 @@ export class BattleAi {
|
|
|
1175
1382
|
* ```
|
|
1176
1383
|
*/
|
|
1177
1384
|
private applyHit(
|
|
1178
|
-
target:
|
|
1385
|
+
target: ActionBattleEntity,
|
|
1179
1386
|
hooks?: ApplyHitHooks,
|
|
1180
1387
|
profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
|
|
1181
1388
|
AttackPattern.Melee
|
|
1182
1389
|
),
|
|
1183
1390
|
pattern: AttackPattern = AttackPattern.Melee
|
|
1184
1391
|
): HitResult {
|
|
1392
|
+
if (this.isTargetDefeated(target)) {
|
|
1393
|
+
return {
|
|
1394
|
+
damage: 0,
|
|
1395
|
+
knockbackForce: 0,
|
|
1396
|
+
knockbackDuration: 0,
|
|
1397
|
+
defeated: true,
|
|
1398
|
+
attacker: this.event,
|
|
1399
|
+
target
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1185
1403
|
if (isActionBattleEntityInvincible(target)) {
|
|
1186
1404
|
return {
|
|
1187
1405
|
damage: 0,
|
|
@@ -1219,13 +1437,16 @@ export class BattleAi {
|
|
|
1219
1437
|
}
|
|
1220
1438
|
|
|
1221
1439
|
// Visual feedback
|
|
1222
|
-
target
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1440
|
+
withActionBattleAnimationUnlocked(target, () => {
|
|
1441
|
+
emitActionBattleClientVisual({
|
|
1442
|
+
moment: "hit",
|
|
1443
|
+
entity: this.event,
|
|
1444
|
+
target,
|
|
1445
|
+
damage: hitResult.damage,
|
|
1446
|
+
result: hitResult,
|
|
1447
|
+
animations: this.animations,
|
|
1448
|
+
});
|
|
1227
1449
|
});
|
|
1228
|
-
target.showHit(`-${hitResult.damage}`);
|
|
1229
1450
|
setActionBattleInvincibility(
|
|
1230
1451
|
target,
|
|
1231
1452
|
profile.reaction.invincibilityMs
|
|
@@ -1251,6 +1472,16 @@ export class BattleAi {
|
|
|
1251
1472
|
hooks.onAfterHit(hitResult);
|
|
1252
1473
|
}
|
|
1253
1474
|
|
|
1475
|
+
const targetAi = (target as any).battleAi as BattleAi | undefined;
|
|
1476
|
+
if (targetAi) {
|
|
1477
|
+
targetAi.handleDamage(this.event, {
|
|
1478
|
+
damage: hitResult.damage,
|
|
1479
|
+
defeated: hitResult.defeated,
|
|
1480
|
+
raw: undefined,
|
|
1481
|
+
reaction: profile.reaction,
|
|
1482
|
+
});
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1254
1485
|
return hitResult;
|
|
1255
1486
|
}
|
|
1256
1487
|
|
|
@@ -1294,10 +1525,15 @@ export class BattleAi {
|
|
|
1294
1525
|
|
|
1295
1526
|
this.comboCount++;
|
|
1296
1527
|
const profile = this.getAttackProfile(AttackPattern.Combo);
|
|
1297
|
-
this.faceTarget();
|
|
1528
|
+
this.faceTarget({ force: true });
|
|
1298
1529
|
this.telegraphAttack(profile);
|
|
1299
|
-
|
|
1300
|
-
|
|
1530
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
1531
|
+
emitActionBattleClientVisual({
|
|
1532
|
+
moment: "attack",
|
|
1533
|
+
entity: this.event,
|
|
1534
|
+
target: this.target,
|
|
1535
|
+
animations: this.animations,
|
|
1536
|
+
});
|
|
1301
1537
|
});
|
|
1302
1538
|
this.scheduleAttackStartup(profile, () => {
|
|
1303
1539
|
this.executeMeleeAttack(profile, AttackPattern.Combo);
|
|
@@ -1324,17 +1560,17 @@ export class BattleAi {
|
|
|
1324
1560
|
const profile = this.getAttackProfile(AttackPattern.Charged);
|
|
1325
1561
|
|
|
1326
1562
|
this.chargingAttack = true;
|
|
1327
|
-
this.faceTarget();
|
|
1563
|
+
this.faceTarget({ force: true });
|
|
1328
1564
|
this.telegraphAttack(profile);
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
{
|
|
1565
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
1566
|
+
emitActionBattleClientVisual({
|
|
1567
|
+
moment: "attack",
|
|
1568
|
+
entity: this.event,
|
|
1334
1569
|
target: this.target,
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1570
|
+
animations: this.animations,
|
|
1571
|
+
animationDefaults: { repeat: 2 },
|
|
1572
|
+
});
|
|
1573
|
+
});
|
|
1338
1574
|
|
|
1339
1575
|
this.scheduleAttackStartup(profile, () => {
|
|
1340
1576
|
if (!this.target || this.state !== AiState.Combat) {
|
|
@@ -1354,8 +1590,13 @@ export class BattleAi {
|
|
|
1354
1590
|
private performZoneAttack() {
|
|
1355
1591
|
const profile = this.getAttackProfile(AttackPattern.Zone);
|
|
1356
1592
|
this.telegraphAttack(profile);
|
|
1357
|
-
|
|
1358
|
-
|
|
1593
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
1594
|
+
emitActionBattleClientVisual({
|
|
1595
|
+
moment: "attack",
|
|
1596
|
+
entity: this.event,
|
|
1597
|
+
target: this.target ?? undefined,
|
|
1598
|
+
animations: this.animations,
|
|
1599
|
+
});
|
|
1359
1600
|
});
|
|
1360
1601
|
|
|
1361
1602
|
const eventX = this.event.x();
|
|
@@ -1376,18 +1617,20 @@ export class BattleAi {
|
|
|
1376
1617
|
});
|
|
1377
1618
|
|
|
1378
1619
|
this.scheduleAttackStartup(profile, () => {
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1620
|
+
const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
|
|
1621
|
+
runActionBattleActiveHitbox(
|
|
1622
|
+
{ ...profile, startupMs: 0 },
|
|
1623
|
+
() => hitboxes,
|
|
1624
|
+
(activeHitboxes) => {
|
|
1625
|
+
this.processHitboxHits(
|
|
1626
|
+
activeHitboxes,
|
|
1627
|
+
hitTracker,
|
|
1628
|
+
profile,
|
|
1629
|
+
AttackPattern.Zone
|
|
1630
|
+
);
|
|
1389
1631
|
},
|
|
1390
|
-
|
|
1632
|
+
(scheduled, delay) => this.schedule(scheduled, delay)
|
|
1633
|
+
);
|
|
1391
1634
|
});
|
|
1392
1635
|
}
|
|
1393
1636
|
|
|
@@ -1395,7 +1638,7 @@ export class BattleAi {
|
|
|
1395
1638
|
* Perform dash attack
|
|
1396
1639
|
*/
|
|
1397
1640
|
private performDashAttack() {
|
|
1398
|
-
if (!this.target) return;
|
|
1641
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
1399
1642
|
const profile = this.getAttackProfile(AttackPattern.DashAttack);
|
|
1400
1643
|
|
|
1401
1644
|
const dx = this.target.x() - this.event.x();
|
|
@@ -1407,12 +1650,12 @@ export class BattleAi {
|
|
|
1407
1650
|
const dirX = dx / dist;
|
|
1408
1651
|
const dirY = dy / dist;
|
|
1409
1652
|
|
|
1410
|
-
this.faceTarget();
|
|
1653
|
+
this.faceTarget({ force: true });
|
|
1411
1654
|
this.telegraphAttack(profile);
|
|
1412
1655
|
|
|
1413
1656
|
this.scheduleAttackStartup(profile, () => {
|
|
1414
1657
|
if (!this.target || this.state !== AiState.Combat) return;
|
|
1415
|
-
this.event
|
|
1658
|
+
safeActionBattleDash(this.event, { x: dirX, y: dirY }, 10, 200);
|
|
1416
1659
|
this.schedule(() => {
|
|
1417
1660
|
if (!this.target || this.state !== AiState.Combat) return;
|
|
1418
1661
|
this.executeMeleeAttack(profile, AttackPattern.DashAttack);
|
|
@@ -1455,7 +1698,7 @@ export class BattleAi {
|
|
|
1455
1698
|
* 2. When near diagonal, require significant difference to change
|
|
1456
1699
|
* 3. Only change if direction is clearly wrong (opposite)
|
|
1457
1700
|
*/
|
|
1458
|
-
private faceTarget() {
|
|
1701
|
+
private faceTarget(options: { force?: boolean } = {}) {
|
|
1459
1702
|
if (!this.target) return;
|
|
1460
1703
|
|
|
1461
1704
|
const dx = this.target.x() - this.event.x();
|
|
@@ -1464,11 +1707,12 @@ export class BattleAi {
|
|
|
1464
1707
|
const absY = Math.abs(dy);
|
|
1465
1708
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1466
1709
|
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
|
|
1710
|
+
// Avoid undefined facing only when both entities are effectively stacked.
|
|
1711
|
+
// A larger dead-zone makes close melee targets keep stale directions after
|
|
1712
|
+
// knockback, so keep this threshold intentionally tiny.
|
|
1713
|
+
const minDistanceForDirectionChange = 4;
|
|
1470
1714
|
if (this.lastFacingDirection && distance < minDistanceForDirectionChange) {
|
|
1471
|
-
return;
|
|
1715
|
+
return;
|
|
1472
1716
|
}
|
|
1473
1717
|
|
|
1474
1718
|
// Calculate the "ideal" direction
|
|
@@ -1500,7 +1744,11 @@ export class BattleAi {
|
|
|
1500
1744
|
}
|
|
1501
1745
|
|
|
1502
1746
|
this.lastFacingDirection = newDirection;
|
|
1503
|
-
|
|
1747
|
+
if (options.force) {
|
|
1748
|
+
applyActionBattleAttackDirection(this.event, newDirection as any);
|
|
1749
|
+
} else {
|
|
1750
|
+
this.event.changeDirection(newDirection as any);
|
|
1751
|
+
}
|
|
1504
1752
|
}
|
|
1505
1753
|
|
|
1506
1754
|
/**
|
|
@@ -1531,7 +1779,9 @@ export class BattleAi {
|
|
|
1531
1779
|
const side = Math.random() > 0.5 ? 1 : -1;
|
|
1532
1780
|
|
|
1533
1781
|
this.debugLog('dodge', `Dodging (dir=${side > 0 ? 'right' : 'left'})`);
|
|
1534
|
-
this.event
|
|
1782
|
+
if (!safeActionBattleDash(this.event, { x: dodgeDirX * side, y: dodgeDirY * side }, 12, 300)) {
|
|
1783
|
+
return false;
|
|
1784
|
+
}
|
|
1535
1785
|
this.lastDodgeTime = currentTime;
|
|
1536
1786
|
|
|
1537
1787
|
// Counter-attack for defensive types
|
|
@@ -1593,7 +1843,9 @@ export class BattleAi {
|
|
|
1593
1843
|
|
|
1594
1844
|
if (dist === 0) return;
|
|
1595
1845
|
|
|
1596
|
-
this.event
|
|
1846
|
+
if (!safeActionBattleDash(this.event, { x: dx / dist, y: dy / dist }, 8, 200)) {
|
|
1847
|
+
return;
|
|
1848
|
+
}
|
|
1597
1849
|
this.lastRetreatTime = currentTime;
|
|
1598
1850
|
}
|
|
1599
1851
|
|
|
@@ -1695,9 +1947,14 @@ export class BattleAi {
|
|
|
1695
1947
|
/**
|
|
1696
1948
|
* Handle player entering vision
|
|
1697
1949
|
*/
|
|
1698
|
-
onDetectInShape(
|
|
1699
|
-
this.
|
|
1700
|
-
this.target
|
|
1950
|
+
onDetectInShape(target: ActionBattleEntity, shape: any) {
|
|
1951
|
+
if (!this.canTarget(target) || this.isTargetDefeated(target)) return;
|
|
1952
|
+
this.debugLog('vision', `Target ${target.id} entered vision (state=${this.state})`);
|
|
1953
|
+
this.engageTarget(target);
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
private engageTarget(target: ActionBattleEntity) {
|
|
1957
|
+
this.target = target;
|
|
1701
1958
|
|
|
1702
1959
|
if (this.state === AiState.Idle) {
|
|
1703
1960
|
this.changeState(AiState.Alert);
|
|
@@ -1709,12 +1966,10 @@ export class BattleAi {
|
|
|
1709
1966
|
/**
|
|
1710
1967
|
* Handle player leaving vision
|
|
1711
1968
|
*/
|
|
1712
|
-
onDetectOutShape(
|
|
1713
|
-
this.debugLog('vision', `
|
|
1714
|
-
if (this.target ===
|
|
1715
|
-
this.
|
|
1716
|
-
this.isMovingToTarget = false;
|
|
1717
|
-
this.event.stopMoveTo();
|
|
1969
|
+
onDetectOutShape(target: ActionBattleEntity, shape: any) {
|
|
1970
|
+
this.debugLog('vision', `Target ${target.id} left vision (wasTarget=${this.target === target})`);
|
|
1971
|
+
if (this.target === target) {
|
|
1972
|
+
this.clearTarget();
|
|
1718
1973
|
this.changeState(AiState.Idle);
|
|
1719
1974
|
}
|
|
1720
1975
|
}
|
|
@@ -1725,7 +1980,7 @@ export class BattleAi {
|
|
|
1725
1980
|
* This triggers state changes like stun and flee check.
|
|
1726
1981
|
* The actual damage is applied externally via RPGJS API.
|
|
1727
1982
|
*/
|
|
1728
|
-
takeDamage(attacker:
|
|
1983
|
+
takeDamage(attacker: ActionBattleEntity): boolean {
|
|
1729
1984
|
if (this.defeated) return true;
|
|
1730
1985
|
// Apply damage using RPGJS system
|
|
1731
1986
|
const raw = this.event.applyDamage(attacker);
|
|
@@ -1737,7 +1992,7 @@ export class BattleAi {
|
|
|
1737
1992
|
}
|
|
1738
1993
|
|
|
1739
1994
|
handleDamage(
|
|
1740
|
-
attacker:
|
|
1995
|
+
attacker: ActionBattleEntity,
|
|
1741
1996
|
damageResult: ActionBattleDamageResult & {
|
|
1742
1997
|
reaction?: NormalizedActionBattleHitReactionProfile;
|
|
1743
1998
|
}
|
|
@@ -1747,15 +2002,17 @@ export class BattleAi {
|
|
|
1747
2002
|
this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
|
|
1748
2003
|
|
|
1749
2004
|
// Visual feedback
|
|
1750
|
-
this.event
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
2005
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
2006
|
+
emitActionBattleClientVisual({
|
|
2007
|
+
moment: "hurt",
|
|
2008
|
+
entity: this.event,
|
|
2009
|
+
target: this.event,
|
|
2010
|
+
attacker,
|
|
2011
|
+
damage,
|
|
2012
|
+
defeated: damageResult.defeated,
|
|
2013
|
+
result: damageResult,
|
|
2014
|
+
animations: this.animations,
|
|
2015
|
+
});
|
|
1759
2016
|
});
|
|
1760
2017
|
|
|
1761
2018
|
// Track damage
|
|
@@ -1794,7 +2051,7 @@ export class BattleAi {
|
|
|
1794
2051
|
* Stops all movements, cleans up resources, calls the onDefeated hook,
|
|
1795
2052
|
* and removes the event from the map.
|
|
1796
2053
|
*/
|
|
1797
|
-
private kill(attacker?:
|
|
2054
|
+
private kill(attacker?: ActionBattleEntity) {
|
|
1798
2055
|
if (this.defeated) return;
|
|
1799
2056
|
this.defeated = true;
|
|
1800
2057
|
|
|
@@ -1828,7 +2085,7 @@ export class BattleAi {
|
|
|
1828
2085
|
});
|
|
1829
2086
|
};
|
|
1830
2087
|
|
|
1831
|
-
if (this.autoAwardRewards) {
|
|
2088
|
+
if (this.autoAwardRewards && attacker && isActionBattlePlayer(attacker)) {
|
|
1832
2089
|
reward.giveTo(attacker);
|
|
1833
2090
|
}
|
|
1834
2091
|
|
|
@@ -1846,7 +2103,7 @@ export class BattleAi {
|
|
|
1846
2103
|
(
|
|
1847
2104
|
this.onDefeatedCallback as (
|
|
1848
2105
|
event: RpgEvent,
|
|
1849
|
-
|
|
2106
|
+
attacker?: ActionBattleEntity
|
|
1850
2107
|
) => void
|
|
1851
2108
|
)(this.event, attacker);
|
|
1852
2109
|
} else {
|
|
@@ -1869,6 +2126,68 @@ export class BattleAi {
|
|
|
1869
2126
|
return Math.sqrt(dx * dx + dy * dy);
|
|
1870
2127
|
}
|
|
1871
2128
|
|
|
2129
|
+
private resolveUsable(usable: any) {
|
|
2130
|
+
if (!usable) return usable;
|
|
2131
|
+
const id = typeof usable === "string" ? usable : usable.id;
|
|
2132
|
+
const learned = id ? (this.event as any).getSkill?.(id) : undefined;
|
|
2133
|
+
if (learned) return learned;
|
|
2134
|
+
try {
|
|
2135
|
+
return id ? (this.event as any).databaseById?.(id) ?? usable : usable;
|
|
2136
|
+
} catch {
|
|
2137
|
+
return usable;
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
private getCurrentActionRange(): number | undefined {
|
|
2142
|
+
const skillRange = this.attackSkill
|
|
2143
|
+
? getActionBattleActionRange(this.resolveUsable(this.attackSkill))
|
|
2144
|
+
: undefined;
|
|
2145
|
+
if (skillRange !== undefined) return skillRange;
|
|
2146
|
+
return getActionBattleActionRange(resolveActionBattleWeapon(this.event));
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
private canTarget(target: ActionBattleEntity): boolean {
|
|
2150
|
+
return canActionBattleTarget(
|
|
2151
|
+
this.event,
|
|
2152
|
+
target,
|
|
2153
|
+
this.targets,
|
|
2154
|
+
getActionBattleOptions().combat?.targets
|
|
2155
|
+
);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
private findNearestTarget(): ActionBattleEntity | null {
|
|
2159
|
+
const map = this.event.getCurrentMap();
|
|
2160
|
+
if (!map) return null;
|
|
2161
|
+
|
|
2162
|
+
const candidates: ActionBattleEntity[] = [];
|
|
2163
|
+
map.getPlayers?.().forEach((player: RpgPlayer) => candidates.push(player));
|
|
2164
|
+
map.getEvents?.().forEach((event: RpgEvent) => candidates.push(event));
|
|
2165
|
+
|
|
2166
|
+
let nearest: ActionBattleEntity | null = null;
|
|
2167
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
2168
|
+
for (const candidate of candidates) {
|
|
2169
|
+
if (!this.canTarget(candidate)) continue;
|
|
2170
|
+
const distance = this.getDistance(this.event, candidate);
|
|
2171
|
+
if (distance > this.visionRange) continue;
|
|
2172
|
+
if (distance < nearestDistance) {
|
|
2173
|
+
nearest = candidate;
|
|
2174
|
+
nearestDistance = distance;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
return nearest;
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
private isTargetDefeated(target: ActionBattleEntity | null | undefined): boolean {
|
|
2182
|
+
return !target || target.hp <= 0;
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
private clearTarget() {
|
|
2186
|
+
this.target = null;
|
|
2187
|
+
this.isMovingToTarget = false;
|
|
2188
|
+
this.event.stopMoveTo();
|
|
2189
|
+
}
|
|
2190
|
+
|
|
1872
2191
|
private updateBehavior(currentTime: number) {
|
|
1873
2192
|
if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) {
|
|
1874
2193
|
return;
|
|
@@ -1927,10 +2246,25 @@ export class BattleAi {
|
|
|
1927
2246
|
}
|
|
1928
2247
|
}
|
|
1929
2248
|
|
|
1930
|
-
private applyCustomBehavior(currentTime: number) {
|
|
1931
|
-
|
|
2249
|
+
private applyCustomBehavior(currentTime: number): boolean {
|
|
2250
|
+
let handled = false;
|
|
2251
|
+
|
|
2252
|
+
if (this.behaviorTree) {
|
|
2253
|
+
const result = this.behaviorTree.tick(this.createAiTreeContext(currentTime));
|
|
2254
|
+
if (result.decision) {
|
|
2255
|
+
handled = this.applyAiDecision(result.decision, currentTime) || handled;
|
|
2256
|
+
}
|
|
2257
|
+
if (result.intent) {
|
|
2258
|
+
handled = this.executeAiIntents(result.intent, currentTime) || handled;
|
|
2259
|
+
}
|
|
2260
|
+
if (result.status === "running") {
|
|
2261
|
+
handled = true;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
if (!this.behaviorKey) return handled;
|
|
1932
2266
|
const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
|
|
1933
|
-
if (!behavior) return;
|
|
2267
|
+
if (!behavior) return handled;
|
|
1934
2268
|
const maxHp = this.event.param[MAXHP];
|
|
1935
2269
|
const decision = behavior({
|
|
1936
2270
|
event: this.event,
|
|
@@ -1941,7 +2275,16 @@ export class BattleAi {
|
|
|
1941
2275
|
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
1942
2276
|
now: currentTime,
|
|
1943
2277
|
});
|
|
1944
|
-
if (!decision) return;
|
|
2278
|
+
if (!decision) return handled;
|
|
2279
|
+
return this.applyAiDecision(decision, currentTime) || handled;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
private applyAiDecision(
|
|
2283
|
+
decision: ActionBattleAiDecision | ReturnType<ActionBattleAiBehavior>,
|
|
2284
|
+
currentTime: number
|
|
2285
|
+
): boolean {
|
|
2286
|
+
if (!decision) return false;
|
|
2287
|
+
let handled = false;
|
|
1945
2288
|
if (decision.attackCooldown !== undefined) {
|
|
1946
2289
|
this.attackCooldown = decision.attackCooldown;
|
|
1947
2290
|
}
|
|
@@ -1955,6 +2298,164 @@ export class BattleAi {
|
|
|
1955
2298
|
this.behaviorMode = decision.mode;
|
|
1956
2299
|
this.behaviorEnabled = true;
|
|
1957
2300
|
}
|
|
2301
|
+
if (decision.intent) {
|
|
2302
|
+
handled = this.executeAiIntents(decision.intent, currentTime);
|
|
2303
|
+
}
|
|
2304
|
+
return handled;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
private createAiTreeContext(currentTime: number) {
|
|
2308
|
+
const maxHp = this.event.param[MAXHP];
|
|
2309
|
+
const distance = this.target ? this.getDistance(this.event, this.target) : null;
|
|
2310
|
+
return {
|
|
2311
|
+
event: this.event,
|
|
2312
|
+
target: this.target,
|
|
2313
|
+
state: this.state,
|
|
2314
|
+
enemyType: this.enemyType,
|
|
2315
|
+
distance,
|
|
2316
|
+
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
2317
|
+
now: currentTime,
|
|
2318
|
+
self: {
|
|
2319
|
+
event: this.event,
|
|
2320
|
+
state: this.state,
|
|
2321
|
+
enemyType: this.enemyType,
|
|
2322
|
+
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
2323
|
+
attackRange: this.attackRange,
|
|
2324
|
+
},
|
|
2325
|
+
targetInfo:
|
|
2326
|
+
this.target && distance !== null
|
|
2327
|
+
? {
|
|
2328
|
+
entity: this.target,
|
|
2329
|
+
distance,
|
|
2330
|
+
inAttackRange: distance <= this.attackRange,
|
|
2331
|
+
visible: true,
|
|
2332
|
+
}
|
|
2333
|
+
: null,
|
|
2334
|
+
memory: this.aiMemory,
|
|
2335
|
+
};
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
private executeAiIntents(
|
|
2339
|
+
input: ActionBattleAiIntent | ActionBattleAiIntent[],
|
|
2340
|
+
currentTime: number
|
|
2341
|
+
): boolean {
|
|
2342
|
+
const intents = Array.isArray(input) ? input : [input];
|
|
2343
|
+
let handled = false;
|
|
2344
|
+
for (const intent of intents) {
|
|
2345
|
+
handled = this.executeAiIntent(intent, currentTime) || handled;
|
|
2346
|
+
}
|
|
2347
|
+
return handled;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
private executeAiIntent(
|
|
2351
|
+
intent: ActionBattleAiIntent,
|
|
2352
|
+
currentTime: number
|
|
2353
|
+
): boolean {
|
|
2354
|
+
const consumes = intent.consume !== false;
|
|
2355
|
+
|
|
2356
|
+
switch (intent.type) {
|
|
2357
|
+
case "setMode":
|
|
2358
|
+
this.behaviorMode = intent.mode;
|
|
2359
|
+
this.behaviorEnabled = true;
|
|
2360
|
+
return consumes;
|
|
2361
|
+
case "idle":
|
|
2362
|
+
this.isMovingToTarget = false;
|
|
2363
|
+
this.event.stopMoveTo();
|
|
2364
|
+
return consumes;
|
|
2365
|
+
case "patrol":
|
|
2366
|
+
this.startPatrol();
|
|
2367
|
+
return consumes;
|
|
2368
|
+
case "faceTarget":
|
|
2369
|
+
this.faceTarget();
|
|
2370
|
+
return consumes;
|
|
2371
|
+
case "moveToTarget":
|
|
2372
|
+
if (!this.target) return false;
|
|
2373
|
+
this.isMovingToTarget = true;
|
|
2374
|
+
this.requestMoveTo(this.target);
|
|
2375
|
+
return consumes;
|
|
2376
|
+
case "fleeFromTarget":
|
|
2377
|
+
if (!this.target) return false;
|
|
2378
|
+
this.isMovingToTarget = false;
|
|
2379
|
+
if (this.state === AiState.Combat) {
|
|
2380
|
+
this.changeState(AiState.Flee);
|
|
2381
|
+
} else {
|
|
2382
|
+
this.fleeFromTarget();
|
|
2383
|
+
}
|
|
2384
|
+
return consumes;
|
|
2385
|
+
case "keepDistance":
|
|
2386
|
+
return this.executeKeepDistance(intent, consumes);
|
|
2387
|
+
case "useAttack":
|
|
2388
|
+
return this.executeRequestedAttack(intent.pattern, currentTime, consumes);
|
|
2389
|
+
case "useSkill":
|
|
2390
|
+
return this.executeRequestedSkill(intent.skill, currentTime, consumes);
|
|
2391
|
+
}
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
private executeKeepDistance(
|
|
2395
|
+
intent: Extract<ActionBattleAiIntent, { type: "keepDistance" }>,
|
|
2396
|
+
consumes: boolean
|
|
2397
|
+
): boolean {
|
|
2398
|
+
if (!this.target) return false;
|
|
2399
|
+
const tolerance = intent.tolerance ?? Math.max(8, intent.distance * 0.15);
|
|
2400
|
+
const distance = this.getDistance(this.event, this.target);
|
|
2401
|
+
if (distance < intent.distance - tolerance) {
|
|
2402
|
+
this.isMovingToTarget = false;
|
|
2403
|
+
this.retreatFromTarget();
|
|
2404
|
+
return consumes;
|
|
2405
|
+
}
|
|
2406
|
+
if (distance > intent.distance + tolerance) {
|
|
2407
|
+
this.isMovingToTarget = true;
|
|
2408
|
+
this.requestMoveTo(this.target);
|
|
2409
|
+
return consumes;
|
|
2410
|
+
}
|
|
2411
|
+
if (this.isMovingToTarget) {
|
|
2412
|
+
this.isMovingToTarget = false;
|
|
2413
|
+
this.event.stopMoveTo();
|
|
2414
|
+
}
|
|
2415
|
+
return consumes;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
private executeRequestedAttack(
|
|
2419
|
+
pattern: AttackPattern | string | undefined,
|
|
2420
|
+
currentTime: number,
|
|
2421
|
+
consumes: boolean
|
|
2422
|
+
): boolean {
|
|
2423
|
+
if (!this.target || this.isTargetDefeated(this.target) || this.chargingAttack) return false;
|
|
2424
|
+
const distance = this.getDistance(this.event, this.target);
|
|
2425
|
+
if (distance > this.attackRange) return false;
|
|
2426
|
+
if (currentTime - this.lastAttackTime < this.attackCooldown) return false;
|
|
2427
|
+
|
|
2428
|
+
if (pattern) {
|
|
2429
|
+
this.performAttackPattern(pattern as AttackPattern);
|
|
2430
|
+
} else {
|
|
2431
|
+
this.selectAndPerformAttack();
|
|
2432
|
+
}
|
|
2433
|
+
this.lastAttackTime = currentTime;
|
|
2434
|
+
return consumes;
|
|
2435
|
+
}
|
|
2436
|
+
|
|
2437
|
+
private executeRequestedSkill(
|
|
2438
|
+
skill: any,
|
|
2439
|
+
currentTime: number,
|
|
2440
|
+
consumes: boolean
|
|
2441
|
+
): boolean {
|
|
2442
|
+
if (!this.target || this.isTargetDefeated(this.target) || !skill) return false;
|
|
2443
|
+
const distance = this.getDistance(this.event, this.target);
|
|
2444
|
+
const resolvedSkill = this.resolveUsable(skill);
|
|
2445
|
+
const range = getActionBattleActionRange(resolvedSkill) ?? this.attackRange;
|
|
2446
|
+
const cooldownRemaining = this.attackCooldown - (currentTime - this.lastAttackTime);
|
|
2447
|
+
if (distance > range) return false;
|
|
2448
|
+
if (cooldownRemaining > 0) return false;
|
|
2449
|
+
|
|
2450
|
+
executeActionBattleUse({
|
|
2451
|
+
attacker: this.event,
|
|
2452
|
+
target: this.target,
|
|
2453
|
+
usable: resolvedSkill,
|
|
2454
|
+
skill: resolvedSkill,
|
|
2455
|
+
profile: this.getAttackProfile(AttackPattern.Melee),
|
|
2456
|
+
});
|
|
2457
|
+
this.lastAttackTime = currentTime;
|
|
2458
|
+
return consumes;
|
|
1958
2459
|
}
|
|
1959
2460
|
|
|
1960
2461
|
private handleTacticalMovement(distance: number) {
|
|
@@ -2016,6 +2517,7 @@ export class BattleAi {
|
|
|
2016
2517
|
private schedule(callback: () => void, delay: number) {
|
|
2017
2518
|
const timer = setTimeout(() => {
|
|
2018
2519
|
this.timers = this.timers.filter((entry) => entry !== timer);
|
|
2520
|
+
if (this.destroyed) return;
|
|
2019
2521
|
callback();
|
|
2020
2522
|
}, delay);
|
|
2021
2523
|
this.timers.push(timer);
|
|
@@ -2025,14 +2527,31 @@ export class BattleAi {
|
|
|
2025
2527
|
// Public getters
|
|
2026
2528
|
getHealth(): number { return this.event.hp; }
|
|
2027
2529
|
getMaxHealth(): number { return this.event.param[MAXHP]; }
|
|
2028
|
-
getTarget():
|
|
2530
|
+
getTarget(): ActionBattleEntity | null { return this.target; }
|
|
2029
2531
|
getState(): AiState { return this.state; }
|
|
2532
|
+
getFaction(): string | undefined {
|
|
2533
|
+
return this.faction;
|
|
2534
|
+
}
|
|
2535
|
+
setFaction(faction: string | undefined): void {
|
|
2536
|
+
this.faction = faction;
|
|
2537
|
+
}
|
|
2538
|
+
getTargets(): ActionBattleTargetSelector {
|
|
2539
|
+
return this.targets;
|
|
2540
|
+
}
|
|
2541
|
+
setTargets(targets: ActionBattleTargetSelector): void {
|
|
2542
|
+
this.targets = targets;
|
|
2543
|
+
if (this.target && !this.canTarget(this.target)) {
|
|
2544
|
+
this.clearTarget();
|
|
2545
|
+
this.changeState(AiState.Idle);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2030
2548
|
getEnemyType(): EnemyType { return this.enemyType; }
|
|
2031
2549
|
|
|
2032
2550
|
/**
|
|
2033
2551
|
* Clean up
|
|
2034
2552
|
*/
|
|
2035
2553
|
destroy() {
|
|
2554
|
+
this.destroyed = true;
|
|
2036
2555
|
if (this.updateInterval) {
|
|
2037
2556
|
clearInterval(this.updateInterval);
|
|
2038
2557
|
this.updateInterval = undefined;
|