@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
|
@@ -0,0 +1,1949 @@
|
|
|
1
|
+
import { isActionBattleEntityInvincible, setActionBattleInvincibility } from "./index2.js";
|
|
2
|
+
import { getActionBattleOptions } from "./index4.js";
|
|
3
|
+
import { ActionBattleHitTracker, runActionBattleActiveHitbox, scheduleActionBattleStartup } from "./index6.js";
|
|
4
|
+
import { applyActionBattleAttackDirection } from "./index7.js";
|
|
5
|
+
import { withActionBattleAnimationUnlocked } from "./index8.js";
|
|
6
|
+
import { getActionBattleAnimationRemovalDelay, resolveActionBattleAnimation } from "./index14.js";
|
|
7
|
+
import { emitActionBattleClientVisual } from "./index15.js";
|
|
8
|
+
import { getActionBattleSystems } from "./index18.js";
|
|
9
|
+
import { normalizeActionBattleEnemyAttackProfiles } from "./index19.js";
|
|
10
|
+
import { canActionBattleTarget, isActionBattlePlayer } from "./index21.js";
|
|
11
|
+
import { executeActionBattleUse, getActionBattleActionRange } from "./index22.js";
|
|
12
|
+
import { resolveActionBattleWeapon } from "./index23.js";
|
|
13
|
+
import { safeActionBattleDash } from "./index24.js";
|
|
14
|
+
import { defineAiBehavior, defineAiTree } from "./index25.js";
|
|
15
|
+
//#region src/ai.server.ts
|
|
16
|
+
var MAXHP = null;
|
|
17
|
+
var resolveMoveCoordinate = (value) => {
|
|
18
|
+
const raw = typeof value === "function" ? value() : value;
|
|
19
|
+
return typeof raw === "number" && Number.isFinite(raw) ? raw : void 0;
|
|
20
|
+
};
|
|
21
|
+
var createMoveSignature = (x, y) => `position:${Math.round(x)}:${Math.round(y)}`;
|
|
22
|
+
var normalizeRewardItem = (item) => {
|
|
23
|
+
if (typeof item === "string") return {
|
|
24
|
+
itemId: item,
|
|
25
|
+
amount: 1,
|
|
26
|
+
chance: 100
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
...item,
|
|
30
|
+
amount: item.amount ?? 1,
|
|
31
|
+
chance: item.chance ?? 100
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
var getRewardItemRef = (item) => item.item ?? item.itemId;
|
|
35
|
+
var getPlayerMap = (player) => {
|
|
36
|
+
return typeof player.getCurrentMap === "function" ? player.getCurrentMap() : void 0;
|
|
37
|
+
};
|
|
38
|
+
var getRewardItemName = (inventoryItem, itemRef) => {
|
|
39
|
+
if (inventoryItem && typeof inventoryItem.name === "function") return inventoryItem.name();
|
|
40
|
+
if (inventoryItem?.name) return inventoryItem.name;
|
|
41
|
+
if (typeof itemRef === "string") return itemRef;
|
|
42
|
+
if (itemRef?.name) return itemRef.name;
|
|
43
|
+
if (itemRef?.id) return itemRef.id;
|
|
44
|
+
return "item";
|
|
45
|
+
};
|
|
46
|
+
var createDefeatReward = (rewards) => {
|
|
47
|
+
let awarded = false;
|
|
48
|
+
return {
|
|
49
|
+
get awarded() {
|
|
50
|
+
return awarded;
|
|
51
|
+
},
|
|
52
|
+
giveTo(player) {
|
|
53
|
+
if (!player || awarded || !rewards) return;
|
|
54
|
+
awarded = true;
|
|
55
|
+
const exp = rewards.exp ?? 0;
|
|
56
|
+
const gold = rewards.gold ?? 0;
|
|
57
|
+
if (exp > 0) player.exp += exp;
|
|
58
|
+
if (gold > 0) player.gold += gold;
|
|
59
|
+
if (rewards.showNotification && (exp > 0 || gold > 0)) player.showNotification(`You won ${exp} experience and ${gold} gold`);
|
|
60
|
+
for (const rawItem of rewards.items ?? []) {
|
|
61
|
+
const item = normalizeRewardItem(rawItem);
|
|
62
|
+
const itemRef = getRewardItemRef(item);
|
|
63
|
+
if (!itemRef) continue;
|
|
64
|
+
if (Math.random() * 100 >= (item.chance ?? 100)) continue;
|
|
65
|
+
const amount = item.amount ?? 1;
|
|
66
|
+
const inventoryItem = player.addItem(itemRef, amount);
|
|
67
|
+
if (rewards.showNotification) {
|
|
68
|
+
const itemData = typeof itemRef === "string" ? getPlayerMap(player)?.database?.()?.[itemRef] : void 0;
|
|
69
|
+
player.showNotification(`You won ${amount} ${getRewardItemName(inventoryItem, itemRef)}`, { icon: itemData?.icon });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* AI Debug Logger
|
|
77
|
+
*
|
|
78
|
+
* Conditional logging utility for AI behavior debugging.
|
|
79
|
+
* Enable by setting `AiDebug.enabled = true` or via environment variable `RPGJS_DEBUG_AI=1`
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* // Enable debug logging
|
|
84
|
+
* AiDebug.enabled = true;
|
|
85
|
+
*
|
|
86
|
+
* // Or filter by event ID
|
|
87
|
+
* AiDebug.filterEventId = 'goblin-1';
|
|
88
|
+
* ```
|
|
89
|
+
*/
|
|
90
|
+
var AiDebug = {
|
|
91
|
+
/** Enable/disable all AI debug logs */
|
|
92
|
+
enabled: globalThis.process?.env?.RPGJS_DEBUG_AI === "1" || false,
|
|
93
|
+
/** Filter logs to a specific event ID (null = all events) */
|
|
94
|
+
filterEventId: null,
|
|
95
|
+
/** Log categories to enable (empty = all) */
|
|
96
|
+
categories: [],
|
|
97
|
+
/**
|
|
98
|
+
* Log an AI debug message
|
|
99
|
+
*
|
|
100
|
+
* @param category - Log category (e.g., 'state', 'attack', 'movement', 'damage')
|
|
101
|
+
* @param eventId - Event ID for filtering
|
|
102
|
+
* @param message - Log message
|
|
103
|
+
* @param data - Optional additional data
|
|
104
|
+
*/
|
|
105
|
+
log(category, eventId, message, data) {}
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* AI State enumeration
|
|
109
|
+
*
|
|
110
|
+
* Defines the different states an AI can be in, each with its own behavior.
|
|
111
|
+
*/
|
|
112
|
+
var AiState = /* @__PURE__ */ function(AiState) {
|
|
113
|
+
AiState["Idle"] = "idle";
|
|
114
|
+
AiState["Alert"] = "alert";
|
|
115
|
+
AiState["Combat"] = "combat";
|
|
116
|
+
AiState["Flee"] = "flee";
|
|
117
|
+
AiState["Stunned"] = "stunned";
|
|
118
|
+
return AiState;
|
|
119
|
+
}({});
|
|
120
|
+
/**
|
|
121
|
+
* Enemy Type enumeration
|
|
122
|
+
*
|
|
123
|
+
* Defines different enemy archetypes with unique behaviors.
|
|
124
|
+
* Stats (HP, ATK, etc.) should be set on the event itself via onInit.
|
|
125
|
+
*/
|
|
126
|
+
var EnemyType = /* @__PURE__ */ function(EnemyType) {
|
|
127
|
+
EnemyType["Aggressive"] = "aggressive";
|
|
128
|
+
EnemyType["Defensive"] = "defensive";
|
|
129
|
+
EnemyType["Ranged"] = "ranged";
|
|
130
|
+
EnemyType["Tank"] = "tank";
|
|
131
|
+
EnemyType["Berserker"] = "berserker";
|
|
132
|
+
return EnemyType;
|
|
133
|
+
}({});
|
|
134
|
+
/**
|
|
135
|
+
* Attack Pattern enumeration
|
|
136
|
+
*
|
|
137
|
+
* Different attack patterns the AI can use.
|
|
138
|
+
*/
|
|
139
|
+
var AttackPattern = /* @__PURE__ */ function(AttackPattern) {
|
|
140
|
+
AttackPattern["Melee"] = "melee";
|
|
141
|
+
AttackPattern["Combo"] = "combo";
|
|
142
|
+
AttackPattern["Charged"] = "charged";
|
|
143
|
+
AttackPattern["Zone"] = "zone";
|
|
144
|
+
AttackPattern["DashAttack"] = "dashAttack";
|
|
145
|
+
return AttackPattern;
|
|
146
|
+
}({});
|
|
147
|
+
/**
|
|
148
|
+
* Default knockback configuration
|
|
149
|
+
*
|
|
150
|
+
* Used when no weapon is equipped or weapon doesn't specify knockback.
|
|
151
|
+
*/
|
|
152
|
+
var DEFAULT_KNOCKBACK = {
|
|
153
|
+
/** Default knockback force */
|
|
154
|
+
force: 50,
|
|
155
|
+
/** Default knockback duration in milliseconds */
|
|
156
|
+
duration: 300
|
|
157
|
+
};
|
|
158
|
+
var mergeBattleAiPresetOptions = (options, seen = /* @__PURE__ */ new Set()) => {
|
|
159
|
+
if (!options.preset) return options;
|
|
160
|
+
if (typeof options.preset === "string") {
|
|
161
|
+
if (seen.has(options.preset)) throw new Error(`Circular action battle AI preset: ${options.preset}`);
|
|
162
|
+
seen.add(options.preset);
|
|
163
|
+
}
|
|
164
|
+
const preset = typeof options.preset === "string" ? getActionBattleSystems().ai.presets[options.preset] : options.preset;
|
|
165
|
+
if (!preset) throw new Error(`Action battle AI preset not found: ${options.preset}`);
|
|
166
|
+
const resolvedPreset = mergeBattleAiPresetOptions(preset, seen);
|
|
167
|
+
const { preset: _preset, ...overrides } = options;
|
|
168
|
+
return {
|
|
169
|
+
...resolvedPreset,
|
|
170
|
+
...overrides,
|
|
171
|
+
behavior: {
|
|
172
|
+
...resolvedPreset.behavior,
|
|
173
|
+
...overrides.behavior
|
|
174
|
+
},
|
|
175
|
+
animations: {
|
|
176
|
+
...resolvedPreset.animations,
|
|
177
|
+
...overrides.animations
|
|
178
|
+
},
|
|
179
|
+
rewards: {
|
|
180
|
+
...resolvedPreset.rewards,
|
|
181
|
+
...overrides.rewards
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Advanced Battle AI Controller for events
|
|
187
|
+
*
|
|
188
|
+
* This class provides intelligent combat behavior control for events.
|
|
189
|
+
* It uses the existing RPGJS API for stats, skills, items, etc.
|
|
190
|
+
* The AI only manages behavior - the event's stats should be configured
|
|
191
|
+
* in onInit using standard RPGJS methods.
|
|
192
|
+
*
|
|
193
|
+
* ## Usage with RPGJS API
|
|
194
|
+
*
|
|
195
|
+
* Configure the event stats using standard RPGJS methods:
|
|
196
|
+
* - `this.hp = 100` - Set health
|
|
197
|
+
* - `this.learnSkill(FireBall)` - Learn a skill
|
|
198
|
+
* - `this.addItem(Potion, 3)` - Add items
|
|
199
|
+
* - `this.equip(Sword)` - Equip items
|
|
200
|
+
* - `this.setClass(WarriorClass)` - Set class
|
|
201
|
+
* - `this.param[ATK] = 20` - Set parameters
|
|
202
|
+
*
|
|
203
|
+
* @example
|
|
204
|
+
* ```ts
|
|
205
|
+
* function GoblinEnemy() {
|
|
206
|
+
* return {
|
|
207
|
+
* name: "Goblin",
|
|
208
|
+
* onInit() {
|
|
209
|
+
* this.setGraphic("goblin");
|
|
210
|
+
*
|
|
211
|
+
* // Configure stats using RPGJS API
|
|
212
|
+
* this.hp = 80;
|
|
213
|
+
* this.param[ATK] = 15;
|
|
214
|
+
* this.param[PDEF] = 5;
|
|
215
|
+
* this.learnSkill(Slash);
|
|
216
|
+
*
|
|
217
|
+
* // Apply AI behavior
|
|
218
|
+
* new BattleAi(this, {
|
|
219
|
+
* enemyType: EnemyType.Aggressive,
|
|
220
|
+
* attackSkill: Slash
|
|
221
|
+
* });
|
|
222
|
+
* }
|
|
223
|
+
* };
|
|
224
|
+
* }
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
var BattleAi = class {
|
|
228
|
+
event;
|
|
229
|
+
target = null;
|
|
230
|
+
lastAttackTime = 0;
|
|
231
|
+
updateInterval;
|
|
232
|
+
/**
|
|
233
|
+
* Log AI debug message for this event
|
|
234
|
+
*/
|
|
235
|
+
debugLog(category, message, data) {
|
|
236
|
+
AiDebug.log(category, this.event.id, message, data);
|
|
237
|
+
}
|
|
238
|
+
traceLog(category, message, data) {}
|
|
239
|
+
lockActionUntil(until, reason, data) {
|
|
240
|
+
if (until <= this.actionLockedUntil) return;
|
|
241
|
+
this.actionLockedUntil = until;
|
|
242
|
+
this.traceLog("state", "action locked", {
|
|
243
|
+
reason,
|
|
244
|
+
lockedMs: Math.max(0, until - Date.now()),
|
|
245
|
+
...data
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
lockForAttack(profile, pattern) {
|
|
249
|
+
this.isMovingToTarget = false;
|
|
250
|
+
this.event.stopMoveTo();
|
|
251
|
+
this.lockActionUntil(Date.now() + profile.totalDurationMs, "attack", {
|
|
252
|
+
pattern,
|
|
253
|
+
totalDurationMs: profile.totalDurationMs,
|
|
254
|
+
startupMs: profile.startupMs,
|
|
255
|
+
activeMs: profile.activeMs,
|
|
256
|
+
recoveryMs: profile.recoveryMs
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
state = AiState.Idle;
|
|
260
|
+
stateStartTime = 0;
|
|
261
|
+
stunnedUntil = 0;
|
|
262
|
+
enemyType;
|
|
263
|
+
faction;
|
|
264
|
+
targets = "players";
|
|
265
|
+
attackCooldown = 1e3;
|
|
266
|
+
visionRange = 150;
|
|
267
|
+
attackRange = 60;
|
|
268
|
+
dodgeChance = .2;
|
|
269
|
+
dodgeCooldown = 2e3;
|
|
270
|
+
lastDodgeTime = 0;
|
|
271
|
+
fleeThreshold = .2;
|
|
272
|
+
attackSkill;
|
|
273
|
+
attackPatterns;
|
|
274
|
+
attackProfiles;
|
|
275
|
+
animations;
|
|
276
|
+
comboCount = 0;
|
|
277
|
+
comboMax = 3;
|
|
278
|
+
chargingAttack = false;
|
|
279
|
+
groupBehavior;
|
|
280
|
+
nearbyEnemies = [];
|
|
281
|
+
groupUpdateInterval = 0;
|
|
282
|
+
patrolWaypoints = [];
|
|
283
|
+
currentPatrolIndex = 0;
|
|
284
|
+
lastHpCheck = 0;
|
|
285
|
+
recentDamageTaken = 0;
|
|
286
|
+
damageCheckInterval = 2e3;
|
|
287
|
+
isMovingToTarget = false;
|
|
288
|
+
onDefeatedCallback;
|
|
289
|
+
rewards;
|
|
290
|
+
autoAwardRewards = true;
|
|
291
|
+
defeated = false;
|
|
292
|
+
lastFacingDirection = null;
|
|
293
|
+
behaviorScore = 50;
|
|
294
|
+
behaviorMode = "tactical";
|
|
295
|
+
behaviorLastUpdate = 0;
|
|
296
|
+
behaviorUpdateInterval = 400;
|
|
297
|
+
behaviorAssaultThreshold = 65;
|
|
298
|
+
behaviorRetreatThreshold = 35;
|
|
299
|
+
behaviorMinStateDuration = 600;
|
|
300
|
+
behaviorEnabled = false;
|
|
301
|
+
moveToCooldown = 400;
|
|
302
|
+
lastMoveToTime = 0;
|
|
303
|
+
retreatCooldown = 600;
|
|
304
|
+
lastRetreatTime = 0;
|
|
305
|
+
actionLockedUntil = 0;
|
|
306
|
+
lastActionLockTraceTime = 0;
|
|
307
|
+
lastMoveToCooldownTraceTime = 0;
|
|
308
|
+
lastMoveToCooldownTraceSignature = null;
|
|
309
|
+
lastTargetMovementSkipTraceTime = 0;
|
|
310
|
+
timers = [];
|
|
311
|
+
behaviorKey;
|
|
312
|
+
behaviorTree;
|
|
313
|
+
aiMemory = {};
|
|
314
|
+
poise = 0;
|
|
315
|
+
hitstunMs = 150;
|
|
316
|
+
invincibilityMs = 250;
|
|
317
|
+
visionShape;
|
|
318
|
+
visionSetupRetries = 0;
|
|
319
|
+
maxVisionSetupRetries = 20;
|
|
320
|
+
destroyed = false;
|
|
321
|
+
lastNoTargetTraceTime = 0;
|
|
322
|
+
constructor(event, options = {}) {
|
|
323
|
+
options = mergeBattleAiPresetOptions(options);
|
|
324
|
+
event.battleAi = this;
|
|
325
|
+
this.event = event;
|
|
326
|
+
this.enemyType = options.enemyType || EnemyType.Aggressive;
|
|
327
|
+
this.faction = options.faction;
|
|
328
|
+
this.targets = options.targets ?? "players";
|
|
329
|
+
this.behaviorKey = options.behaviorKey ?? this.enemyType;
|
|
330
|
+
this.applyEnemyTypeBehavior(options);
|
|
331
|
+
this.attackSkill = options.attackSkill || null;
|
|
332
|
+
this.animations = {
|
|
333
|
+
...getActionBattleOptions().animations,
|
|
334
|
+
...options.animations
|
|
335
|
+
};
|
|
336
|
+
this.attackPatterns = options.attackPatterns || [
|
|
337
|
+
AttackPattern.Melee,
|
|
338
|
+
AttackPattern.Combo,
|
|
339
|
+
AttackPattern.DashAttack
|
|
340
|
+
];
|
|
341
|
+
this.attackProfiles = normalizeActionBattleEnemyAttackProfiles(options.attackProfiles);
|
|
342
|
+
this.groupBehavior = options.groupBehavior || false;
|
|
343
|
+
this.patrolWaypoints = options.patrolWaypoints || [];
|
|
344
|
+
this.currentPatrolIndex = 0;
|
|
345
|
+
this.onDefeatedCallback = options.onDefeated;
|
|
346
|
+
this.rewards = options.rewards;
|
|
347
|
+
this.autoAwardRewards = options.autoAwardRewards ?? true;
|
|
348
|
+
if (options.behavior) {
|
|
349
|
+
this.behaviorEnabled = true;
|
|
350
|
+
if (options.behavior.baseScore !== void 0) this.behaviorScore = options.behavior.baseScore;
|
|
351
|
+
if (options.behavior.updateInterval !== void 0) this.behaviorUpdateInterval = options.behavior.updateInterval;
|
|
352
|
+
if (options.behavior.minStateDuration !== void 0) this.behaviorMinStateDuration = options.behavior.minStateDuration;
|
|
353
|
+
if (options.behavior.assaultThreshold !== void 0) this.behaviorAssaultThreshold = options.behavior.assaultThreshold;
|
|
354
|
+
if (options.behavior.retreatThreshold !== void 0) this.behaviorRetreatThreshold = options.behavior.retreatThreshold;
|
|
355
|
+
}
|
|
356
|
+
if (options.moveToCooldown !== void 0) this.moveToCooldown = options.moveToCooldown;
|
|
357
|
+
if (options.retreatCooldown !== void 0) this.retreatCooldown = options.retreatCooldown;
|
|
358
|
+
if (options.poise !== void 0) this.poise = Math.max(0, options.poise);
|
|
359
|
+
if (options.hitstunMs !== void 0) this.hitstunMs = Math.max(0, options.hitstunMs);
|
|
360
|
+
if (options.invincibilityMs !== void 0) this.invincibilityMs = Math.max(0, options.invincibilityMs);
|
|
361
|
+
if (options.tree || options.behaviorTree) this.behaviorTree = defineAiTree(options.tree ?? options.behaviorTree);
|
|
362
|
+
else if (options.simpleBehavior) this.behaviorTree = defineAiBehavior(options.simpleBehavior);
|
|
363
|
+
if (options.attackRange === void 0) {
|
|
364
|
+
const actionRange = this.getCurrentActionRange();
|
|
365
|
+
if (actionRange !== void 0) this.attackRange = actionRange;
|
|
366
|
+
}
|
|
367
|
+
this.scheduleVisionSetup();
|
|
368
|
+
this.startAiBehaviorLoop();
|
|
369
|
+
if (this.patrolWaypoints.length > 0) this.startPatrol();
|
|
370
|
+
this.debugLog("init", `AI created (type=${this.enemyType}, visionRange=${this.visionRange}, attackRange=${this.attackRange})`);
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* Apply enemy type-specific behavior modifiers
|
|
374
|
+
*
|
|
375
|
+
* This only affects AI behavior (cooldowns, ranges, dodge).
|
|
376
|
+
* Stats should be set on the event itself.
|
|
377
|
+
*/
|
|
378
|
+
applyEnemyTypeBehavior(options) {
|
|
379
|
+
switch (this.enemyType) {
|
|
380
|
+
case EnemyType.Aggressive:
|
|
381
|
+
this.attackCooldown = options.attackCooldown ?? 600;
|
|
382
|
+
this.visionRange = options.visionRange ?? 150;
|
|
383
|
+
this.attackRange = options.attackRange ?? 50;
|
|
384
|
+
this.dodgeChance = options.dodgeChance ?? .1;
|
|
385
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 3e3;
|
|
386
|
+
this.fleeThreshold = options.fleeThreshold ?? .15;
|
|
387
|
+
break;
|
|
388
|
+
case EnemyType.Defensive:
|
|
389
|
+
this.attackCooldown = options.attackCooldown ?? 1500;
|
|
390
|
+
this.visionRange = options.visionRange ?? 120;
|
|
391
|
+
this.attackRange = options.attackRange ?? 60;
|
|
392
|
+
this.dodgeChance = options.dodgeChance ?? .5;
|
|
393
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 1500;
|
|
394
|
+
this.fleeThreshold = options.fleeThreshold ?? .3;
|
|
395
|
+
break;
|
|
396
|
+
case EnemyType.Ranged:
|
|
397
|
+
this.attackCooldown = options.attackCooldown ?? 1200;
|
|
398
|
+
this.visionRange = options.visionRange ?? 200;
|
|
399
|
+
this.attackRange = options.attackRange ?? 120;
|
|
400
|
+
this.dodgeChance = options.dodgeChance ?? .4;
|
|
401
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 2e3;
|
|
402
|
+
this.fleeThreshold = options.fleeThreshold ?? .25;
|
|
403
|
+
break;
|
|
404
|
+
case EnemyType.Tank:
|
|
405
|
+
this.attackCooldown = options.attackCooldown ?? 2e3;
|
|
406
|
+
this.visionRange = options.visionRange ?? 100;
|
|
407
|
+
this.attackRange = options.attackRange ?? 50;
|
|
408
|
+
this.dodgeChance = 0;
|
|
409
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 5e3;
|
|
410
|
+
this.fleeThreshold = options.fleeThreshold ?? .1;
|
|
411
|
+
break;
|
|
412
|
+
case EnemyType.Berserker:
|
|
413
|
+
this.attackCooldown = options.attackCooldown ?? 800;
|
|
414
|
+
this.visionRange = options.visionRange ?? 180;
|
|
415
|
+
this.attackRange = options.attackRange ?? 55;
|
|
416
|
+
this.dodgeChance = options.dodgeChance ?? .15;
|
|
417
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 2500;
|
|
418
|
+
this.fleeThreshold = options.fleeThreshold ?? .05;
|
|
419
|
+
break;
|
|
420
|
+
default:
|
|
421
|
+
this.attackCooldown = options.attackCooldown ?? 1e3;
|
|
422
|
+
this.visionRange = options.visionRange ?? 150;
|
|
423
|
+
this.attackRange = options.attackRange ?? 60;
|
|
424
|
+
this.dodgeChance = options.dodgeChance ?? .2;
|
|
425
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 2e3;
|
|
426
|
+
this.fleeThreshold = options.fleeThreshold ?? .2;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Setup vision detection
|
|
431
|
+
*/
|
|
432
|
+
setupVision() {
|
|
433
|
+
if (this.visionShape) return true;
|
|
434
|
+
const map = this.event.getCurrentMap?.();
|
|
435
|
+
if (map?.physic?.getEntityByUUID && !map.physic.getEntityByUUID(this.event.id)) {
|
|
436
|
+
this.traceLog("vision", "physics body not ready", {
|
|
437
|
+
retries: this.visionSetupRetries,
|
|
438
|
+
hasMap: !!map
|
|
439
|
+
});
|
|
440
|
+
return false;
|
|
441
|
+
}
|
|
442
|
+
const diameter = this.visionRange * 2;
|
|
443
|
+
const shape = this.event.attachShape(`vision_${this.event.id}`, {
|
|
444
|
+
radius: this.visionRange,
|
|
445
|
+
width: diameter,
|
|
446
|
+
height: diameter,
|
|
447
|
+
angle: 360
|
|
448
|
+
});
|
|
449
|
+
if (!shape) {
|
|
450
|
+
this.traceLog("vision", "attachShape returned no shape", {
|
|
451
|
+
retries: this.visionSetupRetries,
|
|
452
|
+
visionRange: this.visionRange
|
|
453
|
+
});
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
this.visionShape = shape;
|
|
457
|
+
this.traceLog("vision", "vision attached", {
|
|
458
|
+
shapeId: shape?.id,
|
|
459
|
+
visionRange: this.visionRange
|
|
460
|
+
});
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
scheduleVisionSetup() {
|
|
464
|
+
if (this.destroyed || this.setupVision()) return;
|
|
465
|
+
if (this.visionSetupRetries >= this.maxVisionSetupRetries) {
|
|
466
|
+
this.traceLog("vision", "vision setup gave up", { retries: this.visionSetupRetries });
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
this.visionSetupRetries++;
|
|
470
|
+
this.schedule(() => {
|
|
471
|
+
if (this.destroyed || !this.event.getCurrentMap()) return;
|
|
472
|
+
this.scheduleVisionSetup();
|
|
473
|
+
}, 50);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Start the AI behavior loop
|
|
477
|
+
*/
|
|
478
|
+
startAiBehaviorLoop() {
|
|
479
|
+
const updateInterval = setInterval(() => {
|
|
480
|
+
if (!this.event.getCurrentMap()) {
|
|
481
|
+
this.destroy();
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
this.updateAiBehavior();
|
|
485
|
+
}, 100);
|
|
486
|
+
this.updateInterval = updateInterval;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Change AI state with validated transitions
|
|
490
|
+
*/
|
|
491
|
+
changeState(newState) {
|
|
492
|
+
if (newState === this.state) return;
|
|
493
|
+
if (!{
|
|
494
|
+
[AiState.Idle]: [AiState.Alert, AiState.Combat],
|
|
495
|
+
[AiState.Alert]: [AiState.Idle, AiState.Combat],
|
|
496
|
+
[AiState.Combat]: [
|
|
497
|
+
AiState.Idle,
|
|
498
|
+
AiState.Flee,
|
|
499
|
+
AiState.Stunned
|
|
500
|
+
],
|
|
501
|
+
[AiState.Flee]: [AiState.Idle, AiState.Combat],
|
|
502
|
+
[AiState.Stunned]: [AiState.Combat, AiState.Idle]
|
|
503
|
+
}[this.state].includes(newState)) {
|
|
504
|
+
this.debugLog("state", `INVALID transition ${this.state} -> ${newState}`);
|
|
505
|
+
this.traceLog("state", "invalid transition", {
|
|
506
|
+
from: this.state,
|
|
507
|
+
to: newState
|
|
508
|
+
});
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
this.debugLog("state", `STATE change: ${this.state} -> ${newState}`);
|
|
512
|
+
this.traceLog("state", "state change", {
|
|
513
|
+
from: this.state,
|
|
514
|
+
to: newState,
|
|
515
|
+
targetId: this.target?.id
|
|
516
|
+
});
|
|
517
|
+
this.state = newState;
|
|
518
|
+
this.stateStartTime = Date.now();
|
|
519
|
+
switch (newState) {
|
|
520
|
+
case AiState.Idle:
|
|
521
|
+
if (this.patrolWaypoints.length > 0) this.startPatrol();
|
|
522
|
+
break;
|
|
523
|
+
case AiState.Alert:
|
|
524
|
+
this.event.stopMoveTo();
|
|
525
|
+
break;
|
|
526
|
+
case AiState.Combat:
|
|
527
|
+
this.comboCount = 0;
|
|
528
|
+
break;
|
|
529
|
+
case AiState.Flee:
|
|
530
|
+
if (this.target) this.fleeFromTarget();
|
|
531
|
+
break;
|
|
532
|
+
case AiState.Stunned:
|
|
533
|
+
this.event.stopMoveTo();
|
|
534
|
+
break;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Main AI behavior update loop
|
|
539
|
+
*/
|
|
540
|
+
updateAiBehavior() {
|
|
541
|
+
const currentTime = Date.now();
|
|
542
|
+
if (this.target && this.isTargetDefeated(this.target)) {
|
|
543
|
+
this.debugLog("combat", `Target ${this.target.id} is defeated, returning to idle`);
|
|
544
|
+
this.clearTarget();
|
|
545
|
+
this.changeState(AiState.Idle);
|
|
546
|
+
this.checkDamageTaken();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (this.groupBehavior) this.updateGroupBehavior();
|
|
550
|
+
if (this.state === AiState.Stunned) {
|
|
551
|
+
if (currentTime >= this.stunnedUntil) this.changeState(AiState.Combat);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (currentTime < this.actionLockedUntil) {
|
|
555
|
+
if (this.target) this.faceTarget();
|
|
556
|
+
if (currentTime - this.lastActionLockTraceTime > 250) {
|
|
557
|
+
this.lastActionLockTraceTime = currentTime;
|
|
558
|
+
this.traceLog("state", "waiting action recovery", {
|
|
559
|
+
remainingMs: this.actionLockedUntil - currentTime,
|
|
560
|
+
state: this.state,
|
|
561
|
+
targetId: this.target?.id
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
this.checkDamageTaken();
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (this.enemyType === EnemyType.Berserker && this.event.param[MAXHP]) {
|
|
568
|
+
const hpPercent = this.event.hp / this.event.param[MAXHP];
|
|
569
|
+
const berserkerModifier = Math.max(.3, hpPercent);
|
|
570
|
+
this.attackCooldown = 800 * berserkerModifier;
|
|
571
|
+
}
|
|
572
|
+
if (this.behaviorEnabled) this.updateBehavior(currentTime);
|
|
573
|
+
if (!this.target && this.state === AiState.Idle) {
|
|
574
|
+
const target = this.findNearestTarget();
|
|
575
|
+
if (target) this.engageTarget(target);
|
|
576
|
+
}
|
|
577
|
+
if (this.applyCustomBehavior(currentTime)) {
|
|
578
|
+
this.checkDamageTaken();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
switch (this.state) {
|
|
582
|
+
case AiState.Idle:
|
|
583
|
+
this.updateIdleBehavior();
|
|
584
|
+
break;
|
|
585
|
+
case AiState.Alert:
|
|
586
|
+
this.updateAlertBehavior();
|
|
587
|
+
break;
|
|
588
|
+
case AiState.Combat:
|
|
589
|
+
this.updateCombatBehavior(currentTime);
|
|
590
|
+
break;
|
|
591
|
+
case AiState.Flee:
|
|
592
|
+
this.updateFleeBehavior();
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
this.checkDamageTaken();
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Update idle behavior (patrolling)
|
|
599
|
+
*/
|
|
600
|
+
updateIdleBehavior() {
|
|
601
|
+
const target = this.findNearestTarget();
|
|
602
|
+
if (target) {
|
|
603
|
+
this.engageTarget(target);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (this.patrolWaypoints.length > 0) {
|
|
607
|
+
const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
|
|
608
|
+
if (this.getDistance(this.event, {
|
|
609
|
+
x: () => waypoint.x,
|
|
610
|
+
y: () => waypoint.y
|
|
611
|
+
}) < 10) {
|
|
612
|
+
this.currentPatrolIndex = (this.currentPatrolIndex + 1) % this.patrolWaypoints.length;
|
|
613
|
+
this.startPatrol();
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Update alert behavior
|
|
619
|
+
*/
|
|
620
|
+
updateAlertBehavior() {
|
|
621
|
+
if (this.target) {
|
|
622
|
+
this.faceTarget();
|
|
623
|
+
const distance = this.getDistance(this.event, this.target);
|
|
624
|
+
this.traceLog("movement", "alert update", {
|
|
625
|
+
targetId: this.target.id,
|
|
626
|
+
distance,
|
|
627
|
+
attackRange: this.attackRange,
|
|
628
|
+
visionRange: this.visionRange,
|
|
629
|
+
isMovingToTarget: this.isMovingToTarget
|
|
630
|
+
});
|
|
631
|
+
if (distance <= this.attackRange * 1.5) {
|
|
632
|
+
if (this.isMovingToTarget) {
|
|
633
|
+
this.isMovingToTarget = false;
|
|
634
|
+
this.event.stopMoveTo();
|
|
635
|
+
}
|
|
636
|
+
this.changeState(AiState.Combat);
|
|
637
|
+
} else if (distance <= this.visionRange * 1.5) {
|
|
638
|
+
if (!this.isMovingToTarget) {
|
|
639
|
+
this.debugLog("movement", `Alert approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
640
|
+
this.requestTargetMovement();
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
this.debugLog("combat", `Alert target out of range (dist=${distance.toFixed(1)})`);
|
|
644
|
+
this.clearTarget();
|
|
645
|
+
this.changeState(AiState.Idle);
|
|
646
|
+
}
|
|
647
|
+
} else this.changeState(AiState.Idle);
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Update combat behavior
|
|
651
|
+
*/
|
|
652
|
+
updateCombatBehavior(currentTime) {
|
|
653
|
+
if (!this.target) {
|
|
654
|
+
this.debugLog("combat", "No target, returning to idle");
|
|
655
|
+
this.changeState(AiState.Idle);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
const distance = this.getDistance(this.event, this.target);
|
|
659
|
+
this.traceLog("combat", "combat update", {
|
|
660
|
+
targetId: this.target.id,
|
|
661
|
+
distance,
|
|
662
|
+
attackRange: this.attackRange,
|
|
663
|
+
visionRange: this.visionRange,
|
|
664
|
+
isMovingToTarget: this.isMovingToTarget,
|
|
665
|
+
behaviorEnabled: this.behaviorEnabled,
|
|
666
|
+
behaviorMode: this.behaviorMode
|
|
667
|
+
});
|
|
668
|
+
if (distance > this.visionRange * 1.5) {
|
|
669
|
+
this.debugLog("combat", `Target out of range (dist=${distance.toFixed(1)}, maxRange=${(this.visionRange * 1.5).toFixed(1)})`);
|
|
670
|
+
this.clearTarget();
|
|
671
|
+
this.changeState(AiState.Idle);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (this.event.param[MAXHP]) {
|
|
675
|
+
const hpPercent = this.event.hp / this.event.param[MAXHP];
|
|
676
|
+
if (hpPercent <= this.fleeThreshold) {
|
|
677
|
+
this.debugLog("combat", `HP low (${(hpPercent * 100).toFixed(0)}%), fleeing`);
|
|
678
|
+
this.isMovingToTarget = false;
|
|
679
|
+
this.changeState(AiState.Flee);
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
if (this.canDodge() && this.shouldDodge()) {
|
|
684
|
+
this.debugLog("combat", "Attempting dodge");
|
|
685
|
+
if (this.tryDodge()) {
|
|
686
|
+
this.isMovingToTarget = false;
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
if (this.behaviorEnabled) {
|
|
691
|
+
if (this.behaviorMode === "tactical") this.handleTacticalMovement(distance);
|
|
692
|
+
else if (this.behaviorMode === "assault") this.handleAssaultMovement(distance);
|
|
693
|
+
else if (this.behaviorMode === "retreat") {
|
|
694
|
+
this.isMovingToTarget = false;
|
|
695
|
+
this.fleeFromTarget();
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
if (this.behaviorEnabled && this.behaviorMode === "assault") {} else if (this.behaviorEnabled && this.behaviorMode === "tactical") {} else if (this.enemyType === EnemyType.Ranged) {
|
|
700
|
+
if (distance < this.attackRange * .6) {
|
|
701
|
+
this.debugLog("movement", `Retreating (dist=${distance.toFixed(1)}, minRange=${(this.attackRange * .6).toFixed(1)})`);
|
|
702
|
+
this.isMovingToTarget = false;
|
|
703
|
+
this.retreatFromTarget();
|
|
704
|
+
} else if (distance > this.attackRange) {
|
|
705
|
+
if (!this.isMovingToTarget) {
|
|
706
|
+
this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
707
|
+
this.requestTargetMovement();
|
|
708
|
+
}
|
|
709
|
+
} else if (this.isMovingToTarget) {
|
|
710
|
+
this.debugLog("movement", `In range, stopping (dist=${distance.toFixed(1)})`);
|
|
711
|
+
this.isMovingToTarget = false;
|
|
712
|
+
this.event.stopMoveTo();
|
|
713
|
+
}
|
|
714
|
+
} else if (distance > this.attackRange) {
|
|
715
|
+
if (!this.isMovingToTarget) {
|
|
716
|
+
this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
717
|
+
this.requestTargetMovement();
|
|
718
|
+
}
|
|
719
|
+
} else if (this.isMovingToTarget) {
|
|
720
|
+
this.debugLog("movement", `In range, stopping (dist=${distance.toFixed(1)})`);
|
|
721
|
+
this.isMovingToTarget = false;
|
|
722
|
+
this.event.stopMoveTo();
|
|
723
|
+
}
|
|
724
|
+
if (distance <= this.attackRange && currentTime - this.lastAttackTime >= this.attackCooldown) {
|
|
725
|
+
if (!this.chargingAttack) {
|
|
726
|
+
this.debugLog("attack", `Attacking (dist=${distance.toFixed(1)}, cooldown=${this.attackCooldown}ms)`);
|
|
727
|
+
this.selectAndPerformAttack();
|
|
728
|
+
this.lastAttackTime = currentTime;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Update flee behavior
|
|
734
|
+
*/
|
|
735
|
+
updateFleeBehavior() {
|
|
736
|
+
if (!this.target) {
|
|
737
|
+
this.changeState(AiState.Idle);
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
const distance = this.getDistance(this.event, this.target);
|
|
741
|
+
if (this.event.param[MAXHP]) {
|
|
742
|
+
if (this.event.hp / this.event.param[MAXHP] > this.fleeThreshold * 1.5 || distance > this.visionRange * 2) {
|
|
743
|
+
this.changeState(AiState.Combat);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
this.fleeFromTarget();
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* Select and perform an attack pattern
|
|
751
|
+
*/
|
|
752
|
+
selectAndPerformAttack() {
|
|
753
|
+
if (!this.target) return;
|
|
754
|
+
if (this.comboCount > 0 && this.comboCount < this.comboMax) {
|
|
755
|
+
this.debugLog("attack", `Continuing combo (${this.comboCount}/${this.comboMax})`);
|
|
756
|
+
this.performComboAttack();
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const pattern = this.selectAttackPattern();
|
|
760
|
+
this.debugLog("attack", `Selected pattern: ${pattern}`);
|
|
761
|
+
this.performAttackPattern(pattern);
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Select attack pattern with weighted probability
|
|
765
|
+
*/
|
|
766
|
+
selectAttackPattern() {
|
|
767
|
+
const weights = {
|
|
768
|
+
[AttackPattern.Melee]: 40,
|
|
769
|
+
[AttackPattern.Combo]: 25,
|
|
770
|
+
[AttackPattern.Charged]: 15,
|
|
771
|
+
[AttackPattern.Zone]: 10,
|
|
772
|
+
[AttackPattern.DashAttack]: 10
|
|
773
|
+
};
|
|
774
|
+
switch (this.enemyType) {
|
|
775
|
+
case EnemyType.Aggressive:
|
|
776
|
+
weights[AttackPattern.Combo] += 20;
|
|
777
|
+
weights[AttackPattern.DashAttack] += 15;
|
|
778
|
+
break;
|
|
779
|
+
case EnemyType.Defensive:
|
|
780
|
+
weights[AttackPattern.Charged] += 25;
|
|
781
|
+
break;
|
|
782
|
+
case EnemyType.Ranged:
|
|
783
|
+
weights[AttackPattern.Zone] += 20;
|
|
784
|
+
break;
|
|
785
|
+
case EnemyType.Tank:
|
|
786
|
+
weights[AttackPattern.Charged] += 30;
|
|
787
|
+
weights[AttackPattern.Zone] += 15;
|
|
788
|
+
break;
|
|
789
|
+
case EnemyType.Berserker:
|
|
790
|
+
weights[AttackPattern.Combo] += 35;
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
let total = 0;
|
|
794
|
+
const available = [];
|
|
795
|
+
this.attackPatterns.forEach((p) => {
|
|
796
|
+
const weight = weights[p] || 10;
|
|
797
|
+
total += weight;
|
|
798
|
+
available.push({
|
|
799
|
+
pattern: p,
|
|
800
|
+
weight
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
let random = Math.random() * total;
|
|
804
|
+
for (const item of available) {
|
|
805
|
+
random -= item.weight;
|
|
806
|
+
if (random <= 0) return item.pattern;
|
|
807
|
+
}
|
|
808
|
+
return this.attackPatterns[0] || AttackPattern.Melee;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Perform attack pattern
|
|
812
|
+
*/
|
|
813
|
+
performAttackPattern(pattern) {
|
|
814
|
+
switch (pattern) {
|
|
815
|
+
case AttackPattern.Melee:
|
|
816
|
+
this.performMeleeAttack();
|
|
817
|
+
break;
|
|
818
|
+
case AttackPattern.Combo:
|
|
819
|
+
this.performComboAttack();
|
|
820
|
+
break;
|
|
821
|
+
case AttackPattern.Charged:
|
|
822
|
+
this.performChargedAttack();
|
|
823
|
+
break;
|
|
824
|
+
case AttackPattern.Zone:
|
|
825
|
+
this.performZoneAttack();
|
|
826
|
+
break;
|
|
827
|
+
case AttackPattern.DashAttack:
|
|
828
|
+
this.performDashAttack();
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
/**
|
|
833
|
+
* Perform melee attack
|
|
834
|
+
* Uses skill if configured, otherwise creates hitbox
|
|
835
|
+
*/
|
|
836
|
+
performMeleeAttack() {
|
|
837
|
+
if (!this.target) return;
|
|
838
|
+
const profile = this.getAttackProfile(AttackPattern.Melee);
|
|
839
|
+
this.faceTarget({ force: true });
|
|
840
|
+
this.lockForAttack(profile, AttackPattern.Melee);
|
|
841
|
+
this.telegraphAttack(profile);
|
|
842
|
+
this.playAttackVisual(profile, AttackPattern.Melee);
|
|
843
|
+
this.scheduleAttackStartup(profile, () => {
|
|
844
|
+
this.executeMeleeAttack(profile, AttackPattern.Melee);
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
executeMeleeAttack(profile, pattern) {
|
|
848
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
849
|
+
this.debugLog("attack", `Applying ${pattern} hit`);
|
|
850
|
+
if (this.attackSkill) {
|
|
851
|
+
const resolvedSkill = this.resolveUsable(this.attackSkill);
|
|
852
|
+
try {
|
|
853
|
+
executeActionBattleUse({
|
|
854
|
+
attacker: this.event,
|
|
855
|
+
target: this.target,
|
|
856
|
+
usable: resolvedSkill,
|
|
857
|
+
skill: resolvedSkill,
|
|
858
|
+
pattern,
|
|
859
|
+
profile
|
|
860
|
+
});
|
|
861
|
+
} catch (e) {
|
|
862
|
+
this.performBasicHitbox(profile, pattern);
|
|
863
|
+
}
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
const weapon = resolveActionBattleWeapon(this.event);
|
|
867
|
+
if (weapon && executeActionBattleUse({
|
|
868
|
+
attacker: this.event,
|
|
869
|
+
target: this.target,
|
|
870
|
+
usable: weapon,
|
|
871
|
+
weapon,
|
|
872
|
+
pattern,
|
|
873
|
+
profile
|
|
874
|
+
})) return;
|
|
875
|
+
this.performBasicHitbox(profile, pattern);
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Perform basic hitbox attack when no skill is set
|
|
879
|
+
*/
|
|
880
|
+
performBasicHitbox(profile = this.getAttackProfile(AttackPattern.Melee), pattern = AttackPattern.Melee) {
|
|
881
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
882
|
+
const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
|
|
883
|
+
runActionBattleActiveHitbox({
|
|
884
|
+
...profile,
|
|
885
|
+
startupMs: 0
|
|
886
|
+
}, () => this.resolveBasicHitboxes(), (hitboxes) => {
|
|
887
|
+
this.processHitboxHits(hitboxes, hitTracker, profile, pattern);
|
|
888
|
+
}, (scheduled, delay) => this.schedule(scheduled, delay));
|
|
889
|
+
}
|
|
890
|
+
resolveBasicHitboxes() {
|
|
891
|
+
if (!this.target || this.isTargetDefeated(this.target)) return [];
|
|
892
|
+
const eventX = this.event.x();
|
|
893
|
+
const eventY = this.event.y();
|
|
894
|
+
const dx = this.target.x() - eventX;
|
|
895
|
+
const dy = this.target.y() - eventY;
|
|
896
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
897
|
+
if (dist === 0) return [];
|
|
898
|
+
const dirX = dx / dist;
|
|
899
|
+
const dirY = dy / dist;
|
|
900
|
+
return [{
|
|
901
|
+
x: eventX + dirX * 30,
|
|
902
|
+
y: eventY + dirY * 30,
|
|
903
|
+
width: 40,
|
|
904
|
+
height: 40
|
|
905
|
+
}];
|
|
906
|
+
}
|
|
907
|
+
queryHitboxCandidates(hitboxes) {
|
|
908
|
+
const map = this.event.getCurrentMap();
|
|
909
|
+
if (!map || typeof map.queryHitbox !== "function") return [];
|
|
910
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
911
|
+
for (const hitbox of hitboxes) for (const hit of map.queryHitbox(hitbox, {
|
|
912
|
+
excludeIds: [this.event.id],
|
|
913
|
+
kinds: ["players", "events"]
|
|
914
|
+
})) if (hit?.id) candidates.set(hit.id, hit);
|
|
915
|
+
return Array.from(candidates.values());
|
|
916
|
+
}
|
|
917
|
+
processHitboxHits(hitboxes, hitTracker, profile, pattern) {
|
|
918
|
+
for (const hit of this.queryHitboxCandidates(hitboxes)) if (hit !== this.event && this.canTarget(hit) && !this.isTargetDefeated(hit) && hitTracker.tryHit(hit)) this.applyHit(hit, void 0, profile, pattern);
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Apply hit to target using RPGJS damage system with knockback
|
|
922
|
+
*
|
|
923
|
+
* Calculates damage using RPGJS formula, applies knockback based on
|
|
924
|
+
* equipped weapon's knockbackForce property, and triggers visual effects.
|
|
925
|
+
* Supports hooks for customizing behavior.
|
|
926
|
+
*
|
|
927
|
+
* @param target - The player or entity being hit
|
|
928
|
+
* @param hooks - Optional hooks for customizing hit behavior
|
|
929
|
+
* @returns The hit result containing damage and knockback info
|
|
930
|
+
*
|
|
931
|
+
* @example
|
|
932
|
+
* ```ts
|
|
933
|
+
* // Basic hit
|
|
934
|
+
* this.applyHit(player);
|
|
935
|
+
*
|
|
936
|
+
* // With custom hooks
|
|
937
|
+
* this.applyHit(player, {
|
|
938
|
+
* onBeforeHit(result) {
|
|
939
|
+
* result.knockbackForce *= 1.5; // Increase knockback
|
|
940
|
+
* return result;
|
|
941
|
+
* },
|
|
942
|
+
* onAfterHit(result) {
|
|
943
|
+
* console.log(`Dealt ${result.damage} damage!`);
|
|
944
|
+
* }
|
|
945
|
+
* });
|
|
946
|
+
* ```
|
|
947
|
+
*/
|
|
948
|
+
applyHit(target, hooks, profile = this.getAttackProfile(AttackPattern.Melee), pattern = AttackPattern.Melee) {
|
|
949
|
+
if (this.isTargetDefeated(target)) return {
|
|
950
|
+
damage: 0,
|
|
951
|
+
knockbackForce: 0,
|
|
952
|
+
knockbackDuration: 0,
|
|
953
|
+
defeated: true,
|
|
954
|
+
attacker: this.event,
|
|
955
|
+
target
|
|
956
|
+
};
|
|
957
|
+
if (isActionBattleEntityInvincible(target)) return {
|
|
958
|
+
damage: 0,
|
|
959
|
+
knockbackForce: 0,
|
|
960
|
+
knockbackDuration: 0,
|
|
961
|
+
defeated: false,
|
|
962
|
+
attacker: this.event,
|
|
963
|
+
target
|
|
964
|
+
};
|
|
965
|
+
const { damage } = target.applyDamage(this.event);
|
|
966
|
+
let hitResult = {
|
|
967
|
+
damage,
|
|
968
|
+
knockbackForce: this.getWeaponKnockbackForce(),
|
|
969
|
+
knockbackDuration: DEFAULT_KNOCKBACK.duration,
|
|
970
|
+
defeated: target.hp <= 0,
|
|
971
|
+
attacker: this.event,
|
|
972
|
+
target
|
|
973
|
+
};
|
|
974
|
+
if (hooks?.onBeforeHit) {
|
|
975
|
+
const modified = hooks.onBeforeHit(hitResult);
|
|
976
|
+
if (modified) hitResult = modified;
|
|
977
|
+
}
|
|
978
|
+
withActionBattleAnimationUnlocked(target, () => {
|
|
979
|
+
emitActionBattleClientVisual({
|
|
980
|
+
moment: "hurt",
|
|
981
|
+
entity: this.event,
|
|
982
|
+
target,
|
|
983
|
+
attacker: this.event,
|
|
984
|
+
damage: hitResult.damage,
|
|
985
|
+
result: hitResult
|
|
986
|
+
});
|
|
987
|
+
});
|
|
988
|
+
setActionBattleInvincibility(target, profile.reaction.invincibilityMs);
|
|
989
|
+
if (hitResult.knockbackForce > 0) {
|
|
990
|
+
const dx = target.x() - this.event.x();
|
|
991
|
+
const dy = target.y() - this.event.y();
|
|
992
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
993
|
+
if (distance > 0) {
|
|
994
|
+
const knockbackDirection = {
|
|
995
|
+
x: dx / distance,
|
|
996
|
+
y: dy / distance
|
|
997
|
+
};
|
|
998
|
+
target.knockback(knockbackDirection, hitResult.knockbackForce, hitResult.knockbackDuration);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
if (hooks?.onAfterHit) hooks.onAfterHit(hitResult);
|
|
1002
|
+
const targetAi = target.battleAi;
|
|
1003
|
+
if (targetAi) targetAi.handleDamage(this.event, {
|
|
1004
|
+
damage: hitResult.damage,
|
|
1005
|
+
defeated: hitResult.defeated,
|
|
1006
|
+
raw: void 0,
|
|
1007
|
+
reaction: profile.reaction
|
|
1008
|
+
});
|
|
1009
|
+
return hitResult;
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* Get knockback force from equipped weapon
|
|
1013
|
+
*
|
|
1014
|
+
* Retrieves the knockbackForce property from the event's equipped weapon.
|
|
1015
|
+
* Falls back to DEFAULT_KNOCKBACK.force if no weapon or property is set.
|
|
1016
|
+
*
|
|
1017
|
+
* @returns Knockback force value
|
|
1018
|
+
*
|
|
1019
|
+
* @example
|
|
1020
|
+
* ```ts
|
|
1021
|
+
* // Weapon with knockbackForce: 80
|
|
1022
|
+
* const force = this.getWeaponKnockbackForce(); // 80
|
|
1023
|
+
*
|
|
1024
|
+
* // No weapon equipped
|
|
1025
|
+
* const force = this.getWeaponKnockbackForce(); // 50 (default)
|
|
1026
|
+
* ```
|
|
1027
|
+
*/
|
|
1028
|
+
getWeaponKnockbackForce() {
|
|
1029
|
+
try {
|
|
1030
|
+
const equipments = this.event.equipments?.() || [];
|
|
1031
|
+
for (const item of equipments) {
|
|
1032
|
+
const itemData = this.event.databaseById?.(item.id());
|
|
1033
|
+
if (itemData?._type === "weapon" && itemData.knockbackForce !== void 0) return itemData.knockbackForce;
|
|
1034
|
+
}
|
|
1035
|
+
} catch {}
|
|
1036
|
+
return DEFAULT_KNOCKBACK.force;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Perform combo attack
|
|
1040
|
+
*/
|
|
1041
|
+
performComboAttack() {
|
|
1042
|
+
if (!this.target) return;
|
|
1043
|
+
this.comboCount++;
|
|
1044
|
+
const profile = this.getAttackProfile(AttackPattern.Combo);
|
|
1045
|
+
this.faceTarget({ force: true });
|
|
1046
|
+
this.lockForAttack(profile, AttackPattern.Combo);
|
|
1047
|
+
this.telegraphAttack(profile);
|
|
1048
|
+
this.playAttackVisual(profile, AttackPattern.Combo);
|
|
1049
|
+
this.scheduleAttackStartup(profile, () => {
|
|
1050
|
+
this.executeMeleeAttack(profile, AttackPattern.Combo);
|
|
1051
|
+
});
|
|
1052
|
+
if (this.comboCount < this.comboMax) this.schedule(() => {
|
|
1053
|
+
if (this.target && this.state === AiState.Combat) this.performComboAttack();
|
|
1054
|
+
else this.comboCount = 0;
|
|
1055
|
+
}, 300);
|
|
1056
|
+
else this.comboCount = 0;
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Perform charged attack
|
|
1060
|
+
*/
|
|
1061
|
+
performChargedAttack() {
|
|
1062
|
+
if (!this.target) return;
|
|
1063
|
+
const profile = this.getAttackProfile(AttackPattern.Charged);
|
|
1064
|
+
this.chargingAttack = true;
|
|
1065
|
+
this.faceTarget({ force: true });
|
|
1066
|
+
this.lockForAttack(profile, AttackPattern.Charged);
|
|
1067
|
+
this.telegraphAttack(profile);
|
|
1068
|
+
this.playAttackVisual(profile, AttackPattern.Charged, { repeat: 2 });
|
|
1069
|
+
this.scheduleAttackStartup(profile, () => {
|
|
1070
|
+
if (!this.target || this.state !== AiState.Combat) {
|
|
1071
|
+
this.chargingAttack = false;
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
this.executeMeleeAttack(profile, AttackPattern.Charged);
|
|
1075
|
+
});
|
|
1076
|
+
this.schedule(() => {
|
|
1077
|
+
this.chargingAttack = false;
|
|
1078
|
+
}, profile.totalDurationMs);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Perform zone attack (360 degrees)
|
|
1082
|
+
*/
|
|
1083
|
+
performZoneAttack() {
|
|
1084
|
+
const profile = this.getAttackProfile(AttackPattern.Zone);
|
|
1085
|
+
this.lockForAttack(profile, AttackPattern.Zone);
|
|
1086
|
+
this.telegraphAttack(profile);
|
|
1087
|
+
this.playAttackVisual(profile, AttackPattern.Zone);
|
|
1088
|
+
const eventX = this.event.x();
|
|
1089
|
+
const eventY = this.event.y();
|
|
1090
|
+
const radius = 50;
|
|
1091
|
+
const hitboxes = [];
|
|
1092
|
+
[
|
|
1093
|
+
0,
|
|
1094
|
+
90,
|
|
1095
|
+
180,
|
|
1096
|
+
270
|
|
1097
|
+
].forEach((angle) => {
|
|
1098
|
+
const rad = angle * Math.PI / 180;
|
|
1099
|
+
hitboxes.push({
|
|
1100
|
+
x: eventX + Math.cos(rad) * radius,
|
|
1101
|
+
y: eventY + Math.sin(rad) * radius,
|
|
1102
|
+
width: 40,
|
|
1103
|
+
height: 40
|
|
1104
|
+
});
|
|
1105
|
+
});
|
|
1106
|
+
this.scheduleAttackStartup(profile, () => {
|
|
1107
|
+
const hitTracker = new ActionBattleHitTracker(profile.hitPolicy);
|
|
1108
|
+
runActionBattleActiveHitbox({
|
|
1109
|
+
...profile,
|
|
1110
|
+
startupMs: 0
|
|
1111
|
+
}, () => hitboxes, (activeHitboxes) => {
|
|
1112
|
+
this.processHitboxHits(activeHitboxes, hitTracker, profile, AttackPattern.Zone);
|
|
1113
|
+
}, (scheduled, delay) => this.schedule(scheduled, delay));
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Perform dash attack
|
|
1118
|
+
*/
|
|
1119
|
+
performDashAttack() {
|
|
1120
|
+
if (!this.target || this.isTargetDefeated(this.target)) return;
|
|
1121
|
+
const profile = this.getAttackProfile(AttackPattern.DashAttack);
|
|
1122
|
+
const dx = this.target.x() - this.event.x();
|
|
1123
|
+
const dy = this.target.y() - this.event.y();
|
|
1124
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1125
|
+
if (dist === 0) return;
|
|
1126
|
+
const dirX = dx / dist;
|
|
1127
|
+
const dirY = dy / dist;
|
|
1128
|
+
this.faceTarget({ force: true });
|
|
1129
|
+
this.lockForAttack(profile, AttackPattern.DashAttack);
|
|
1130
|
+
this.telegraphAttack(profile);
|
|
1131
|
+
this.playAttackVisual(profile, AttackPattern.DashAttack);
|
|
1132
|
+
this.scheduleAttackStartup(profile, () => {
|
|
1133
|
+
if (!this.target || this.state !== AiState.Combat) return;
|
|
1134
|
+
safeActionBattleDash(this.event, {
|
|
1135
|
+
x: dirX,
|
|
1136
|
+
y: dirY
|
|
1137
|
+
}, 10, 200);
|
|
1138
|
+
this.schedule(() => {
|
|
1139
|
+
if (!this.target || this.state !== AiState.Combat) return;
|
|
1140
|
+
this.executeMeleeAttack(profile, AttackPattern.DashAttack);
|
|
1141
|
+
}, 200);
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
getAttackProfile(pattern) {
|
|
1145
|
+
return this.attackProfiles[pattern] ?? this.attackProfiles.melee;
|
|
1146
|
+
}
|
|
1147
|
+
playAttackVisual(profile, pattern, animationDefaults) {
|
|
1148
|
+
const moment = profile.animationKey === "castSkill" || profile.animationKey === "castSpell" ? "castSkill" : "attack";
|
|
1149
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
1150
|
+
emitActionBattleClientVisual({
|
|
1151
|
+
moment,
|
|
1152
|
+
entity: this.event,
|
|
1153
|
+
target: this.target ?? void 0,
|
|
1154
|
+
pattern,
|
|
1155
|
+
animations: this.animations,
|
|
1156
|
+
animationDefaults
|
|
1157
|
+
});
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
telegraphAttack(profile) {
|
|
1161
|
+
if (profile.startupMs <= 0) return;
|
|
1162
|
+
this.event.flash({
|
|
1163
|
+
type: "tint",
|
|
1164
|
+
tint: "white",
|
|
1165
|
+
duration: Math.min(profile.startupMs, 300),
|
|
1166
|
+
cycles: 1
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
scheduleAttackStartup(profile, callback) {
|
|
1170
|
+
return scheduleActionBattleStartup(profile, callback, (scheduled, delay) => this.schedule(scheduled, delay));
|
|
1171
|
+
}
|
|
1172
|
+
/**
|
|
1173
|
+
* Face the current target with hysteresis to prevent animation flickering
|
|
1174
|
+
*
|
|
1175
|
+
* Uses multiple strategies to prevent flickering:
|
|
1176
|
+
* 1. When very close to target (collision), keep current direction
|
|
1177
|
+
* 2. When near diagonal, require significant difference to change
|
|
1178
|
+
* 3. Only change if direction is clearly wrong (opposite)
|
|
1179
|
+
*/
|
|
1180
|
+
faceTarget(options = {}) {
|
|
1181
|
+
if (!this.target) return;
|
|
1182
|
+
const dx = this.target.x() - this.event.x();
|
|
1183
|
+
const dy = this.target.y() - this.event.y();
|
|
1184
|
+
const absX = Math.abs(dx);
|
|
1185
|
+
const absY = Math.abs(dy);
|
|
1186
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
1187
|
+
if (this.lastFacingDirection && distance < 4) return;
|
|
1188
|
+
let newDirection;
|
|
1189
|
+
if (absX >= absY) newDirection = dx >= 0 ? "right" : "left";
|
|
1190
|
+
else newDirection = dy >= 0 ? "down" : "up";
|
|
1191
|
+
const hysteresisThreshold = .2;
|
|
1192
|
+
const ratio = absX > 0 || absY > 0 ? Math.min(absX, absY) / Math.max(absX, absY) : 0;
|
|
1193
|
+
if (this.lastFacingDirection && ratio > 1 - hysteresisThreshold) {
|
|
1194
|
+
if (!(this.lastFacingDirection === "left" && dx > 20 || this.lastFacingDirection === "right" && dx < -20 || this.lastFacingDirection === "up" && dy > 20 || this.lastFacingDirection === "down" && dy < -20)) return;
|
|
1195
|
+
}
|
|
1196
|
+
this.lastFacingDirection = newDirection;
|
|
1197
|
+
if (options.force) applyActionBattleAttackDirection(this.event, newDirection);
|
|
1198
|
+
else this.event.changeDirection(newDirection);
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Try to dodge
|
|
1202
|
+
*/
|
|
1203
|
+
tryDodge() {
|
|
1204
|
+
const currentTime = Date.now();
|
|
1205
|
+
if (currentTime - this.lastDodgeTime < this.dodgeCooldown) {
|
|
1206
|
+
this.debugLog("dodge", `Dodge on cooldown (${this.dodgeCooldown - (currentTime - this.lastDodgeTime)}ms remaining)`);
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
if (Math.random() > this.dodgeChance) {
|
|
1210
|
+
this.debugLog("dodge", `Dodge roll failed (chance=${(this.dodgeChance * 100).toFixed(0)}%)`);
|
|
1211
|
+
return false;
|
|
1212
|
+
}
|
|
1213
|
+
if (!this.target) return false;
|
|
1214
|
+
const dx = this.target.x() - this.event.x();
|
|
1215
|
+
const dy = this.target.y() - this.event.y();
|
|
1216
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1217
|
+
if (dist === 0) return false;
|
|
1218
|
+
const dodgeDirX = -dy / dist;
|
|
1219
|
+
const dodgeDirY = dx / dist;
|
|
1220
|
+
const side = Math.random() > .5 ? 1 : -1;
|
|
1221
|
+
this.debugLog("dodge", `Dodging (dir=${side > 0 ? "right" : "left"})`);
|
|
1222
|
+
if (!safeActionBattleDash(this.event, {
|
|
1223
|
+
x: dodgeDirX * side,
|
|
1224
|
+
y: dodgeDirY * side
|
|
1225
|
+
}, 12, 300)) return false;
|
|
1226
|
+
this.lastDodgeTime = currentTime;
|
|
1227
|
+
if (this.enemyType === EnemyType.Defensive && Math.random() < .5) {
|
|
1228
|
+
this.debugLog("dodge", "Counter-attack after dodge");
|
|
1229
|
+
this.schedule(() => {
|
|
1230
|
+
if (this.target && this.state === AiState.Combat) this.selectAndPerformAttack();
|
|
1231
|
+
}, 400);
|
|
1232
|
+
}
|
|
1233
|
+
return true;
|
|
1234
|
+
}
|
|
1235
|
+
canDodge() {
|
|
1236
|
+
if (this.dodgeChance === 0) return false;
|
|
1237
|
+
return Date.now() - this.lastDodgeTime >= this.dodgeCooldown;
|
|
1238
|
+
}
|
|
1239
|
+
shouldDodge() {
|
|
1240
|
+
if (!this.target) return false;
|
|
1241
|
+
return this.getDistance(this.event, this.target) < this.attackRange * .8;
|
|
1242
|
+
}
|
|
1243
|
+
/**
|
|
1244
|
+
* Flee from target
|
|
1245
|
+
*/
|
|
1246
|
+
fleeFromTarget() {
|
|
1247
|
+
if (!this.target) return;
|
|
1248
|
+
const dx = this.event.x() - this.target.x();
|
|
1249
|
+
const dy = this.event.y() - this.target.y();
|
|
1250
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1251
|
+
if (dist === 0) return;
|
|
1252
|
+
const fleeTarget = {
|
|
1253
|
+
x: this.event.x() + dx / dist * 200,
|
|
1254
|
+
y: this.event.y() + dy / dist * 200
|
|
1255
|
+
};
|
|
1256
|
+
this.requestMoveTo(fleeTarget);
|
|
1257
|
+
}
|
|
1258
|
+
/**
|
|
1259
|
+
* Retreat from target (temporary)
|
|
1260
|
+
*/
|
|
1261
|
+
retreatFromTarget() {
|
|
1262
|
+
if (!this.target) return;
|
|
1263
|
+
const currentTime = Date.now();
|
|
1264
|
+
if (currentTime - this.lastRetreatTime < this.retreatCooldown) return;
|
|
1265
|
+
const dx = this.event.x() - this.target.x();
|
|
1266
|
+
const dy = this.event.y() - this.target.y();
|
|
1267
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1268
|
+
if (dist === 0) return;
|
|
1269
|
+
if (!safeActionBattleDash(this.event, {
|
|
1270
|
+
x: dx / dist,
|
|
1271
|
+
y: dy / dist
|
|
1272
|
+
}, 8, 200)) return;
|
|
1273
|
+
this.lastRetreatTime = currentTime;
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Check damage taken for retreat decision
|
|
1277
|
+
*/
|
|
1278
|
+
checkDamageTaken() {
|
|
1279
|
+
const currentTime = Date.now();
|
|
1280
|
+
if (currentTime - this.lastHpCheck >= this.damageCheckInterval) {
|
|
1281
|
+
this.recentDamageTaken = 0;
|
|
1282
|
+
this.lastHpCheck = currentTime;
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Start patrol
|
|
1287
|
+
*/
|
|
1288
|
+
startPatrol() {
|
|
1289
|
+
if (this.patrolWaypoints.length === 0) return;
|
|
1290
|
+
const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
|
|
1291
|
+
this.requestMoveTo({
|
|
1292
|
+
x: waypoint.x,
|
|
1293
|
+
y: waypoint.y
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Update group behavior
|
|
1298
|
+
*/
|
|
1299
|
+
updateGroupBehavior() {
|
|
1300
|
+
if (!this.groupBehavior) return;
|
|
1301
|
+
this.groupUpdateInterval++;
|
|
1302
|
+
if (this.groupUpdateInterval >= 20) {
|
|
1303
|
+
this.groupUpdateInterval = 0;
|
|
1304
|
+
this.findNearbyEnemies();
|
|
1305
|
+
}
|
|
1306
|
+
if (this.nearbyEnemies.length > 0 && this.target && this.state === AiState.Combat) this.applyFormation();
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Find nearby enemies
|
|
1310
|
+
*/
|
|
1311
|
+
findNearbyEnemies() {
|
|
1312
|
+
this.nearbyEnemies = [];
|
|
1313
|
+
const map = this.event.getCurrentMap();
|
|
1314
|
+
if (!map) return;
|
|
1315
|
+
const allEvents = Object.values(map.events());
|
|
1316
|
+
const groupRadius = 150;
|
|
1317
|
+
allEvents.forEach((event) => {
|
|
1318
|
+
if (event === this.event) return;
|
|
1319
|
+
const ai = event.battleAi;
|
|
1320
|
+
if (ai && ai.groupBehavior) {
|
|
1321
|
+
if (this.getDistance(this.event, event) <= groupRadius) this.nearbyEnemies.push(ai);
|
|
1322
|
+
}
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Apply formation around target
|
|
1327
|
+
*/
|
|
1328
|
+
applyFormation() {
|
|
1329
|
+
if (!this.target || this.nearbyEnemies.length === 0) return;
|
|
1330
|
+
const totalEnemies = this.nearbyEnemies.length + 1;
|
|
1331
|
+
const angleStep = 2 * Math.PI / totalEnemies;
|
|
1332
|
+
let ourIndex = 0;
|
|
1333
|
+
for (let i = 0; i < this.nearbyEnemies.length; i++) if (this.nearbyEnemies[i].event.id < this.event.id) ourIndex++;
|
|
1334
|
+
const angle = angleStep * ourIndex;
|
|
1335
|
+
const formationRadius = this.attackRange * 1.2;
|
|
1336
|
+
const formationX = this.target.x() + Math.cos(angle) * formationRadius;
|
|
1337
|
+
const formationY = this.target.y() + Math.sin(angle) * formationRadius;
|
|
1338
|
+
if (Math.sqrt(Math.pow(this.event.x() - formationX, 2) + Math.pow(this.event.y() - formationY, 2)) > 20) this.requestMoveTo({
|
|
1339
|
+
x: formationX,
|
|
1340
|
+
y: formationY
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Handle player entering vision
|
|
1345
|
+
*/
|
|
1346
|
+
onDetectInShape(target, shape) {
|
|
1347
|
+
const canTarget = this.canTarget(target);
|
|
1348
|
+
const defeated = this.isTargetDefeated(target);
|
|
1349
|
+
this.traceLog("vision", "detect in shape", {
|
|
1350
|
+
targetId: target?.id,
|
|
1351
|
+
shapeId: shape?.id,
|
|
1352
|
+
canTarget,
|
|
1353
|
+
defeated,
|
|
1354
|
+
state: this.state,
|
|
1355
|
+
targetHp: target?.hp
|
|
1356
|
+
});
|
|
1357
|
+
if (!canTarget || defeated) return;
|
|
1358
|
+
this.debugLog("vision", `Target ${target.id} entered vision (state=${this.state})`);
|
|
1359
|
+
this.engageTarget(target);
|
|
1360
|
+
}
|
|
1361
|
+
engageTarget(target) {
|
|
1362
|
+
this.traceLog("target", "engage target", {
|
|
1363
|
+
targetId: target.id,
|
|
1364
|
+
previousTargetId: this.target?.id,
|
|
1365
|
+
state: this.state,
|
|
1366
|
+
distance: this.getDistance(this.event, target)
|
|
1367
|
+
});
|
|
1368
|
+
this.target = target;
|
|
1369
|
+
if (this.state === AiState.Idle) this.changeState(AiState.Alert);
|
|
1370
|
+
else if (this.state === AiState.Alert) this.changeState(AiState.Combat);
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* Handle player leaving vision
|
|
1374
|
+
*/
|
|
1375
|
+
onDetectOutShape(target, shape) {
|
|
1376
|
+
this.debugLog("vision", `Target ${target.id} left vision (wasTarget=${this.target === target})`);
|
|
1377
|
+
this.traceLog("vision", "detect out shape", {
|
|
1378
|
+
targetId: target?.id,
|
|
1379
|
+
shapeId: shape?.id,
|
|
1380
|
+
wasTarget: this.target === target,
|
|
1381
|
+
state: this.state
|
|
1382
|
+
});
|
|
1383
|
+
if (this.target === target) {
|
|
1384
|
+
this.clearTarget();
|
|
1385
|
+
this.changeState(AiState.Idle);
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Handle taking damage (called from server.ts)
|
|
1390
|
+
*
|
|
1391
|
+
* This triggers state changes like stun and flee check.
|
|
1392
|
+
* The actual damage is applied externally via RPGJS API.
|
|
1393
|
+
*/
|
|
1394
|
+
takeDamage(attacker) {
|
|
1395
|
+
if (this.defeated) return true;
|
|
1396
|
+
const raw = this.event.applyDamage(attacker);
|
|
1397
|
+
return this.handleDamage(attacker, {
|
|
1398
|
+
damage: raw.damage ?? 0,
|
|
1399
|
+
defeated: this.event.hp <= 0,
|
|
1400
|
+
raw
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
handleDamage(attacker, damageResult) {
|
|
1404
|
+
if (this.defeated) return true;
|
|
1405
|
+
const damage = Number.isFinite(damageResult.damage) ? damageResult.damage : 0;
|
|
1406
|
+
this.debugLog("damage", `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || "?"})`);
|
|
1407
|
+
const canRetaliate = attacker ? this.canTarget(attacker) : false;
|
|
1408
|
+
const attackerDefeated = this.isTargetDefeated(attacker);
|
|
1409
|
+
this.traceLog("damage", "handle damage", {
|
|
1410
|
+
attackerId: attacker?.id,
|
|
1411
|
+
damage,
|
|
1412
|
+
defeated: damageResult.defeated,
|
|
1413
|
+
eventHp: this.event.hp,
|
|
1414
|
+
maxHp: this.event.param[MAXHP],
|
|
1415
|
+
state: this.state,
|
|
1416
|
+
canRetaliate,
|
|
1417
|
+
attackerDefeated,
|
|
1418
|
+
currentTargetId: this.target?.id
|
|
1419
|
+
});
|
|
1420
|
+
withActionBattleAnimationUnlocked(this.event, () => {
|
|
1421
|
+
emitActionBattleClientVisual({
|
|
1422
|
+
moment: "hurt",
|
|
1423
|
+
entity: this.event,
|
|
1424
|
+
target: this.event,
|
|
1425
|
+
attacker,
|
|
1426
|
+
damage,
|
|
1427
|
+
defeated: damageResult.defeated,
|
|
1428
|
+
result: damageResult,
|
|
1429
|
+
animations: this.animations
|
|
1430
|
+
});
|
|
1431
|
+
});
|
|
1432
|
+
this.recentDamageTaken += damage;
|
|
1433
|
+
if (attacker && this.canTarget(attacker) && !this.isTargetDefeated(attacker) && this.state !== AiState.Flee) {
|
|
1434
|
+
this.traceLog("target", "retaliate against attacker", {
|
|
1435
|
+
attackerId: attacker.id,
|
|
1436
|
+
previousTargetId: this.target?.id,
|
|
1437
|
+
state: this.state
|
|
1438
|
+
});
|
|
1439
|
+
this.target = attacker;
|
|
1440
|
+
if (this.state === AiState.Idle || this.state === AiState.Alert) {
|
|
1441
|
+
this.isMovingToTarget = false;
|
|
1442
|
+
this.changeState(AiState.Combat);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
const reaction = damageResult.reaction;
|
|
1446
|
+
const staggerPower = reaction?.staggerPower ?? damage;
|
|
1447
|
+
const hitstunMs = reaction?.hitstunMs ?? this.hitstunMs;
|
|
1448
|
+
const shouldStun = (damage > 0 || (reaction?.staggerPower ?? 0) > 0) && staggerPower >= this.poise && hitstunMs > 0;
|
|
1449
|
+
this.lockActionUntil(Date.now() + Math.max(220, hitstunMs + 120), "damage recovery", {
|
|
1450
|
+
attackerId: attacker?.id,
|
|
1451
|
+
damage,
|
|
1452
|
+
hitstunMs
|
|
1453
|
+
});
|
|
1454
|
+
setActionBattleInvincibility(this.event, reaction?.invincibilityMs ?? this.invincibilityMs);
|
|
1455
|
+
if (shouldStun && this.state !== AiState.Stunned && this.state !== AiState.Flee) {
|
|
1456
|
+
this.debugLog("damage", "Stunned from damage");
|
|
1457
|
+
this.isMovingToTarget = false;
|
|
1458
|
+
this.stunnedUntil = Date.now() + hitstunMs;
|
|
1459
|
+
this.changeState(AiState.Stunned);
|
|
1460
|
+
}
|
|
1461
|
+
if (damageResult.defeated || this.event.hp <= 0) {
|
|
1462
|
+
this.debugLog("damage", "Defeated!");
|
|
1463
|
+
this.kill(attacker);
|
|
1464
|
+
return true;
|
|
1465
|
+
}
|
|
1466
|
+
return false;
|
|
1467
|
+
}
|
|
1468
|
+
/**
|
|
1469
|
+
* Kill this AI
|
|
1470
|
+
*
|
|
1471
|
+
* Stops all movements, cleans up resources, calls the onDefeated hook,
|
|
1472
|
+
* and removes the event from the map.
|
|
1473
|
+
*/
|
|
1474
|
+
kill(attacker) {
|
|
1475
|
+
if (this.defeated) return;
|
|
1476
|
+
this.defeated = true;
|
|
1477
|
+
const dieAnimation = resolveActionBattleAnimation("die", this.event, this.animations, { attacker });
|
|
1478
|
+
const removeDelay = getActionBattleAnimationRemovalDelay(dieAnimation);
|
|
1479
|
+
const reward = createDefeatReward(this.rewards);
|
|
1480
|
+
let removed = false;
|
|
1481
|
+
const remove = () => {
|
|
1482
|
+
if (removed) return;
|
|
1483
|
+
removed = true;
|
|
1484
|
+
this.event.remove({
|
|
1485
|
+
reason: "defeated",
|
|
1486
|
+
data: { animation: dieAnimation },
|
|
1487
|
+
transition: dieAnimation ? {
|
|
1488
|
+
animation: dieAnimation.animationName,
|
|
1489
|
+
graphic: dieAnimation.graphic,
|
|
1490
|
+
duration: removeDelay
|
|
1491
|
+
} : void 0,
|
|
1492
|
+
timeoutMs: removeDelay
|
|
1493
|
+
});
|
|
1494
|
+
};
|
|
1495
|
+
if (this.autoAwardRewards && attacker && isActionBattlePlayer(attacker)) reward.giveTo(attacker);
|
|
1496
|
+
const context = {
|
|
1497
|
+
event: this.event,
|
|
1498
|
+
attacker,
|
|
1499
|
+
reward,
|
|
1500
|
+
remove
|
|
1501
|
+
};
|
|
1502
|
+
if (this.onDefeatedCallback) if (this.onDefeatedCallback.length >= 2) this.onDefeatedCallback(this.event, attacker);
|
|
1503
|
+
else this.onDefeatedCallback(context);
|
|
1504
|
+
this.destroy();
|
|
1505
|
+
remove();
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Get distance between entities
|
|
1509
|
+
*/
|
|
1510
|
+
getDistance(entity1, entity2) {
|
|
1511
|
+
const dx = entity1.x() - entity2.x();
|
|
1512
|
+
const dy = entity1.y() - entity2.y();
|
|
1513
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
1514
|
+
}
|
|
1515
|
+
resolveUsable(usable) {
|
|
1516
|
+
if (!usable) return usable;
|
|
1517
|
+
const id = typeof usable === "string" ? usable : usable.id;
|
|
1518
|
+
const learned = id ? this.event.getSkill?.(id) : void 0;
|
|
1519
|
+
if (learned) return learned;
|
|
1520
|
+
try {
|
|
1521
|
+
return id ? this.event.databaseById?.(id) ?? usable : usable;
|
|
1522
|
+
} catch {
|
|
1523
|
+
return usable;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
getCurrentActionRange() {
|
|
1527
|
+
const skillRange = this.attackSkill ? getActionBattleActionRange(this.resolveUsable(this.attackSkill)) : void 0;
|
|
1528
|
+
if (skillRange !== void 0) return skillRange;
|
|
1529
|
+
return getActionBattleActionRange(resolveActionBattleWeapon(this.event));
|
|
1530
|
+
}
|
|
1531
|
+
canTarget(target) {
|
|
1532
|
+
return canActionBattleTarget(this.event, target, this.targets, getActionBattleOptions().combat?.targets);
|
|
1533
|
+
}
|
|
1534
|
+
findNearestTarget() {
|
|
1535
|
+
const map = this.event.getCurrentMap();
|
|
1536
|
+
if (!map) {
|
|
1537
|
+
this.traceLog("target", "find nearest skipped: no map");
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
const candidates = [];
|
|
1541
|
+
map.getPlayers?.().forEach((player) => candidates.push(player));
|
|
1542
|
+
map.getEvents?.().forEach((event) => candidates.push(event));
|
|
1543
|
+
let nearest = null;
|
|
1544
|
+
let nearestDistance = Number.POSITIVE_INFINITY;
|
|
1545
|
+
for (const candidate of candidates) {
|
|
1546
|
+
if (!this.canTarget(candidate)) continue;
|
|
1547
|
+
const distance = this.getDistance(this.event, candidate);
|
|
1548
|
+
if (distance > this.visionRange) continue;
|
|
1549
|
+
if (distance < nearestDistance) {
|
|
1550
|
+
nearest = candidate;
|
|
1551
|
+
nearestDistance = distance;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const now = Date.now();
|
|
1555
|
+
if (nearest) this.traceLog("target", "nearest target found", {
|
|
1556
|
+
targetId: nearest.id,
|
|
1557
|
+
distance: nearestDistance,
|
|
1558
|
+
visionRange: this.visionRange,
|
|
1559
|
+
candidates: candidates.length
|
|
1560
|
+
});
|
|
1561
|
+
else if (now - this.lastNoTargetTraceTime > 1e3) {
|
|
1562
|
+
this.lastNoTargetTraceTime = now;
|
|
1563
|
+
this.traceLog("target", "no target found", {
|
|
1564
|
+
visionRange: this.visionRange,
|
|
1565
|
+
candidates: candidates.map((candidate) => {
|
|
1566
|
+
const distance = this.getDistance(this.event, candidate);
|
|
1567
|
+
return {
|
|
1568
|
+
id: candidate.id,
|
|
1569
|
+
hp: candidate.hp,
|
|
1570
|
+
canTarget: this.canTarget(candidate),
|
|
1571
|
+
defeated: this.isTargetDefeated(candidate),
|
|
1572
|
+
distance
|
|
1573
|
+
};
|
|
1574
|
+
})
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
return nearest;
|
|
1578
|
+
}
|
|
1579
|
+
isTargetDefeated(target) {
|
|
1580
|
+
return !target || target.hp <= 0;
|
|
1581
|
+
}
|
|
1582
|
+
clearTarget() {
|
|
1583
|
+
this.target = null;
|
|
1584
|
+
this.isMovingToTarget = false;
|
|
1585
|
+
this.event.stopMoveTo();
|
|
1586
|
+
}
|
|
1587
|
+
updateBehavior(currentTime) {
|
|
1588
|
+
if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) return;
|
|
1589
|
+
this.behaviorLastUpdate = currentTime;
|
|
1590
|
+
let score = this.behaviorScore;
|
|
1591
|
+
const maxHp = this.event.param[MAXHP];
|
|
1592
|
+
if (maxHp) {
|
|
1593
|
+
const hpPercent = this.event.hp / maxHp;
|
|
1594
|
+
score += (hpPercent - .5) * 40;
|
|
1595
|
+
}
|
|
1596
|
+
if (this.recentDamageTaken > 0) score -= Math.min(30, this.recentDamageTaken * .5);
|
|
1597
|
+
if (this.target) {
|
|
1598
|
+
const distance = this.getDistance(this.event, this.target);
|
|
1599
|
+
if (distance <= this.attackRange) score += 10;
|
|
1600
|
+
else if (distance > this.visionRange) score -= 10;
|
|
1601
|
+
}
|
|
1602
|
+
if (this.groupBehavior && this.nearbyEnemies.length > 0) score += Math.min(15, this.nearbyEnemies.length * 5);
|
|
1603
|
+
score = Math.max(0, Math.min(100, score));
|
|
1604
|
+
this.behaviorScore = score;
|
|
1605
|
+
const previousMode = this.behaviorMode;
|
|
1606
|
+
if (score >= this.behaviorAssaultThreshold) this.behaviorMode = "assault";
|
|
1607
|
+
else if (score <= this.behaviorRetreatThreshold) this.behaviorMode = "retreat";
|
|
1608
|
+
else this.behaviorMode = "tactical";
|
|
1609
|
+
if (previousMode !== this.behaviorMode) this.debugLog("state", `Behavior mode: ${previousMode} -> ${this.behaviorMode} (score=${score.toFixed(0)})`);
|
|
1610
|
+
if (this.behaviorMode === "retreat" && this.state === AiState.Combat) {
|
|
1611
|
+
if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) {
|
|
1612
|
+
this.isMovingToTarget = false;
|
|
1613
|
+
this.changeState(AiState.Flee);
|
|
1614
|
+
}
|
|
1615
|
+
} else if (this.behaviorMode === "assault" && this.state === AiState.Flee) {
|
|
1616
|
+
if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) this.changeState(AiState.Combat);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
applyCustomBehavior(currentTime) {
|
|
1620
|
+
let handled = false;
|
|
1621
|
+
if (this.behaviorTree) {
|
|
1622
|
+
const result = this.behaviorTree.tick(this.createAiTreeContext(currentTime));
|
|
1623
|
+
if (result.decision) handled = this.applyAiDecision(result.decision, currentTime) || handled;
|
|
1624
|
+
if (result.intent) handled = this.executeAiIntents(result.intent, currentTime) || handled;
|
|
1625
|
+
if (result.status === "running") handled = true;
|
|
1626
|
+
}
|
|
1627
|
+
if (!this.behaviorKey) return handled;
|
|
1628
|
+
const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
|
|
1629
|
+
if (!behavior) return handled;
|
|
1630
|
+
const maxHp = this.event.param[MAXHP];
|
|
1631
|
+
const decision = behavior({
|
|
1632
|
+
event: this.event,
|
|
1633
|
+
target: this.target,
|
|
1634
|
+
state: this.state,
|
|
1635
|
+
enemyType: this.enemyType,
|
|
1636
|
+
distance: this.target ? this.getDistance(this.event, this.target) : null,
|
|
1637
|
+
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
1638
|
+
now: currentTime
|
|
1639
|
+
});
|
|
1640
|
+
if (!decision) return handled;
|
|
1641
|
+
return this.applyAiDecision(decision, currentTime) || handled;
|
|
1642
|
+
}
|
|
1643
|
+
applyAiDecision(decision, currentTime) {
|
|
1644
|
+
if (!decision) return false;
|
|
1645
|
+
let handled = false;
|
|
1646
|
+
if (decision.attackCooldown !== void 0) this.attackCooldown = decision.attackCooldown;
|
|
1647
|
+
if (decision.moveToCooldown !== void 0) this.moveToCooldown = decision.moveToCooldown;
|
|
1648
|
+
if (decision.attackPatterns?.length) this.attackPatterns = decision.attackPatterns;
|
|
1649
|
+
if (decision.mode) {
|
|
1650
|
+
this.behaviorMode = decision.mode;
|
|
1651
|
+
this.behaviorEnabled = true;
|
|
1652
|
+
}
|
|
1653
|
+
if (decision.intent) handled = this.executeAiIntents(decision.intent, currentTime);
|
|
1654
|
+
return handled;
|
|
1655
|
+
}
|
|
1656
|
+
createAiTreeContext(currentTime) {
|
|
1657
|
+
const maxHp = this.event.param[MAXHP];
|
|
1658
|
+
const distance = this.target ? this.getDistance(this.event, this.target) : null;
|
|
1659
|
+
return {
|
|
1660
|
+
event: this.event,
|
|
1661
|
+
target: this.target,
|
|
1662
|
+
state: this.state,
|
|
1663
|
+
enemyType: this.enemyType,
|
|
1664
|
+
distance,
|
|
1665
|
+
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
1666
|
+
now: currentTime,
|
|
1667
|
+
self: {
|
|
1668
|
+
event: this.event,
|
|
1669
|
+
state: this.state,
|
|
1670
|
+
enemyType: this.enemyType,
|
|
1671
|
+
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
1672
|
+
attackRange: this.attackRange
|
|
1673
|
+
},
|
|
1674
|
+
targetInfo: this.target && distance !== null ? {
|
|
1675
|
+
entity: this.target,
|
|
1676
|
+
distance,
|
|
1677
|
+
inAttackRange: distance <= this.attackRange,
|
|
1678
|
+
visible: true
|
|
1679
|
+
} : null,
|
|
1680
|
+
memory: this.aiMemory
|
|
1681
|
+
};
|
|
1682
|
+
}
|
|
1683
|
+
executeAiIntents(input, currentTime) {
|
|
1684
|
+
const intents = Array.isArray(input) ? input : [input];
|
|
1685
|
+
let handled = false;
|
|
1686
|
+
for (const intent of intents) handled = this.executeAiIntent(intent, currentTime) || handled;
|
|
1687
|
+
return handled;
|
|
1688
|
+
}
|
|
1689
|
+
executeAiIntent(intent, currentTime) {
|
|
1690
|
+
const consumes = intent.consume !== false;
|
|
1691
|
+
switch (intent.type) {
|
|
1692
|
+
case "setMode":
|
|
1693
|
+
this.behaviorMode = intent.mode;
|
|
1694
|
+
this.behaviorEnabled = true;
|
|
1695
|
+
return consumes;
|
|
1696
|
+
case "idle":
|
|
1697
|
+
this.isMovingToTarget = false;
|
|
1698
|
+
this.event.stopMoveTo();
|
|
1699
|
+
return consumes;
|
|
1700
|
+
case "patrol":
|
|
1701
|
+
this.startPatrol();
|
|
1702
|
+
return consumes;
|
|
1703
|
+
case "faceTarget":
|
|
1704
|
+
this.faceTarget();
|
|
1705
|
+
return consumes;
|
|
1706
|
+
case "moveToTarget":
|
|
1707
|
+
if (!this.target) return false;
|
|
1708
|
+
this.requestTargetMovement();
|
|
1709
|
+
return consumes;
|
|
1710
|
+
case "fleeFromTarget":
|
|
1711
|
+
if (!this.target) return false;
|
|
1712
|
+
this.isMovingToTarget = false;
|
|
1713
|
+
if (this.state === AiState.Combat) this.changeState(AiState.Flee);
|
|
1714
|
+
else this.fleeFromTarget();
|
|
1715
|
+
return consumes;
|
|
1716
|
+
case "keepDistance": return this.executeKeepDistance(intent, consumes);
|
|
1717
|
+
case "useAttack": return this.executeRequestedAttack(intent.pattern, currentTime, consumes);
|
|
1718
|
+
case "useSkill": return this.executeRequestedSkill(intent.skill, currentTime, consumes);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
executeKeepDistance(intent, consumes) {
|
|
1722
|
+
if (!this.target) return false;
|
|
1723
|
+
const tolerance = intent.tolerance ?? Math.max(8, intent.distance * .15);
|
|
1724
|
+
const distance = this.getDistance(this.event, this.target);
|
|
1725
|
+
if (distance < intent.distance - tolerance) {
|
|
1726
|
+
this.isMovingToTarget = false;
|
|
1727
|
+
this.retreatFromTarget();
|
|
1728
|
+
return consumes;
|
|
1729
|
+
}
|
|
1730
|
+
if (distance > intent.distance + tolerance) {
|
|
1731
|
+
this.requestTargetMovement();
|
|
1732
|
+
return consumes;
|
|
1733
|
+
}
|
|
1734
|
+
if (this.isMovingToTarget) {
|
|
1735
|
+
this.isMovingToTarget = false;
|
|
1736
|
+
this.event.stopMoveTo();
|
|
1737
|
+
}
|
|
1738
|
+
return consumes;
|
|
1739
|
+
}
|
|
1740
|
+
executeRequestedAttack(pattern, currentTime, consumes) {
|
|
1741
|
+
if (!this.target || this.isTargetDefeated(this.target) || this.chargingAttack) return false;
|
|
1742
|
+
if (this.getDistance(this.event, this.target) > this.attackRange) return false;
|
|
1743
|
+
if (currentTime - this.lastAttackTime < this.attackCooldown) return false;
|
|
1744
|
+
if (pattern) this.performAttackPattern(pattern);
|
|
1745
|
+
else this.selectAndPerformAttack();
|
|
1746
|
+
this.lastAttackTime = currentTime;
|
|
1747
|
+
return consumes;
|
|
1748
|
+
}
|
|
1749
|
+
executeRequestedSkill(skill, currentTime, consumes) {
|
|
1750
|
+
if (!this.target || this.isTargetDefeated(this.target) || !skill) return false;
|
|
1751
|
+
const distance = this.getDistance(this.event, this.target);
|
|
1752
|
+
const resolvedSkill = this.resolveUsable(skill);
|
|
1753
|
+
const range = getActionBattleActionRange(resolvedSkill) ?? this.attackRange;
|
|
1754
|
+
const cooldownRemaining = this.attackCooldown - (currentTime - this.lastAttackTime);
|
|
1755
|
+
if (distance > range) return false;
|
|
1756
|
+
if (cooldownRemaining > 0) return false;
|
|
1757
|
+
executeActionBattleUse({
|
|
1758
|
+
attacker: this.event,
|
|
1759
|
+
target: this.target,
|
|
1760
|
+
usable: resolvedSkill,
|
|
1761
|
+
skill: resolvedSkill,
|
|
1762
|
+
profile: this.getAttackProfile(AttackPattern.Melee)
|
|
1763
|
+
});
|
|
1764
|
+
this.lastAttackTime = currentTime;
|
|
1765
|
+
return consumes;
|
|
1766
|
+
}
|
|
1767
|
+
handleTacticalMovement(distance) {
|
|
1768
|
+
if (!this.target) return;
|
|
1769
|
+
const minRange = this.attackRange * .7;
|
|
1770
|
+
const maxRange = this.attackRange * 1.2;
|
|
1771
|
+
if (distance < minRange) {
|
|
1772
|
+
this.debugLog("movement", `Tactical retreat (dist=${distance.toFixed(1)}, minRange=${minRange.toFixed(1)})`);
|
|
1773
|
+
this.isMovingToTarget = false;
|
|
1774
|
+
this.retreatFromTarget();
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (distance > maxRange) {
|
|
1778
|
+
if (!this.isMovingToTarget) {
|
|
1779
|
+
this.debugLog("movement", `Tactical approach (dist=${distance.toFixed(1)}, maxRange=${maxRange.toFixed(1)})`);
|
|
1780
|
+
this.requestTargetMovement();
|
|
1781
|
+
}
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
if (this.isMovingToTarget) {
|
|
1785
|
+
this.debugLog("movement", `Tactical hold (dist=${distance.toFixed(1)})`);
|
|
1786
|
+
this.isMovingToTarget = false;
|
|
1787
|
+
this.event.stopMoveTo();
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
handleAssaultMovement(distance) {
|
|
1791
|
+
if (!this.target) return;
|
|
1792
|
+
if (distance > this.attackRange) {
|
|
1793
|
+
if (!this.isMovingToTarget) {
|
|
1794
|
+
this.debugLog("movement", `Assault approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
1795
|
+
this.requestTargetMovement();
|
|
1796
|
+
}
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (this.isMovingToTarget) {
|
|
1800
|
+
this.debugLog("movement", `Assault hold (dist=${distance.toFixed(1)})`);
|
|
1801
|
+
this.isMovingToTarget = false;
|
|
1802
|
+
this.event.stopMoveTo();
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
resolveMoveTarget(target) {
|
|
1806
|
+
if (!target) return null;
|
|
1807
|
+
const targetId = target.id !== void 0 && target.id !== null ? String(target.id) : void 0;
|
|
1808
|
+
const x = resolveMoveCoordinate(target.x);
|
|
1809
|
+
const y = resolveMoveCoordinate(target.y);
|
|
1810
|
+
if (targetId) return {
|
|
1811
|
+
kind: "entity",
|
|
1812
|
+
target,
|
|
1813
|
+
id: targetId,
|
|
1814
|
+
x,
|
|
1815
|
+
y,
|
|
1816
|
+
signature: `entity:${targetId}`
|
|
1817
|
+
};
|
|
1818
|
+
if (x === void 0 || y === void 0) return null;
|
|
1819
|
+
return {
|
|
1820
|
+
kind: "position",
|
|
1821
|
+
target: {
|
|
1822
|
+
x,
|
|
1823
|
+
y
|
|
1824
|
+
},
|
|
1825
|
+
x,
|
|
1826
|
+
y,
|
|
1827
|
+
signature: createMoveSignature(x, y)
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
requestMoveTo(target) {
|
|
1831
|
+
const currentTime = Date.now();
|
|
1832
|
+
const resolvedTarget = this.resolveMoveTarget(target);
|
|
1833
|
+
if (!resolvedTarget) {
|
|
1834
|
+
this.traceLog("movement", "moveTo skipped: invalid target", { target });
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
if (currentTime - this.lastMoveToTime < this.moveToCooldown) {
|
|
1838
|
+
if (this.lastMoveToCooldownTraceSignature !== resolvedTarget.signature || currentTime - this.lastMoveToCooldownTraceTime > 1e3) {
|
|
1839
|
+
this.lastMoveToCooldownTraceTime = currentTime;
|
|
1840
|
+
this.lastMoveToCooldownTraceSignature = resolvedTarget.signature;
|
|
1841
|
+
this.traceLog("movement", "moveTo skipped: cooldown", {
|
|
1842
|
+
targetKind: resolvedTarget.kind,
|
|
1843
|
+
targetId: resolvedTarget.kind === "entity" ? resolvedTarget.id : void 0,
|
|
1844
|
+
targetPosition: {
|
|
1845
|
+
x: resolvedTarget.x,
|
|
1846
|
+
y: resolvedTarget.y
|
|
1847
|
+
},
|
|
1848
|
+
elapsed: currentTime - this.lastMoveToTime,
|
|
1849
|
+
moveToCooldown: this.moveToCooldown
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
return false;
|
|
1853
|
+
}
|
|
1854
|
+
const map = this.event.getCurrentMap?.();
|
|
1855
|
+
const hasBody = !!map?.physic?.getEntityByUUID?.(this.event.id) || !!map?.getBody?.(this.event.id);
|
|
1856
|
+
this.traceLog("movement", "moveTo requested", {
|
|
1857
|
+
targetKind: resolvedTarget.kind,
|
|
1858
|
+
targetId: resolvedTarget.kind === "entity" ? resolvedTarget.id : void 0,
|
|
1859
|
+
eventPosition: {
|
|
1860
|
+
x: this.event.x?.(),
|
|
1861
|
+
y: this.event.y?.()
|
|
1862
|
+
},
|
|
1863
|
+
targetPosition: {
|
|
1864
|
+
x: resolvedTarget.x,
|
|
1865
|
+
y: resolvedTarget.y
|
|
1866
|
+
},
|
|
1867
|
+
hasMap: !!map,
|
|
1868
|
+
hasMovementBody: hasBody
|
|
1869
|
+
});
|
|
1870
|
+
this.event.moveTo(resolvedTarget.target);
|
|
1871
|
+
this.lastMoveToTime = currentTime;
|
|
1872
|
+
return true;
|
|
1873
|
+
}
|
|
1874
|
+
requestTargetMovement(target = this.target) {
|
|
1875
|
+
if (!target) {
|
|
1876
|
+
this.traceLog("movement", "target movement skipped: no target");
|
|
1877
|
+
return false;
|
|
1878
|
+
}
|
|
1879
|
+
const started = this.requestMoveTo(target);
|
|
1880
|
+
if (started) this.isMovingToTarget = true;
|
|
1881
|
+
else {
|
|
1882
|
+
const now = Date.now();
|
|
1883
|
+
if (now - this.lastTargetMovementSkipTraceTime > 1e3) {
|
|
1884
|
+
this.lastTargetMovementSkipTraceTime = now;
|
|
1885
|
+
this.traceLog("movement", "target movement did not start", {
|
|
1886
|
+
targetId: target.id,
|
|
1887
|
+
isMovingToTarget: this.isMovingToTarget
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
return started;
|
|
1892
|
+
}
|
|
1893
|
+
schedule(callback, delay) {
|
|
1894
|
+
const timer = setTimeout(() => {
|
|
1895
|
+
this.timers = this.timers.filter((entry) => entry !== timer);
|
|
1896
|
+
if (this.destroyed) return;
|
|
1897
|
+
callback();
|
|
1898
|
+
}, delay);
|
|
1899
|
+
this.timers.push(timer);
|
|
1900
|
+
return timer;
|
|
1901
|
+
}
|
|
1902
|
+
getHealth() {
|
|
1903
|
+
return this.event.hp;
|
|
1904
|
+
}
|
|
1905
|
+
getMaxHealth() {
|
|
1906
|
+
return this.event.param[MAXHP];
|
|
1907
|
+
}
|
|
1908
|
+
getTarget() {
|
|
1909
|
+
return this.target;
|
|
1910
|
+
}
|
|
1911
|
+
getState() {
|
|
1912
|
+
return this.state;
|
|
1913
|
+
}
|
|
1914
|
+
getFaction() {
|
|
1915
|
+
return this.faction;
|
|
1916
|
+
}
|
|
1917
|
+
setFaction(faction) {
|
|
1918
|
+
this.faction = faction;
|
|
1919
|
+
}
|
|
1920
|
+
getTargets() {
|
|
1921
|
+
return this.targets;
|
|
1922
|
+
}
|
|
1923
|
+
setTargets(targets) {
|
|
1924
|
+
this.targets = targets;
|
|
1925
|
+
if (this.target && !this.canTarget(this.target)) {
|
|
1926
|
+
this.clearTarget();
|
|
1927
|
+
this.changeState(AiState.Idle);
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
getEnemyType() {
|
|
1931
|
+
return this.enemyType;
|
|
1932
|
+
}
|
|
1933
|
+
/**
|
|
1934
|
+
* Clean up
|
|
1935
|
+
*/
|
|
1936
|
+
destroy() {
|
|
1937
|
+
this.destroyed = true;
|
|
1938
|
+
if (this.updateInterval) {
|
|
1939
|
+
clearInterval(this.updateInterval);
|
|
1940
|
+
this.updateInterval = void 0;
|
|
1941
|
+
}
|
|
1942
|
+
this.target = null;
|
|
1943
|
+
this.nearbyEnemies = [];
|
|
1944
|
+
this.timers.forEach((timer) => clearTimeout(timer));
|
|
1945
|
+
this.timers = [];
|
|
1946
|
+
}
|
|
1947
|
+
};
|
|
1948
|
+
//#endregion
|
|
1949
|
+
export { AiDebug, AiState, AttackPattern, BattleAi, DEFAULT_KNOCKBACK, EnemyType };
|