@rpgjs/action-battle 5.0.0-beta.11 → 5.0.0-beta.13
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 +22 -0
- package/dist/client/ai.server.d.ts +57 -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 +3 -2
- 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 +203 -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 +70 -0
- package/dist/client/index22.js +226 -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 +1949 -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 +57 -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 +3 -2
- 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 +67 -11
- package/dist/server/index14.js +207 -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 +1949 -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 +208 -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 +380 -1
- package/src/ai.server.ts +963 -137
- 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 +387 -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 +72 -1
- package/src/core/equipment.ts +9 -5
- package/src/core/hit.spec.ts +21 -0
- package/src/core/targets.spec.ts +124 -0
- package/src/core/targets.ts +150 -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,
|
|
@@ -30,6 +60,31 @@ type RpgEventWithBattleAi = RpgEvent & {
|
|
|
30
60
|
battleAi?: BattleAi;
|
|
31
61
|
};
|
|
32
62
|
|
|
63
|
+
type ResolvedMoveTarget =
|
|
64
|
+
| {
|
|
65
|
+
kind: "entity";
|
|
66
|
+
target: ActionBattleEntity;
|
|
67
|
+
id?: string;
|
|
68
|
+
x?: number;
|
|
69
|
+
y?: number;
|
|
70
|
+
signature: string;
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
kind: "position";
|
|
74
|
+
target: { x: number; y: number };
|
|
75
|
+
x: number;
|
|
76
|
+
y: number;
|
|
77
|
+
signature: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const resolveMoveCoordinate = (value: unknown): number | undefined => {
|
|
81
|
+
const raw = typeof value === "function" ? (value as () => unknown)() : value;
|
|
82
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : undefined;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const createMoveSignature = (x: number, y: number): string =>
|
|
86
|
+
`position:${Math.round(x)}:${Math.round(y)}`;
|
|
87
|
+
|
|
33
88
|
export interface BattleAiRewardItem {
|
|
34
89
|
item?: any;
|
|
35
90
|
itemId?: string;
|
|
@@ -51,7 +106,7 @@ export interface BattleAiDefeatReward {
|
|
|
51
106
|
|
|
52
107
|
export interface BattleAiDefeatedContext {
|
|
53
108
|
event: RpgEvent;
|
|
54
|
-
attacker?:
|
|
109
|
+
attacker?: ActionBattleEntity;
|
|
55
110
|
reward: BattleAiDefeatReward;
|
|
56
111
|
remove: () => void;
|
|
57
112
|
}
|
|
@@ -62,10 +117,13 @@ export type BattleAiDefeatedCallback = (
|
|
|
62
117
|
|
|
63
118
|
export type BattleAiLegacyDefeatedCallback = (
|
|
64
119
|
event: RpgEvent,
|
|
65
|
-
attacker?:
|
|
120
|
+
attacker?: ActionBattleEntity
|
|
66
121
|
) => void;
|
|
67
122
|
|
|
68
123
|
export interface BattleAiBaseOptions {
|
|
124
|
+
preset?: string | ActionBattleAiPreset;
|
|
125
|
+
faction?: string;
|
|
126
|
+
targets?: ActionBattleTargetSelector;
|
|
69
127
|
enemyType?: EnemyType;
|
|
70
128
|
attackCooldown?: number;
|
|
71
129
|
visionRange?: number;
|
|
@@ -91,6 +149,9 @@ export interface BattleAiBaseOptions {
|
|
|
91
149
|
retreatThreshold?: number;
|
|
92
150
|
};
|
|
93
151
|
behaviorKey?: string;
|
|
152
|
+
tree?: ActionBattleAiTreeInput;
|
|
153
|
+
behaviorTree?: ActionBattleAiTreeInput;
|
|
154
|
+
simpleBehavior?: ActionBattleAiSimpleBehavior;
|
|
94
155
|
animations?: ActionBattleAnimationOptions;
|
|
95
156
|
rewards?: BattleAiRewards;
|
|
96
157
|
autoAwardRewards?: boolean;
|
|
@@ -298,16 +359,10 @@ export const AiDebug = {
|
|
|
298
359
|
* @param data - Optional additional data
|
|
299
360
|
*/
|
|
300
361
|
log(category: string, eventId: string | undefined, message: string, data?: any): void {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const prefix = `[AI:${category}]${eventId ? ` [${eventId.substring(0, 8)}]` : ''}`;
|
|
306
|
-
if (data !== undefined) {
|
|
307
|
-
console.log(prefix, message, data);
|
|
308
|
-
} else {
|
|
309
|
-
console.log(prefix, message);
|
|
310
|
-
}
|
|
362
|
+
void category;
|
|
363
|
+
void eventId;
|
|
364
|
+
void message;
|
|
365
|
+
void data;
|
|
311
366
|
}
|
|
312
367
|
};
|
|
313
368
|
|
|
@@ -363,6 +418,49 @@ export const DEFAULT_KNOCKBACK = {
|
|
|
363
418
|
duration: 300
|
|
364
419
|
};
|
|
365
420
|
|
|
421
|
+
const mergeBattleAiPresetOptions = (
|
|
422
|
+
options: BattleAiOptions | BattleAiLegacyOptions,
|
|
423
|
+
seen: Set<string> = new Set()
|
|
424
|
+
): BattleAiOptions | BattleAiLegacyOptions => {
|
|
425
|
+
if (!options.preset) return options;
|
|
426
|
+
|
|
427
|
+
if (typeof options.preset === "string") {
|
|
428
|
+
if (seen.has(options.preset)) {
|
|
429
|
+
throw new Error(`Circular action battle AI preset: ${options.preset}`);
|
|
430
|
+
}
|
|
431
|
+
seen.add(options.preset);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const preset =
|
|
435
|
+
typeof options.preset === "string"
|
|
436
|
+
? getActionBattleSystems().ai.presets[options.preset]
|
|
437
|
+
: options.preset;
|
|
438
|
+
|
|
439
|
+
if (!preset) {
|
|
440
|
+
throw new Error(`Action battle AI preset not found: ${options.preset}`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const resolvedPreset = mergeBattleAiPresetOptions(preset, seen);
|
|
444
|
+
const { preset: _preset, ...overrides } = options;
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
...resolvedPreset,
|
|
448
|
+
...overrides,
|
|
449
|
+
behavior: {
|
|
450
|
+
...resolvedPreset.behavior,
|
|
451
|
+
...overrides.behavior,
|
|
452
|
+
},
|
|
453
|
+
animations: {
|
|
454
|
+
...resolvedPreset.animations,
|
|
455
|
+
...overrides.animations,
|
|
456
|
+
},
|
|
457
|
+
rewards: {
|
|
458
|
+
...resolvedPreset.rewards,
|
|
459
|
+
...overrides.rewards,
|
|
460
|
+
},
|
|
461
|
+
} as BattleAiOptions | BattleAiLegacyOptions;
|
|
462
|
+
};
|
|
463
|
+
|
|
366
464
|
/**
|
|
367
465
|
* Advanced Battle AI Controller for events
|
|
368
466
|
*
|
|
@@ -407,7 +505,7 @@ export const DEFAULT_KNOCKBACK = {
|
|
|
407
505
|
*/
|
|
408
506
|
export class BattleAi {
|
|
409
507
|
private event: RpgEvent;
|
|
410
|
-
private target:
|
|
508
|
+
private target: ActionBattleEntity | null = null;
|
|
411
509
|
private lastAttackTime: number = 0;
|
|
412
510
|
private updateInterval?: any;
|
|
413
511
|
|
|
@@ -418,6 +516,37 @@ export class BattleAi {
|
|
|
418
516
|
AiDebug.log(category, this.event.id, message, data);
|
|
419
517
|
}
|
|
420
518
|
|
|
519
|
+
private traceLog(category: string, message: string, data?: any): void {
|
|
520
|
+
void category;
|
|
521
|
+
void message;
|
|
522
|
+
void data;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private lockActionUntil(until: number, reason: string, data?: any): void {
|
|
526
|
+
if (until <= this.actionLockedUntil) return;
|
|
527
|
+
this.actionLockedUntil = until;
|
|
528
|
+
this.traceLog("state", "action locked", {
|
|
529
|
+
reason,
|
|
530
|
+
lockedMs: Math.max(0, until - Date.now()),
|
|
531
|
+
...data,
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
private lockForAttack(
|
|
536
|
+
profile: NormalizedActionBattleAttackProfile,
|
|
537
|
+
pattern: AttackPattern
|
|
538
|
+
): void {
|
|
539
|
+
this.isMovingToTarget = false;
|
|
540
|
+
this.event.stopMoveTo();
|
|
541
|
+
this.lockActionUntil(Date.now() + profile.totalDurationMs, "attack", {
|
|
542
|
+
pattern,
|
|
543
|
+
totalDurationMs: profile.totalDurationMs,
|
|
544
|
+
startupMs: profile.startupMs,
|
|
545
|
+
activeMs: profile.activeMs,
|
|
546
|
+
recoveryMs: profile.recoveryMs,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
421
550
|
// State machine
|
|
422
551
|
private state: AiState = AiState.Idle;
|
|
423
552
|
private stateStartTime: number = 0;
|
|
@@ -425,6 +554,8 @@ export class BattleAi {
|
|
|
425
554
|
|
|
426
555
|
// Enemy type and behavior
|
|
427
556
|
private enemyType: EnemyType;
|
|
557
|
+
private faction?: string;
|
|
558
|
+
private targets: ActionBattleTargetSelector = "players";
|
|
428
559
|
private attackCooldown: number = 1000;
|
|
429
560
|
private visionRange: number = 150;
|
|
430
561
|
private attackRange: number = 60;
|
|
@@ -489,11 +620,23 @@ export class BattleAi {
|
|
|
489
620
|
private lastMoveToTime: number = 0;
|
|
490
621
|
private retreatCooldown: number = 600;
|
|
491
622
|
private lastRetreatTime: number = 0;
|
|
623
|
+
private actionLockedUntil: number = 0;
|
|
624
|
+
private lastActionLockTraceTime: number = 0;
|
|
625
|
+
private lastMoveToCooldownTraceTime: number = 0;
|
|
626
|
+
private lastMoveToCooldownTraceSignature: string | null = null;
|
|
627
|
+
private lastTargetMovementSkipTraceTime: number = 0;
|
|
492
628
|
private timers: ReturnType<typeof setTimeout>[] = [];
|
|
493
629
|
private behaviorKey?: string;
|
|
630
|
+
private behaviorTree?: ActionBattleAiTreeNode;
|
|
631
|
+
private aiMemory: ActionBattleAiMemory = {};
|
|
494
632
|
private poise: number = 0;
|
|
495
633
|
private hitstunMs: number = 150;
|
|
496
634
|
private invincibilityMs: number = 250;
|
|
635
|
+
private visionShape?: any;
|
|
636
|
+
private visionSetupRetries: number = 0;
|
|
637
|
+
private maxVisionSetupRetries: number = 20;
|
|
638
|
+
private destroyed: boolean = false;
|
|
639
|
+
private lastNoTargetTraceTime: number = 0;
|
|
497
640
|
|
|
498
641
|
/**
|
|
499
642
|
* Create a new Battle AI Controller
|
|
@@ -525,11 +668,14 @@ export class BattleAi {
|
|
|
525
668
|
event: RpgEventWithBattleAi,
|
|
526
669
|
options: BattleAiOptions | BattleAiLegacyOptions = {}
|
|
527
670
|
) {
|
|
671
|
+
options = mergeBattleAiPresetOptions(options);
|
|
528
672
|
event.battleAi = this;
|
|
529
673
|
this.event = event;
|
|
530
674
|
|
|
531
675
|
// Set enemy type and apply behavior modifiers
|
|
532
676
|
this.enemyType = options.enemyType || EnemyType.Aggressive;
|
|
677
|
+
this.faction = options.faction;
|
|
678
|
+
this.targets = options.targets ?? "players";
|
|
533
679
|
this.behaviorKey = options.behaviorKey ?? this.enemyType;
|
|
534
680
|
this.applyEnemyTypeBehavior(options);
|
|
535
681
|
|
|
@@ -597,9 +743,21 @@ export class BattleAi {
|
|
|
597
743
|
if (options.invincibilityMs !== undefined) {
|
|
598
744
|
this.invincibilityMs = Math.max(0, options.invincibilityMs);
|
|
599
745
|
}
|
|
746
|
+
if (options.tree || options.behaviorTree) {
|
|
747
|
+
this.behaviorTree = defineAiTree(options.tree ?? options.behaviorTree!);
|
|
748
|
+
} else if (options.simpleBehavior) {
|
|
749
|
+
this.behaviorTree = defineAiBehavior(options.simpleBehavior);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (options.attackRange === undefined) {
|
|
753
|
+
const actionRange = this.getCurrentActionRange();
|
|
754
|
+
if (actionRange !== undefined) {
|
|
755
|
+
this.attackRange = actionRange;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
600
758
|
|
|
601
759
|
// Setup AI systems
|
|
602
|
-
this.
|
|
760
|
+
this.scheduleVisionSetup();
|
|
603
761
|
this.startAiBehaviorLoop();
|
|
604
762
|
if (this.patrolWaypoints.length > 0) {
|
|
605
763
|
this.startPatrol();
|
|
@@ -681,14 +839,56 @@ export class BattleAi {
|
|
|
681
839
|
/**
|
|
682
840
|
* Setup vision detection
|
|
683
841
|
*/
|
|
684
|
-
private setupVision() {
|
|
842
|
+
private setupVision(): boolean {
|
|
843
|
+
if (this.visionShape) return true;
|
|
844
|
+
const map = this.event.getCurrentMap?.();
|
|
845
|
+
if (
|
|
846
|
+
map?.physic?.getEntityByUUID &&
|
|
847
|
+
!map.physic.getEntityByUUID(this.event.id)
|
|
848
|
+
) {
|
|
849
|
+
this.traceLog("vision", "physics body not ready", {
|
|
850
|
+
retries: this.visionSetupRetries,
|
|
851
|
+
hasMap: !!map,
|
|
852
|
+
});
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
|
|
685
856
|
const diameter = this.visionRange * 2;
|
|
686
|
-
this.event.attachShape(`vision_${this.event.id}`, {
|
|
857
|
+
const shape = this.event.attachShape(`vision_${this.event.id}`, {
|
|
687
858
|
radius: this.visionRange,
|
|
688
859
|
width: diameter,
|
|
689
860
|
height: diameter,
|
|
690
861
|
angle: 360,
|
|
691
862
|
});
|
|
863
|
+
if (!shape) {
|
|
864
|
+
this.traceLog("vision", "attachShape returned no shape", {
|
|
865
|
+
retries: this.visionSetupRetries,
|
|
866
|
+
visionRange: this.visionRange,
|
|
867
|
+
});
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
this.visionShape = shape;
|
|
871
|
+
this.traceLog("vision", "vision attached", {
|
|
872
|
+
shapeId: (shape as any)?.id,
|
|
873
|
+
visionRange: this.visionRange,
|
|
874
|
+
});
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private scheduleVisionSetup() {
|
|
879
|
+
if (this.destroyed || this.setupVision()) return;
|
|
880
|
+
if (this.visionSetupRetries >= this.maxVisionSetupRetries) {
|
|
881
|
+
this.traceLog("vision", "vision setup gave up", {
|
|
882
|
+
retries: this.visionSetupRetries,
|
|
883
|
+
});
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
this.visionSetupRetries++;
|
|
888
|
+
this.schedule(() => {
|
|
889
|
+
if (this.destroyed || !this.event.getCurrentMap()) return;
|
|
890
|
+
this.scheduleVisionSetup();
|
|
891
|
+
}, 50);
|
|
692
892
|
}
|
|
693
893
|
|
|
694
894
|
/**
|
|
@@ -723,10 +923,19 @@ export class BattleAi {
|
|
|
723
923
|
|
|
724
924
|
if (!validTransitions[this.state].includes(newState)) {
|
|
725
925
|
this.debugLog('state', `INVALID transition ${this.state} -> ${newState}`);
|
|
926
|
+
this.traceLog("state", "invalid transition", {
|
|
927
|
+
from: this.state,
|
|
928
|
+
to: newState,
|
|
929
|
+
});
|
|
726
930
|
return;
|
|
727
931
|
}
|
|
728
932
|
|
|
729
933
|
this.debugLog('state', `STATE change: ${this.state} -> ${newState}`);
|
|
934
|
+
this.traceLog("state", "state change", {
|
|
935
|
+
from: this.state,
|
|
936
|
+
to: newState,
|
|
937
|
+
targetId: this.target?.id,
|
|
938
|
+
});
|
|
730
939
|
this.state = newState;
|
|
731
940
|
this.stateStartTime = Date.now();
|
|
732
941
|
|
|
@@ -759,6 +968,14 @@ export class BattleAi {
|
|
|
759
968
|
private updateAiBehavior() {
|
|
760
969
|
const currentTime = Date.now();
|
|
761
970
|
|
|
971
|
+
if (this.target && this.isTargetDefeated(this.target)) {
|
|
972
|
+
this.debugLog('combat', `Target ${this.target.id} is defeated, returning to idle`);
|
|
973
|
+
this.clearTarget();
|
|
974
|
+
this.changeState(AiState.Idle);
|
|
975
|
+
this.checkDamageTaken();
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
762
979
|
// Update group behavior
|
|
763
980
|
if (this.groupBehavior) {
|
|
764
981
|
this.updateGroupBehavior();
|
|
@@ -772,6 +989,20 @@ export class BattleAi {
|
|
|
772
989
|
return;
|
|
773
990
|
}
|
|
774
991
|
|
|
992
|
+
if (currentTime < this.actionLockedUntil) {
|
|
993
|
+
if (this.target) this.faceTarget();
|
|
994
|
+
if (currentTime - this.lastActionLockTraceTime > 250) {
|
|
995
|
+
this.lastActionLockTraceTime = currentTime;
|
|
996
|
+
this.traceLog("state", "waiting action recovery", {
|
|
997
|
+
remainingMs: this.actionLockedUntil - currentTime,
|
|
998
|
+
state: this.state,
|
|
999
|
+
targetId: this.target?.id,
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
this.checkDamageTaken();
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
775
1006
|
// Berserker: faster attacks when HP is low
|
|
776
1007
|
if (this.enemyType === EnemyType.Berserker && this.event.param[MAXHP]) {
|
|
777
1008
|
const hpPercent = this.event.hp / this.event.param[MAXHP];
|
|
@@ -784,7 +1015,18 @@ export class BattleAi {
|
|
|
784
1015
|
this.updateBehavior(currentTime);
|
|
785
1016
|
}
|
|
786
1017
|
|
|
787
|
-
this.
|
|
1018
|
+
if (!this.target && this.state === AiState.Idle) {
|
|
1019
|
+
const target = this.findNearestTarget();
|
|
1020
|
+
if (target) {
|
|
1021
|
+
this.engageTarget(target);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const customBehaviorHandled = this.applyCustomBehavior(currentTime);
|
|
1026
|
+
if (customBehaviorHandled) {
|
|
1027
|
+
this.checkDamageTaken();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
788
1030
|
|
|
789
1031
|
// State-specific behavior
|
|
790
1032
|
switch (this.state) {
|
|
@@ -810,6 +1052,12 @@ export class BattleAi {
|
|
|
810
1052
|
* Update idle behavior (patrolling)
|
|
811
1053
|
*/
|
|
812
1054
|
private updateIdleBehavior() {
|
|
1055
|
+
const target = this.findNearestTarget();
|
|
1056
|
+
if (target) {
|
|
1057
|
+
this.engageTarget(target);
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
813
1061
|
if (this.patrolWaypoints.length > 0) {
|
|
814
1062
|
const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
|
|
815
1063
|
const distance = this.getDistance(this.event, {
|
|
@@ -832,8 +1080,28 @@ export class BattleAi {
|
|
|
832
1080
|
this.faceTarget();
|
|
833
1081
|
|
|
834
1082
|
const distance = this.getDistance(this.event, this.target);
|
|
1083
|
+
this.traceLog("movement", "alert update", {
|
|
1084
|
+
targetId: this.target.id,
|
|
1085
|
+
distance,
|
|
1086
|
+
attackRange: this.attackRange,
|
|
1087
|
+
visionRange: this.visionRange,
|
|
1088
|
+
isMovingToTarget: this.isMovingToTarget,
|
|
1089
|
+
});
|
|
835
1090
|
if (distance <= this.attackRange * 1.5) {
|
|
1091
|
+
if (this.isMovingToTarget) {
|
|
1092
|
+
this.isMovingToTarget = false;
|
|
1093
|
+
this.event.stopMoveTo();
|
|
1094
|
+
}
|
|
836
1095
|
this.changeState(AiState.Combat);
|
|
1096
|
+
} else if (distance <= this.visionRange * 1.5) {
|
|
1097
|
+
if (!this.isMovingToTarget) {
|
|
1098
|
+
this.debugLog('movement', `Alert approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
1099
|
+
this.requestTargetMovement();
|
|
1100
|
+
}
|
|
1101
|
+
} else {
|
|
1102
|
+
this.debugLog('combat', `Alert target out of range (dist=${distance.toFixed(1)})`);
|
|
1103
|
+
this.clearTarget();
|
|
1104
|
+
this.changeState(AiState.Idle);
|
|
837
1105
|
}
|
|
838
1106
|
} else {
|
|
839
1107
|
this.changeState(AiState.Idle);
|
|
@@ -851,13 +1119,20 @@ export class BattleAi {
|
|
|
851
1119
|
}
|
|
852
1120
|
|
|
853
1121
|
const distance = this.getDistance(this.event, this.target);
|
|
1122
|
+
this.traceLog("combat", "combat update", {
|
|
1123
|
+
targetId: this.target.id,
|
|
1124
|
+
distance,
|
|
1125
|
+
attackRange: this.attackRange,
|
|
1126
|
+
visionRange: this.visionRange,
|
|
1127
|
+
isMovingToTarget: this.isMovingToTarget,
|
|
1128
|
+
behaviorEnabled: this.behaviorEnabled,
|
|
1129
|
+
behaviorMode: this.behaviorMode,
|
|
1130
|
+
});
|
|
854
1131
|
|
|
855
1132
|
// Check if target is still in range
|
|
856
1133
|
if (distance > this.visionRange * 1.5) {
|
|
857
1134
|
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();
|
|
1135
|
+
this.clearTarget();
|
|
861
1136
|
this.changeState(AiState.Idle);
|
|
862
1137
|
return;
|
|
863
1138
|
}
|
|
@@ -907,8 +1182,7 @@ export class BattleAi {
|
|
|
907
1182
|
} else if (distance > this.attackRange) {
|
|
908
1183
|
if (!this.isMovingToTarget) {
|
|
909
1184
|
this.debugLog('movement', `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
910
|
-
this.
|
|
911
|
-
this.requestMoveTo(this.target);
|
|
1185
|
+
this.requestTargetMovement();
|
|
912
1186
|
}
|
|
913
1187
|
} else {
|
|
914
1188
|
if (this.isMovingToTarget) {
|
|
@@ -921,8 +1195,7 @@ export class BattleAi {
|
|
|
921
1195
|
if (distance > this.attackRange) {
|
|
922
1196
|
if (!this.isMovingToTarget) {
|
|
923
1197
|
this.debugLog('movement', `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
924
|
-
this.
|
|
925
|
-
this.requestMoveTo(this.target);
|
|
1198
|
+
this.requestTargetMovement();
|
|
926
1199
|
}
|
|
927
1200
|
} else {
|
|
928
1201
|
if (this.isMovingToTarget) {
|
|
@@ -1068,11 +1341,10 @@ export class BattleAi {
|
|
|
1068
1341
|
if (!this.target) return;
|
|
1069
1342
|
const profile = this.getAttackProfile(AttackPattern.Melee);
|
|
1070
1343
|
|
|
1071
|
-
this.faceTarget();
|
|
1344
|
+
this.faceTarget({ force: true });
|
|
1345
|
+
this.lockForAttack(profile, AttackPattern.Melee);
|
|
1072
1346
|
this.telegraphAttack(profile);
|
|
1073
|
-
|
|
1074
|
-
target: this.target,
|
|
1075
|
-
});
|
|
1347
|
+
this.playAttackVisual(profile, AttackPattern.Melee);
|
|
1076
1348
|
|
|
1077
1349
|
this.scheduleAttackStartup(profile, () => {
|
|
1078
1350
|
this.executeMeleeAttack(profile, AttackPattern.Melee);
|
|
@@ -1083,24 +1355,43 @@ export class BattleAi {
|
|
|
1083
1355
|
profile: NormalizedActionBattleAttackProfile,
|
|
1084
1356
|
pattern: AttackPattern
|
|
1085
1357
|
) {
|
|
1086
|
-
if (!this.target) return;
|
|
1358
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
1087
1359
|
this.debugLog('attack', `Applying ${pattern} hit`);
|
|
1088
1360
|
|
|
1089
|
-
// Use skill if available
|
|
1090
1361
|
if (this.attackSkill) {
|
|
1362
|
+
const resolvedSkill = this.resolveUsable(this.attackSkill);
|
|
1091
1363
|
try {
|
|
1092
|
-
|
|
1093
|
-
|
|
1364
|
+
executeActionBattleUse({
|
|
1365
|
+
attacker: this.event,
|
|
1094
1366
|
target: this.target,
|
|
1367
|
+
usable: resolvedSkill,
|
|
1368
|
+
skill: resolvedSkill,
|
|
1369
|
+
pattern,
|
|
1370
|
+
profile,
|
|
1095
1371
|
});
|
|
1096
|
-
this.event.useSkill(this.attackSkill, this.target);
|
|
1097
1372
|
} catch (e) {
|
|
1098
1373
|
// Skill failed (no SP, etc.) - fall back to basic attack
|
|
1099
1374
|
this.performBasicHitbox(profile, pattern);
|
|
1100
1375
|
}
|
|
1101
|
-
|
|
1102
|
-
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const weapon = resolveActionBattleWeapon(this.event);
|
|
1380
|
+
if (
|
|
1381
|
+
weapon &&
|
|
1382
|
+
executeActionBattleUse({
|
|
1383
|
+
attacker: this.event,
|
|
1384
|
+
target: this.target,
|
|
1385
|
+
usable: weapon,
|
|
1386
|
+
weapon,
|
|
1387
|
+
pattern,
|
|
1388
|
+
profile,
|
|
1389
|
+
})
|
|
1390
|
+
) {
|
|
1391
|
+
return;
|
|
1103
1392
|
}
|
|
1393
|
+
|
|
1394
|
+
this.performBasicHitbox(profile, pattern);
|
|
1104
1395
|
}
|
|
1105
1396
|
|
|
1106
1397
|
/**
|
|
@@ -1112,7 +1403,21 @@ export class BattleAi {
|
|
|
1112
1403
|
),
|
|
1113
1404
|
pattern: AttackPattern = AttackPattern.Melee
|
|
1114
1405
|
) {
|
|
1115
|
-
if (!this.target) return;
|
|
1406
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
1407
|
+
|
|
1408
|
+
const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
|
|
1409
|
+
runActionBattleActiveHitbox(
|
|
1410
|
+
{ ...profile, startupMs: 0 },
|
|
1411
|
+
() => this.resolveBasicHitboxes(),
|
|
1412
|
+
(hitboxes) => {
|
|
1413
|
+
this.processHitboxHits(hitboxes, hitTracker, profile, pattern);
|
|
1414
|
+
},
|
|
1415
|
+
(scheduled, delay) => this.schedule(scheduled, delay)
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
private resolveBasicHitboxes(): ActionBattleHitbox[] {
|
|
1420
|
+
if (!this.target || this.isTargetDefeated(this.target)) return [];
|
|
1116
1421
|
|
|
1117
1422
|
const eventX = this.event.x();
|
|
1118
1423
|
const eventY = this.event.y();
|
|
@@ -1120,30 +1425,51 @@ export class BattleAi {
|
|
|
1120
1425
|
const dy = this.target.y() - eventY;
|
|
1121
1426
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1122
1427
|
|
|
1123
|
-
if (dist === 0) return;
|
|
1428
|
+
if (dist === 0) return [];
|
|
1124
1429
|
|
|
1125
1430
|
const dirX = dx / dist;
|
|
1126
1431
|
const dirY = dy / dist;
|
|
1127
1432
|
|
|
1128
|
-
|
|
1433
|
+
return [{
|
|
1129
1434
|
x: eventX + dirX * 30,
|
|
1130
1435
|
y: eventY + dirY * 30,
|
|
1131
1436
|
width: 40,
|
|
1132
1437
|
height: 40,
|
|
1133
1438
|
}];
|
|
1439
|
+
}
|
|
1134
1440
|
|
|
1441
|
+
private queryHitboxCandidates(hitboxes: ActionBattleHitbox[]) {
|
|
1135
1442
|
const map = this.event.getCurrentMap();
|
|
1136
|
-
map
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
}
|
|
1146
|
-
}
|
|
1443
|
+
if (!map || typeof (map as any).queryHitbox !== "function") return [];
|
|
1444
|
+
|
|
1445
|
+
const candidates = new Map<string, ActionBattleEntity>();
|
|
1446
|
+
for (const hitbox of hitboxes) {
|
|
1447
|
+
for (const hit of (map as any).queryHitbox(hitbox, {
|
|
1448
|
+
excludeIds: [this.event.id],
|
|
1449
|
+
kinds: ["players", "events"],
|
|
1450
|
+
})) {
|
|
1451
|
+
if (hit?.id) candidates.set(hit.id, hit);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return Array.from(candidates.values());
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
private processHitboxHits(
|
|
1458
|
+
hitboxes: ActionBattleHitbox[],
|
|
1459
|
+
hitTracker: ActionBattleHitTracker,
|
|
1460
|
+
profile: NormalizedActionBattleAttackProfile,
|
|
1461
|
+
pattern: AttackPattern
|
|
1462
|
+
) {
|
|
1463
|
+
for (const hit of this.queryHitboxCandidates(hitboxes)) {
|
|
1464
|
+
if (
|
|
1465
|
+
hit !== this.event &&
|
|
1466
|
+
this.canTarget(hit) &&
|
|
1467
|
+
!this.isTargetDefeated(hit) &&
|
|
1468
|
+
hitTracker.tryHit(hit)
|
|
1469
|
+
) {
|
|
1470
|
+
this.applyHit(hit, undefined, profile, pattern);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1147
1473
|
}
|
|
1148
1474
|
|
|
1149
1475
|
/**
|
|
@@ -1175,13 +1501,24 @@ export class BattleAi {
|
|
|
1175
1501
|
* ```
|
|
1176
1502
|
*/
|
|
1177
1503
|
private applyHit(
|
|
1178
|
-
target:
|
|
1504
|
+
target: ActionBattleEntity,
|
|
1179
1505
|
hooks?: ApplyHitHooks,
|
|
1180
1506
|
profile: NormalizedActionBattleAttackProfile = this.getAttackProfile(
|
|
1181
1507
|
AttackPattern.Melee
|
|
1182
1508
|
),
|
|
1183
1509
|
pattern: AttackPattern = AttackPattern.Melee
|
|
1184
1510
|
): HitResult {
|
|
1511
|
+
if (this.isTargetDefeated(target)) {
|
|
1512
|
+
return {
|
|
1513
|
+
damage: 0,
|
|
1514
|
+
knockbackForce: 0,
|
|
1515
|
+
knockbackDuration: 0,
|
|
1516
|
+
defeated: true,
|
|
1517
|
+
attacker: this.event,
|
|
1518
|
+
target
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1185
1522
|
if (isActionBattleEntityInvincible(target)) {
|
|
1186
1523
|
return {
|
|
1187
1524
|
damage: 0,
|
|
@@ -1219,13 +1556,16 @@ export class BattleAi {
|
|
|
1219
1556
|
}
|
|
1220
1557
|
|
|
1221
1558
|
// Visual feedback
|
|
1222
|
-
target
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1559
|
+
withActionBattleAnimationUnlocked(target, () => {
|
|
1560
|
+
emitActionBattleClientVisual({
|
|
1561
|
+
moment: "hurt",
|
|
1562
|
+
entity: this.event,
|
|
1563
|
+
target,
|
|
1564
|
+
attacker: this.event,
|
|
1565
|
+
damage: hitResult.damage,
|
|
1566
|
+
result: hitResult,
|
|
1567
|
+
});
|
|
1227
1568
|
});
|
|
1228
|
-
target.showHit(`-${hitResult.damage}`);
|
|
1229
1569
|
setActionBattleInvincibility(
|
|
1230
1570
|
target,
|
|
1231
1571
|
profile.reaction.invincibilityMs
|
|
@@ -1251,6 +1591,16 @@ export class BattleAi {
|
|
|
1251
1591
|
hooks.onAfterHit(hitResult);
|
|
1252
1592
|
}
|
|
1253
1593
|
|
|
1594
|
+
const targetAi = (target as any).battleAi as BattleAi | undefined;
|
|
1595
|
+
if (targetAi) {
|
|
1596
|
+
targetAi.handleDamage(this.event, {
|
|
1597
|
+
damage: hitResult.damage,
|
|
1598
|
+
defeated: hitResult.defeated,
|
|
1599
|
+
raw: undefined,
|
|
1600
|
+
reaction: profile.reaction,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1254
1604
|
return hitResult;
|
|
1255
1605
|
}
|
|
1256
1606
|
|
|
@@ -1294,11 +1644,10 @@ export class BattleAi {
|
|
|
1294
1644
|
|
|
1295
1645
|
this.comboCount++;
|
|
1296
1646
|
const profile = this.getAttackProfile(AttackPattern.Combo);
|
|
1297
|
-
this.faceTarget();
|
|
1647
|
+
this.faceTarget({ force: true });
|
|
1648
|
+
this.lockForAttack(profile, AttackPattern.Combo);
|
|
1298
1649
|
this.telegraphAttack(profile);
|
|
1299
|
-
|
|
1300
|
-
target: this.target,
|
|
1301
|
-
});
|
|
1650
|
+
this.playAttackVisual(profile, AttackPattern.Combo);
|
|
1302
1651
|
this.scheduleAttackStartup(profile, () => {
|
|
1303
1652
|
this.executeMeleeAttack(profile, AttackPattern.Combo);
|
|
1304
1653
|
});
|
|
@@ -1324,17 +1673,10 @@ export class BattleAi {
|
|
|
1324
1673
|
const profile = this.getAttackProfile(AttackPattern.Charged);
|
|
1325
1674
|
|
|
1326
1675
|
this.chargingAttack = true;
|
|
1327
|
-
this.faceTarget();
|
|
1676
|
+
this.faceTarget({ force: true });
|
|
1677
|
+
this.lockForAttack(profile, AttackPattern.Charged);
|
|
1328
1678
|
this.telegraphAttack(profile);
|
|
1329
|
-
|
|
1330
|
-
"attack",
|
|
1331
|
-
this.event,
|
|
1332
|
-
this.animations,
|
|
1333
|
-
{
|
|
1334
|
-
target: this.target,
|
|
1335
|
-
},
|
|
1336
|
-
{ repeat: 2 }
|
|
1337
|
-
);
|
|
1679
|
+
this.playAttackVisual(profile, AttackPattern.Charged, { repeat: 2 });
|
|
1338
1680
|
|
|
1339
1681
|
this.scheduleAttackStartup(profile, () => {
|
|
1340
1682
|
if (!this.target || this.state !== AiState.Combat) {
|
|
@@ -1353,10 +1695,9 @@ export class BattleAi {
|
|
|
1353
1695
|
*/
|
|
1354
1696
|
private performZoneAttack() {
|
|
1355
1697
|
const profile = this.getAttackProfile(AttackPattern.Zone);
|
|
1698
|
+
this.lockForAttack(profile, AttackPattern.Zone);
|
|
1356
1699
|
this.telegraphAttack(profile);
|
|
1357
|
-
|
|
1358
|
-
target: this.target ?? undefined,
|
|
1359
|
-
});
|
|
1700
|
+
this.playAttackVisual(profile, AttackPattern.Zone);
|
|
1360
1701
|
|
|
1361
1702
|
const eventX = this.event.x();
|
|
1362
1703
|
const eventY = this.event.y();
|
|
@@ -1376,18 +1717,20 @@ export class BattleAi {
|
|
|
1376
1717
|
});
|
|
1377
1718
|
|
|
1378
1719
|
this.scheduleAttackStartup(profile, () => {
|
|
1379
|
-
const
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1720
|
+
const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
|
|
1721
|
+
runActionBattleActiveHitbox(
|
|
1722
|
+
{ ...profile, startupMs: 0 },
|
|
1723
|
+
() => hitboxes,
|
|
1724
|
+
(activeHitboxes) => {
|
|
1725
|
+
this.processHitboxHits(
|
|
1726
|
+
activeHitboxes,
|
|
1727
|
+
hitTracker,
|
|
1728
|
+
profile,
|
|
1729
|
+
AttackPattern.Zone
|
|
1730
|
+
);
|
|
1389
1731
|
},
|
|
1390
|
-
|
|
1732
|
+
(scheduled, delay) => this.schedule(scheduled, delay)
|
|
1733
|
+
);
|
|
1391
1734
|
});
|
|
1392
1735
|
}
|
|
1393
1736
|
|
|
@@ -1395,7 +1738,7 @@ export class BattleAi {
|
|
|
1395
1738
|
* Perform dash attack
|
|
1396
1739
|
*/
|
|
1397
1740
|
private performDashAttack() {
|
|
1398
|
-
if (!this.target) return;
|
|
1741
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
1399
1742
|
const profile = this.getAttackProfile(AttackPattern.DashAttack);
|
|
1400
1743
|
|
|
1401
1744
|
const dx = this.target.x() - this.event.x();
|
|
@@ -1407,12 +1750,14 @@ export class BattleAi {
|
|
|
1407
1750
|
const dirX = dx / dist;
|
|
1408
1751
|
const dirY = dy / dist;
|
|
1409
1752
|
|
|
1410
|
-
this.faceTarget();
|
|
1753
|
+
this.faceTarget({ force: true });
|
|
1754
|
+
this.lockForAttack(profile, AttackPattern.DashAttack);
|
|
1411
1755
|
this.telegraphAttack(profile);
|
|
1756
|
+
this.playAttackVisual(profile, AttackPattern.DashAttack);
|
|
1412
1757
|
|
|
1413
1758
|
this.scheduleAttackStartup(profile, () => {
|
|
1414
1759
|
if (!this.target || this.state !== AiState.Combat) return;
|
|
1415
|
-
this.event
|
|
1760
|
+
safeActionBattleDash(this.event, { x: dirX, y: dirY }, 10, 200);
|
|
1416
1761
|
this.schedule(() => {
|
|
1417
1762
|
if (!this.target || this.state !== AiState.Combat) return;
|
|
1418
1763
|
this.executeMeleeAttack(profile, AttackPattern.DashAttack);
|
|
@@ -1428,6 +1773,28 @@ export class BattleAi {
|
|
|
1428
1773
|
] ?? this.attackProfiles.melee;
|
|
1429
1774
|
}
|
|
1430
1775
|
|
|
1776
|
+
private playAttackVisual(
|
|
1777
|
+
profile: NormalizedActionBattleAttackProfile,
|
|
1778
|
+
pattern: AttackPattern,
|
|
1779
|
+
animationDefaults?: { animationName?: string; repeat?: number }
|
|
1780
|
+
): void {
|
|
1781
|
+
const moment =
|
|
1782
|
+
profile.animationKey === "castSkill" || profile.animationKey === "castSpell"
|
|
1783
|
+
? "castSkill"
|
|
1784
|
+
: "attack";
|
|
1785
|
+
|
|
1786
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
1787
|
+
emitActionBattleClientVisual({
|
|
1788
|
+
moment,
|
|
1789
|
+
entity: this.event,
|
|
1790
|
+
target: this.target ?? undefined,
|
|
1791
|
+
pattern,
|
|
1792
|
+
animations: this.animations,
|
|
1793
|
+
animationDefaults,
|
|
1794
|
+
});
|
|
1795
|
+
});
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1431
1798
|
private telegraphAttack(profile: NormalizedActionBattleAttackProfile) {
|
|
1432
1799
|
if (profile.startupMs <= 0) return;
|
|
1433
1800
|
this.event.flash({
|
|
@@ -1455,7 +1822,7 @@ export class BattleAi {
|
|
|
1455
1822
|
* 2. When near diagonal, require significant difference to change
|
|
1456
1823
|
* 3. Only change if direction is clearly wrong (opposite)
|
|
1457
1824
|
*/
|
|
1458
|
-
private faceTarget() {
|
|
1825
|
+
private faceTarget(options: { force?: boolean } = {}) {
|
|
1459
1826
|
if (!this.target) return;
|
|
1460
1827
|
|
|
1461
1828
|
const dx = this.target.x() - this.event.x();
|
|
@@ -1464,11 +1831,12 @@ export class BattleAi {
|
|
|
1464
1831
|
const absY = Math.abs(dy);
|
|
1465
1832
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1466
1833
|
|
|
1467
|
-
//
|
|
1468
|
-
//
|
|
1469
|
-
|
|
1834
|
+
// Avoid undefined facing only when both entities are effectively stacked.
|
|
1835
|
+
// A larger dead-zone makes close melee targets keep stale directions after
|
|
1836
|
+
// knockback, so keep this threshold intentionally tiny.
|
|
1837
|
+
const minDistanceForDirectionChange = 4;
|
|
1470
1838
|
if (this.lastFacingDirection && distance < minDistanceForDirectionChange) {
|
|
1471
|
-
return;
|
|
1839
|
+
return;
|
|
1472
1840
|
}
|
|
1473
1841
|
|
|
1474
1842
|
// Calculate the "ideal" direction
|
|
@@ -1500,7 +1868,11 @@ export class BattleAi {
|
|
|
1500
1868
|
}
|
|
1501
1869
|
|
|
1502
1870
|
this.lastFacingDirection = newDirection;
|
|
1503
|
-
|
|
1871
|
+
if (options.force) {
|
|
1872
|
+
applyActionBattleAttackDirection(this.event, newDirection as any);
|
|
1873
|
+
} else {
|
|
1874
|
+
this.event.changeDirection(newDirection as any);
|
|
1875
|
+
}
|
|
1504
1876
|
}
|
|
1505
1877
|
|
|
1506
1878
|
/**
|
|
@@ -1531,7 +1903,9 @@ export class BattleAi {
|
|
|
1531
1903
|
const side = Math.random() > 0.5 ? 1 : -1;
|
|
1532
1904
|
|
|
1533
1905
|
this.debugLog('dodge', `Dodging (dir=${side > 0 ? 'right' : 'left'})`);
|
|
1534
|
-
this.event
|
|
1906
|
+
if (!safeActionBattleDash(this.event, { x: dodgeDirX * side, y: dodgeDirY * side }, 12, 300)) {
|
|
1907
|
+
return false;
|
|
1908
|
+
}
|
|
1535
1909
|
this.lastDodgeTime = currentTime;
|
|
1536
1910
|
|
|
1537
1911
|
// Counter-attack for defensive types
|
|
@@ -1570,8 +1944,8 @@ export class BattleAi {
|
|
|
1570
1944
|
if (dist === 0) return;
|
|
1571
1945
|
|
|
1572
1946
|
const fleeTarget = {
|
|
1573
|
-
x:
|
|
1574
|
-
y:
|
|
1947
|
+
x: this.event.x() + (dx / dist) * 200,
|
|
1948
|
+
y: this.event.y() + (dy / dist) * 200
|
|
1575
1949
|
};
|
|
1576
1950
|
|
|
1577
1951
|
this.requestMoveTo(fleeTarget as any);
|
|
@@ -1593,7 +1967,9 @@ export class BattleAi {
|
|
|
1593
1967
|
|
|
1594
1968
|
if (dist === 0) return;
|
|
1595
1969
|
|
|
1596
|
-
this.event
|
|
1970
|
+
if (!safeActionBattleDash(this.event, { x: dx / dist, y: dy / dist }, 8, 200)) {
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1597
1973
|
this.lastRetreatTime = currentTime;
|
|
1598
1974
|
}
|
|
1599
1975
|
|
|
@@ -1616,7 +1992,7 @@ export class BattleAi {
|
|
|
1616
1992
|
if (this.patrolWaypoints.length === 0) return;
|
|
1617
1993
|
|
|
1618
1994
|
const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
|
|
1619
|
-
this.requestMoveTo({ x:
|
|
1995
|
+
this.requestMoveTo({ x: waypoint.x, y: waypoint.y } as any);
|
|
1620
1996
|
}
|
|
1621
1997
|
|
|
1622
1998
|
/**
|
|
@@ -1688,16 +2064,37 @@ export class BattleAi {
|
|
|
1688
2064
|
);
|
|
1689
2065
|
|
|
1690
2066
|
if (distanceToFormation > 20) {
|
|
1691
|
-
this.requestMoveTo({ x:
|
|
2067
|
+
this.requestMoveTo({ x: formationX, y: formationY } as any);
|
|
1692
2068
|
}
|
|
1693
2069
|
}
|
|
1694
2070
|
|
|
1695
2071
|
/**
|
|
1696
2072
|
* Handle player entering vision
|
|
1697
2073
|
*/
|
|
1698
|
-
onDetectInShape(
|
|
1699
|
-
|
|
1700
|
-
this.target
|
|
2074
|
+
onDetectInShape(target: ActionBattleEntity, shape: any) {
|
|
2075
|
+
const canTarget = this.canTarget(target);
|
|
2076
|
+
const defeated = this.isTargetDefeated(target);
|
|
2077
|
+
this.traceLog("vision", "detect in shape", {
|
|
2078
|
+
targetId: target?.id,
|
|
2079
|
+
shapeId: shape?.id,
|
|
2080
|
+
canTarget,
|
|
2081
|
+
defeated,
|
|
2082
|
+
state: this.state,
|
|
2083
|
+
targetHp: (target as any)?.hp,
|
|
2084
|
+
});
|
|
2085
|
+
if (!canTarget || defeated) return;
|
|
2086
|
+
this.debugLog('vision', `Target ${target.id} entered vision (state=${this.state})`);
|
|
2087
|
+
this.engageTarget(target);
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
private engageTarget(target: ActionBattleEntity) {
|
|
2091
|
+
this.traceLog("target", "engage target", {
|
|
2092
|
+
targetId: target.id,
|
|
2093
|
+
previousTargetId: this.target?.id,
|
|
2094
|
+
state: this.state,
|
|
2095
|
+
distance: this.getDistance(this.event, target),
|
|
2096
|
+
});
|
|
2097
|
+
this.target = target;
|
|
1701
2098
|
|
|
1702
2099
|
if (this.state === AiState.Idle) {
|
|
1703
2100
|
this.changeState(AiState.Alert);
|
|
@@ -1709,12 +2106,16 @@ export class BattleAi {
|
|
|
1709
2106
|
/**
|
|
1710
2107
|
* Handle player leaving vision
|
|
1711
2108
|
*/
|
|
1712
|
-
onDetectOutShape(
|
|
1713
|
-
this.debugLog('vision', `
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
this.
|
|
2109
|
+
onDetectOutShape(target: ActionBattleEntity, shape: any) {
|
|
2110
|
+
this.debugLog('vision', `Target ${target.id} left vision (wasTarget=${this.target === target})`);
|
|
2111
|
+
this.traceLog("vision", "detect out shape", {
|
|
2112
|
+
targetId: target?.id,
|
|
2113
|
+
shapeId: shape?.id,
|
|
2114
|
+
wasTarget: this.target === target,
|
|
2115
|
+
state: this.state,
|
|
2116
|
+
});
|
|
2117
|
+
if (this.target === target) {
|
|
2118
|
+
this.clearTarget();
|
|
1718
2119
|
this.changeState(AiState.Idle);
|
|
1719
2120
|
}
|
|
1720
2121
|
}
|
|
@@ -1725,7 +2126,7 @@ export class BattleAi {
|
|
|
1725
2126
|
* This triggers state changes like stun and flee check.
|
|
1726
2127
|
* The actual damage is applied externally via RPGJS API.
|
|
1727
2128
|
*/
|
|
1728
|
-
takeDamage(attacker:
|
|
2129
|
+
takeDamage(attacker: ActionBattleEntity): boolean {
|
|
1729
2130
|
if (this.defeated) return true;
|
|
1730
2131
|
// Apply damage using RPGJS system
|
|
1731
2132
|
const raw = this.event.applyDamage(attacker);
|
|
@@ -1737,34 +2138,75 @@ export class BattleAi {
|
|
|
1737
2138
|
}
|
|
1738
2139
|
|
|
1739
2140
|
handleDamage(
|
|
1740
|
-
attacker:
|
|
2141
|
+
attacker: ActionBattleEntity,
|
|
1741
2142
|
damageResult: ActionBattleDamageResult & {
|
|
1742
2143
|
reaction?: NormalizedActionBattleHitReactionProfile;
|
|
1743
2144
|
}
|
|
1744
2145
|
): boolean {
|
|
1745
2146
|
if (this.defeated) return true;
|
|
1746
|
-
const damage = damageResult.damage;
|
|
2147
|
+
const damage = Number.isFinite(damageResult.damage) ? damageResult.damage : 0;
|
|
1747
2148
|
this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
|
|
2149
|
+
const canRetaliate = attacker ? this.canTarget(attacker) : false;
|
|
2150
|
+
const attackerDefeated = this.isTargetDefeated(attacker);
|
|
2151
|
+
this.traceLog("damage", "handle damage", {
|
|
2152
|
+
attackerId: attacker?.id,
|
|
2153
|
+
damage,
|
|
2154
|
+
defeated: damageResult.defeated,
|
|
2155
|
+
eventHp: this.event.hp,
|
|
2156
|
+
maxHp: this.event.param[MAXHP],
|
|
2157
|
+
state: this.state,
|
|
2158
|
+
canRetaliate,
|
|
2159
|
+
attackerDefeated,
|
|
2160
|
+
currentTargetId: this.target?.id,
|
|
2161
|
+
});
|
|
1748
2162
|
|
|
1749
2163
|
// Visual feedback
|
|
1750
|
-
this.event
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
2164
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
2165
|
+
emitActionBattleClientVisual({
|
|
2166
|
+
moment: "hurt",
|
|
2167
|
+
entity: this.event,
|
|
2168
|
+
target: this.event,
|
|
2169
|
+
attacker,
|
|
2170
|
+
damage,
|
|
2171
|
+
defeated: damageResult.defeated,
|
|
2172
|
+
result: damageResult,
|
|
2173
|
+
animations: this.animations,
|
|
2174
|
+
});
|
|
1759
2175
|
});
|
|
1760
2176
|
|
|
1761
2177
|
// Track damage
|
|
1762
2178
|
this.recentDamageTaken += damage;
|
|
1763
2179
|
|
|
2180
|
+
if (
|
|
2181
|
+
attacker &&
|
|
2182
|
+
this.canTarget(attacker) &&
|
|
2183
|
+
!this.isTargetDefeated(attacker) &&
|
|
2184
|
+
this.state !== AiState.Flee
|
|
2185
|
+
) {
|
|
2186
|
+
this.traceLog("target", "retaliate against attacker", {
|
|
2187
|
+
attackerId: attacker.id,
|
|
2188
|
+
previousTargetId: this.target?.id,
|
|
2189
|
+
state: this.state,
|
|
2190
|
+
});
|
|
2191
|
+
this.target = attacker;
|
|
2192
|
+
if (this.state === AiState.Idle || this.state === AiState.Alert) {
|
|
2193
|
+
this.isMovingToTarget = false;
|
|
2194
|
+
this.changeState(AiState.Combat);
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
1764
2198
|
const reaction = damageResult.reaction;
|
|
1765
2199
|
const staggerPower = reaction?.staggerPower ?? damage;
|
|
1766
2200
|
const hitstunMs = reaction?.hitstunMs ?? this.hitstunMs;
|
|
1767
|
-
const shouldStun =
|
|
2201
|
+
const shouldStun =
|
|
2202
|
+
(damage > 0 || (reaction?.staggerPower ?? 0) > 0) &&
|
|
2203
|
+
staggerPower >= this.poise &&
|
|
2204
|
+
hitstunMs > 0;
|
|
2205
|
+
this.lockActionUntil(Date.now() + Math.max(220, hitstunMs + 120), "damage recovery", {
|
|
2206
|
+
attackerId: attacker?.id,
|
|
2207
|
+
damage,
|
|
2208
|
+
hitstunMs,
|
|
2209
|
+
});
|
|
1768
2210
|
setActionBattleInvincibility(
|
|
1769
2211
|
this.event,
|
|
1770
2212
|
reaction?.invincibilityMs ?? this.invincibilityMs
|
|
@@ -1794,7 +2236,7 @@ export class BattleAi {
|
|
|
1794
2236
|
* Stops all movements, cleans up resources, calls the onDefeated hook,
|
|
1795
2237
|
* and removes the event from the map.
|
|
1796
2238
|
*/
|
|
1797
|
-
private kill(attacker?:
|
|
2239
|
+
private kill(attacker?: ActionBattleEntity) {
|
|
1798
2240
|
if (this.defeated) return;
|
|
1799
2241
|
this.defeated = true;
|
|
1800
2242
|
|
|
@@ -1828,7 +2270,7 @@ export class BattleAi {
|
|
|
1828
2270
|
});
|
|
1829
2271
|
};
|
|
1830
2272
|
|
|
1831
|
-
if (this.autoAwardRewards) {
|
|
2273
|
+
if (this.autoAwardRewards && attacker && isActionBattlePlayer(attacker)) {
|
|
1832
2274
|
reward.giveTo(attacker);
|
|
1833
2275
|
}
|
|
1834
2276
|
|
|
@@ -1846,7 +2288,7 @@ export class BattleAi {
|
|
|
1846
2288
|
(
|
|
1847
2289
|
this.onDefeatedCallback as (
|
|
1848
2290
|
event: RpgEvent,
|
|
1849
|
-
|
|
2291
|
+
attacker?: ActionBattleEntity
|
|
1850
2292
|
) => void
|
|
1851
2293
|
)(this.event, attacker);
|
|
1852
2294
|
} else {
|
|
@@ -1869,6 +2311,96 @@ export class BattleAi {
|
|
|
1869
2311
|
return Math.sqrt(dx * dx + dy * dy);
|
|
1870
2312
|
}
|
|
1871
2313
|
|
|
2314
|
+
private resolveUsable(usable: any) {
|
|
2315
|
+
if (!usable) return usable;
|
|
2316
|
+
const id = typeof usable === "string" ? usable : usable.id;
|
|
2317
|
+
const learned = id ? (this.event as any).getSkill?.(id) : undefined;
|
|
2318
|
+
if (learned) return learned;
|
|
2319
|
+
try {
|
|
2320
|
+
return id ? (this.event as any).databaseById?.(id) ?? usable : usable;
|
|
2321
|
+
} catch {
|
|
2322
|
+
return usable;
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
private getCurrentActionRange(): number | undefined {
|
|
2327
|
+
const skillRange = this.attackSkill
|
|
2328
|
+
? getActionBattleActionRange(this.resolveUsable(this.attackSkill))
|
|
2329
|
+
: undefined;
|
|
2330
|
+
if (skillRange !== undefined) return skillRange;
|
|
2331
|
+
return getActionBattleActionRange(resolveActionBattleWeapon(this.event));
|
|
2332
|
+
}
|
|
2333
|
+
|
|
2334
|
+
private canTarget(target: ActionBattleEntity): boolean {
|
|
2335
|
+
return canActionBattleTarget(
|
|
2336
|
+
this.event,
|
|
2337
|
+
target,
|
|
2338
|
+
this.targets,
|
|
2339
|
+
getActionBattleOptions().combat?.targets
|
|
2340
|
+
);
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
private findNearestTarget(): ActionBattleEntity | null {
|
|
2344
|
+
const map = this.event.getCurrentMap();
|
|
2345
|
+
if (!map) {
|
|
2346
|
+
this.traceLog("target", "find nearest skipped: no map");
|
|
2347
|
+
return null;
|
|
2348
|
+
}
|
|
2349
|
+
|
|
2350
|
+
const candidates: ActionBattleEntity[] = [];
|
|
2351
|
+
map.getPlayers?.().forEach((player: RpgPlayer) => candidates.push(player));
|
|
2352
|
+
map.getEvents?.().forEach((event: RpgEvent) => candidates.push(event));
|
|
2353
|
+
|
|
2354
|
+
let nearest: ActionBattleEntity | null = null;
|
|
2355
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
2356
|
+
for (const candidate of candidates) {
|
|
2357
|
+
if (!this.canTarget(candidate)) continue;
|
|
2358
|
+
const distance = this.getDistance(this.event, candidate);
|
|
2359
|
+
if (distance > this.visionRange) continue;
|
|
2360
|
+
if (distance < nearestDistance) {
|
|
2361
|
+
nearest = candidate;
|
|
2362
|
+
nearestDistance = distance;
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
const now = Date.now();
|
|
2367
|
+
if (nearest) {
|
|
2368
|
+
this.traceLog("target", "nearest target found", {
|
|
2369
|
+
targetId: nearest.id,
|
|
2370
|
+
distance: nearestDistance,
|
|
2371
|
+
visionRange: this.visionRange,
|
|
2372
|
+
candidates: candidates.length,
|
|
2373
|
+
});
|
|
2374
|
+
} else if (now - this.lastNoTargetTraceTime > 1000) {
|
|
2375
|
+
this.lastNoTargetTraceTime = now;
|
|
2376
|
+
this.traceLog("target", "no target found", {
|
|
2377
|
+
visionRange: this.visionRange,
|
|
2378
|
+
candidates: candidates.map((candidate) => {
|
|
2379
|
+
const distance = this.getDistance(this.event, candidate);
|
|
2380
|
+
return {
|
|
2381
|
+
id: candidate.id,
|
|
2382
|
+
hp: (candidate as any).hp,
|
|
2383
|
+
canTarget: this.canTarget(candidate),
|
|
2384
|
+
defeated: this.isTargetDefeated(candidate),
|
|
2385
|
+
distance,
|
|
2386
|
+
};
|
|
2387
|
+
}),
|
|
2388
|
+
});
|
|
2389
|
+
}
|
|
2390
|
+
|
|
2391
|
+
return nearest;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
private isTargetDefeated(target: ActionBattleEntity | null | undefined): boolean {
|
|
2395
|
+
return !target || target.hp <= 0;
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
private clearTarget() {
|
|
2399
|
+
this.target = null;
|
|
2400
|
+
this.isMovingToTarget = false;
|
|
2401
|
+
this.event.stopMoveTo();
|
|
2402
|
+
}
|
|
2403
|
+
|
|
1872
2404
|
private updateBehavior(currentTime: number) {
|
|
1873
2405
|
if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) {
|
|
1874
2406
|
return;
|
|
@@ -1927,10 +2459,25 @@ export class BattleAi {
|
|
|
1927
2459
|
}
|
|
1928
2460
|
}
|
|
1929
2461
|
|
|
1930
|
-
private applyCustomBehavior(currentTime: number) {
|
|
1931
|
-
|
|
2462
|
+
private applyCustomBehavior(currentTime: number): boolean {
|
|
2463
|
+
let handled = false;
|
|
2464
|
+
|
|
2465
|
+
if (this.behaviorTree) {
|
|
2466
|
+
const result = this.behaviorTree.tick(this.createAiTreeContext(currentTime));
|
|
2467
|
+
if (result.decision) {
|
|
2468
|
+
handled = this.applyAiDecision(result.decision, currentTime) || handled;
|
|
2469
|
+
}
|
|
2470
|
+
if (result.intent) {
|
|
2471
|
+
handled = this.executeAiIntents(result.intent, currentTime) || handled;
|
|
2472
|
+
}
|
|
2473
|
+
if (result.status === "running") {
|
|
2474
|
+
handled = true;
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
if (!this.behaviorKey) return handled;
|
|
1932
2479
|
const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
|
|
1933
|
-
if (!behavior) return;
|
|
2480
|
+
if (!behavior) return handled;
|
|
1934
2481
|
const maxHp = this.event.param[MAXHP];
|
|
1935
2482
|
const decision = behavior({
|
|
1936
2483
|
event: this.event,
|
|
@@ -1941,7 +2488,16 @@ export class BattleAi {
|
|
|
1941
2488
|
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
1942
2489
|
now: currentTime,
|
|
1943
2490
|
});
|
|
1944
|
-
if (!decision) return;
|
|
2491
|
+
if (!decision) return handled;
|
|
2492
|
+
return this.applyAiDecision(decision, currentTime) || handled;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
private applyAiDecision(
|
|
2496
|
+
decision: ActionBattleAiDecision | ReturnType<ActionBattleAiBehavior>,
|
|
2497
|
+
currentTime: number
|
|
2498
|
+
): boolean {
|
|
2499
|
+
if (!decision) return false;
|
|
2500
|
+
let handled = false;
|
|
1945
2501
|
if (decision.attackCooldown !== undefined) {
|
|
1946
2502
|
this.attackCooldown = decision.attackCooldown;
|
|
1947
2503
|
}
|
|
@@ -1955,6 +2511,162 @@ export class BattleAi {
|
|
|
1955
2511
|
this.behaviorMode = decision.mode;
|
|
1956
2512
|
this.behaviorEnabled = true;
|
|
1957
2513
|
}
|
|
2514
|
+
if (decision.intent) {
|
|
2515
|
+
handled = this.executeAiIntents(decision.intent, currentTime);
|
|
2516
|
+
}
|
|
2517
|
+
return handled;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
private createAiTreeContext(currentTime: number) {
|
|
2521
|
+
const maxHp = this.event.param[MAXHP];
|
|
2522
|
+
const distance = this.target ? this.getDistance(this.event, this.target) : null;
|
|
2523
|
+
return {
|
|
2524
|
+
event: this.event,
|
|
2525
|
+
target: this.target,
|
|
2526
|
+
state: this.state,
|
|
2527
|
+
enemyType: this.enemyType,
|
|
2528
|
+
distance,
|
|
2529
|
+
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
2530
|
+
now: currentTime,
|
|
2531
|
+
self: {
|
|
2532
|
+
event: this.event,
|
|
2533
|
+
state: this.state,
|
|
2534
|
+
enemyType: this.enemyType,
|
|
2535
|
+
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
2536
|
+
attackRange: this.attackRange,
|
|
2537
|
+
},
|
|
2538
|
+
targetInfo:
|
|
2539
|
+
this.target && distance !== null
|
|
2540
|
+
? {
|
|
2541
|
+
entity: this.target,
|
|
2542
|
+
distance,
|
|
2543
|
+
inAttackRange: distance <= this.attackRange,
|
|
2544
|
+
visible: true,
|
|
2545
|
+
}
|
|
2546
|
+
: null,
|
|
2547
|
+
memory: this.aiMemory,
|
|
2548
|
+
};
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
private executeAiIntents(
|
|
2552
|
+
input: ActionBattleAiIntent | ActionBattleAiIntent[],
|
|
2553
|
+
currentTime: number
|
|
2554
|
+
): boolean {
|
|
2555
|
+
const intents = Array.isArray(input) ? input : [input];
|
|
2556
|
+
let handled = false;
|
|
2557
|
+
for (const intent of intents) {
|
|
2558
|
+
handled = this.executeAiIntent(intent, currentTime) || handled;
|
|
2559
|
+
}
|
|
2560
|
+
return handled;
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
private executeAiIntent(
|
|
2564
|
+
intent: ActionBattleAiIntent,
|
|
2565
|
+
currentTime: number
|
|
2566
|
+
): boolean {
|
|
2567
|
+
const consumes = intent.consume !== false;
|
|
2568
|
+
|
|
2569
|
+
switch (intent.type) {
|
|
2570
|
+
case "setMode":
|
|
2571
|
+
this.behaviorMode = intent.mode;
|
|
2572
|
+
this.behaviorEnabled = true;
|
|
2573
|
+
return consumes;
|
|
2574
|
+
case "idle":
|
|
2575
|
+
this.isMovingToTarget = false;
|
|
2576
|
+
this.event.stopMoveTo();
|
|
2577
|
+
return consumes;
|
|
2578
|
+
case "patrol":
|
|
2579
|
+
this.startPatrol();
|
|
2580
|
+
return consumes;
|
|
2581
|
+
case "faceTarget":
|
|
2582
|
+
this.faceTarget();
|
|
2583
|
+
return consumes;
|
|
2584
|
+
case "moveToTarget":
|
|
2585
|
+
if (!this.target) return false;
|
|
2586
|
+
this.requestTargetMovement();
|
|
2587
|
+
return consumes;
|
|
2588
|
+
case "fleeFromTarget":
|
|
2589
|
+
if (!this.target) return false;
|
|
2590
|
+
this.isMovingToTarget = false;
|
|
2591
|
+
if (this.state === AiState.Combat) {
|
|
2592
|
+
this.changeState(AiState.Flee);
|
|
2593
|
+
} else {
|
|
2594
|
+
this.fleeFromTarget();
|
|
2595
|
+
}
|
|
2596
|
+
return consumes;
|
|
2597
|
+
case "keepDistance":
|
|
2598
|
+
return this.executeKeepDistance(intent, consumes);
|
|
2599
|
+
case "useAttack":
|
|
2600
|
+
return this.executeRequestedAttack(intent.pattern, currentTime, consumes);
|
|
2601
|
+
case "useSkill":
|
|
2602
|
+
return this.executeRequestedSkill(intent.skill, currentTime, consumes);
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
private executeKeepDistance(
|
|
2607
|
+
intent: Extract<ActionBattleAiIntent, { type: "keepDistance" }>,
|
|
2608
|
+
consumes: boolean
|
|
2609
|
+
): boolean {
|
|
2610
|
+
if (!this.target) return false;
|
|
2611
|
+
const tolerance = intent.tolerance ?? Math.max(8, intent.distance * 0.15);
|
|
2612
|
+
const distance = this.getDistance(this.event, this.target);
|
|
2613
|
+
if (distance < intent.distance - tolerance) {
|
|
2614
|
+
this.isMovingToTarget = false;
|
|
2615
|
+
this.retreatFromTarget();
|
|
2616
|
+
return consumes;
|
|
2617
|
+
}
|
|
2618
|
+
if (distance > intent.distance + tolerance) {
|
|
2619
|
+
this.requestTargetMovement();
|
|
2620
|
+
return consumes;
|
|
2621
|
+
}
|
|
2622
|
+
if (this.isMovingToTarget) {
|
|
2623
|
+
this.isMovingToTarget = false;
|
|
2624
|
+
this.event.stopMoveTo();
|
|
2625
|
+
}
|
|
2626
|
+
return consumes;
|
|
2627
|
+
}
|
|
2628
|
+
|
|
2629
|
+
private executeRequestedAttack(
|
|
2630
|
+
pattern: AttackPattern | string | undefined,
|
|
2631
|
+
currentTime: number,
|
|
2632
|
+
consumes: boolean
|
|
2633
|
+
): boolean {
|
|
2634
|
+
if (!this.target || this.isTargetDefeated(this.target) || this.chargingAttack) return false;
|
|
2635
|
+
const distance = this.getDistance(this.event, this.target);
|
|
2636
|
+
if (distance > this.attackRange) return false;
|
|
2637
|
+
if (currentTime - this.lastAttackTime < this.attackCooldown) return false;
|
|
2638
|
+
|
|
2639
|
+
if (pattern) {
|
|
2640
|
+
this.performAttackPattern(pattern as AttackPattern);
|
|
2641
|
+
} else {
|
|
2642
|
+
this.selectAndPerformAttack();
|
|
2643
|
+
}
|
|
2644
|
+
this.lastAttackTime = currentTime;
|
|
2645
|
+
return consumes;
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
private executeRequestedSkill(
|
|
2649
|
+
skill: any,
|
|
2650
|
+
currentTime: number,
|
|
2651
|
+
consumes: boolean
|
|
2652
|
+
): boolean {
|
|
2653
|
+
if (!this.target || this.isTargetDefeated(this.target) || !skill) return false;
|
|
2654
|
+
const distance = this.getDistance(this.event, this.target);
|
|
2655
|
+
const resolvedSkill = this.resolveUsable(skill);
|
|
2656
|
+
const range = getActionBattleActionRange(resolvedSkill) ?? this.attackRange;
|
|
2657
|
+
const cooldownRemaining = this.attackCooldown - (currentTime - this.lastAttackTime);
|
|
2658
|
+
if (distance > range) return false;
|
|
2659
|
+
if (cooldownRemaining > 0) return false;
|
|
2660
|
+
|
|
2661
|
+
executeActionBattleUse({
|
|
2662
|
+
attacker: this.event,
|
|
2663
|
+
target: this.target,
|
|
2664
|
+
usable: resolvedSkill,
|
|
2665
|
+
skill: resolvedSkill,
|
|
2666
|
+
profile: this.getAttackProfile(AttackPattern.Melee),
|
|
2667
|
+
});
|
|
2668
|
+
this.lastAttackTime = currentTime;
|
|
2669
|
+
return consumes;
|
|
1958
2670
|
}
|
|
1959
2671
|
|
|
1960
2672
|
private handleTacticalMovement(distance: number) {
|
|
@@ -1972,8 +2684,7 @@ export class BattleAi {
|
|
|
1972
2684
|
if (distance > maxRange) {
|
|
1973
2685
|
if (!this.isMovingToTarget) {
|
|
1974
2686
|
this.debugLog('movement', `Tactical approach (dist=${distance.toFixed(1)}, maxRange=${maxRange.toFixed(1)})`);
|
|
1975
|
-
this.
|
|
1976
|
-
this.requestMoveTo(this.target);
|
|
2687
|
+
this.requestTargetMovement();
|
|
1977
2688
|
}
|
|
1978
2689
|
return;
|
|
1979
2690
|
}
|
|
@@ -1990,8 +2701,7 @@ export class BattleAi {
|
|
|
1990
2701
|
if (distance > this.attackRange) {
|
|
1991
2702
|
if (!this.isMovingToTarget) {
|
|
1992
2703
|
this.debugLog('movement', `Assault approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
1993
|
-
this.
|
|
1994
|
-
this.requestMoveTo(this.target);
|
|
2704
|
+
this.requestTargetMovement();
|
|
1995
2705
|
}
|
|
1996
2706
|
return;
|
|
1997
2707
|
}
|
|
@@ -2003,19 +2713,118 @@ export class BattleAi {
|
|
|
2003
2713
|
}
|
|
2004
2714
|
}
|
|
2005
2715
|
|
|
2716
|
+
private resolveMoveTarget(target: any): ResolvedMoveTarget | null {
|
|
2717
|
+
if (!target) return null;
|
|
2718
|
+
|
|
2719
|
+
const targetId =
|
|
2720
|
+
target.id !== undefined && target.id !== null ? String(target.id) : undefined;
|
|
2721
|
+
const x = resolveMoveCoordinate(target.x);
|
|
2722
|
+
const y = resolveMoveCoordinate(target.y);
|
|
2723
|
+
|
|
2724
|
+
if (targetId) {
|
|
2725
|
+
return {
|
|
2726
|
+
kind: "entity",
|
|
2727
|
+
target,
|
|
2728
|
+
id: targetId,
|
|
2729
|
+
x,
|
|
2730
|
+
y,
|
|
2731
|
+
signature: `entity:${targetId}`,
|
|
2732
|
+
};
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
if (x === undefined || y === undefined) {
|
|
2736
|
+
return null;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
return {
|
|
2740
|
+
kind: "position",
|
|
2741
|
+
target: { x, y },
|
|
2742
|
+
x,
|
|
2743
|
+
y,
|
|
2744
|
+
signature: createMoveSignature(x, y),
|
|
2745
|
+
};
|
|
2746
|
+
}
|
|
2747
|
+
|
|
2006
2748
|
private requestMoveTo(target: any): boolean {
|
|
2007
2749
|
const currentTime = Date.now();
|
|
2750
|
+
const resolvedTarget = this.resolveMoveTarget(target);
|
|
2751
|
+
if (!resolvedTarget) {
|
|
2752
|
+
this.traceLog("movement", "moveTo skipped: invalid target", {
|
|
2753
|
+
target,
|
|
2754
|
+
});
|
|
2755
|
+
return false;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2008
2758
|
if (currentTime - this.lastMoveToTime < this.moveToCooldown) {
|
|
2759
|
+
if (
|
|
2760
|
+
this.lastMoveToCooldownTraceSignature !== resolvedTarget.signature ||
|
|
2761
|
+
currentTime - this.lastMoveToCooldownTraceTime > 1000
|
|
2762
|
+
) {
|
|
2763
|
+
this.lastMoveToCooldownTraceTime = currentTime;
|
|
2764
|
+
this.lastMoveToCooldownTraceSignature = resolvedTarget.signature;
|
|
2765
|
+
this.traceLog("movement", "moveTo skipped: cooldown", {
|
|
2766
|
+
targetKind: resolvedTarget.kind,
|
|
2767
|
+
targetId:
|
|
2768
|
+
resolvedTarget.kind === "entity" ? resolvedTarget.id : undefined,
|
|
2769
|
+
targetPosition: {
|
|
2770
|
+
x: resolvedTarget.x,
|
|
2771
|
+
y: resolvedTarget.y,
|
|
2772
|
+
},
|
|
2773
|
+
elapsed: currentTime - this.lastMoveToTime,
|
|
2774
|
+
moveToCooldown: this.moveToCooldown,
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2009
2777
|
return false;
|
|
2010
2778
|
}
|
|
2011
|
-
this.event.
|
|
2779
|
+
const map = this.event.getCurrentMap?.() as any;
|
|
2780
|
+
const hasBody =
|
|
2781
|
+
!!map?.physic?.getEntityByUUID?.(this.event.id) ||
|
|
2782
|
+
!!map?.getBody?.(this.event.id);
|
|
2783
|
+
this.traceLog("movement", "moveTo requested", {
|
|
2784
|
+
targetKind: resolvedTarget.kind,
|
|
2785
|
+
targetId:
|
|
2786
|
+
resolvedTarget.kind === "entity" ? resolvedTarget.id : undefined,
|
|
2787
|
+
eventPosition: {
|
|
2788
|
+
x: this.event.x?.(),
|
|
2789
|
+
y: this.event.y?.(),
|
|
2790
|
+
},
|
|
2791
|
+
targetPosition: {
|
|
2792
|
+
x: resolvedTarget.x,
|
|
2793
|
+
y: resolvedTarget.y,
|
|
2794
|
+
},
|
|
2795
|
+
hasMap: !!map,
|
|
2796
|
+
hasMovementBody: hasBody,
|
|
2797
|
+
});
|
|
2798
|
+
this.event.moveTo(resolvedTarget.target as any);
|
|
2012
2799
|
this.lastMoveToTime = currentTime;
|
|
2013
2800
|
return true;
|
|
2014
2801
|
}
|
|
2015
2802
|
|
|
2803
|
+
private requestTargetMovement(target: ActionBattleEntity | null = this.target): boolean {
|
|
2804
|
+
if (!target) {
|
|
2805
|
+
this.traceLog("movement", "target movement skipped: no target");
|
|
2806
|
+
return false;
|
|
2807
|
+
}
|
|
2808
|
+
const started = this.requestMoveTo(target);
|
|
2809
|
+
if (started) {
|
|
2810
|
+
this.isMovingToTarget = true;
|
|
2811
|
+
} else {
|
|
2812
|
+
const now = Date.now();
|
|
2813
|
+
if (now - this.lastTargetMovementSkipTraceTime > 1000) {
|
|
2814
|
+
this.lastTargetMovementSkipTraceTime = now;
|
|
2815
|
+
this.traceLog("movement", "target movement did not start", {
|
|
2816
|
+
targetId: target.id,
|
|
2817
|
+
isMovingToTarget: this.isMovingToTarget,
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
return started;
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2016
2824
|
private schedule(callback: () => void, delay: number) {
|
|
2017
2825
|
const timer = setTimeout(() => {
|
|
2018
2826
|
this.timers = this.timers.filter((entry) => entry !== timer);
|
|
2827
|
+
if (this.destroyed) return;
|
|
2019
2828
|
callback();
|
|
2020
2829
|
}, delay);
|
|
2021
2830
|
this.timers.push(timer);
|
|
@@ -2025,14 +2834,31 @@ export class BattleAi {
|
|
|
2025
2834
|
// Public getters
|
|
2026
2835
|
getHealth(): number { return this.event.hp; }
|
|
2027
2836
|
getMaxHealth(): number { return this.event.param[MAXHP]; }
|
|
2028
|
-
getTarget():
|
|
2837
|
+
getTarget(): ActionBattleEntity | null { return this.target; }
|
|
2029
2838
|
getState(): AiState { return this.state; }
|
|
2839
|
+
getFaction(): string | undefined {
|
|
2840
|
+
return this.faction;
|
|
2841
|
+
}
|
|
2842
|
+
setFaction(faction: string | undefined): void {
|
|
2843
|
+
this.faction = faction;
|
|
2844
|
+
}
|
|
2845
|
+
getTargets(): ActionBattleTargetSelector {
|
|
2846
|
+
return this.targets;
|
|
2847
|
+
}
|
|
2848
|
+
setTargets(targets: ActionBattleTargetSelector): void {
|
|
2849
|
+
this.targets = targets;
|
|
2850
|
+
if (this.target && !this.canTarget(this.target)) {
|
|
2851
|
+
this.clearTarget();
|
|
2852
|
+
this.changeState(AiState.Idle);
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2030
2855
|
getEnemyType(): EnemyType { return this.enemyType; }
|
|
2031
2856
|
|
|
2032
2857
|
/**
|
|
2033
2858
|
* Clean up
|
|
2034
2859
|
*/
|
|
2035
2860
|
destroy() {
|
|
2861
|
+
this.destroyed = true;
|
|
2036
2862
|
if (this.updateInterval) {
|
|
2037
2863
|
clearInterval(this.updateInterval);
|
|
2038
2864
|
this.updateInterval = undefined;
|