@rpgjs/action-battle 5.0.0-alpha.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +784 -0
- package/dist/ai.server.d.ts +482 -0
- package/dist/client/index.js +25 -0
- package/dist/client/index2.js +13 -0
- package/dist/client/index3.js +1129 -0
- package/dist/client/index4.js +137 -0
- package/dist/client.d.ts +2 -0
- package/dist/index.d.ts +4 -0
- package/dist/server/index.js +25 -0
- package/dist/server/index2.js +138 -0
- package/dist/server/index3.js +1128 -0
- package/dist/server.d.ts +93 -0
- package/package.json +45 -0
- package/src/ai.server.ts +1430 -0
- package/src/client.ts +11 -0
- package/src/index.ts +21 -0
- package/src/server.ts +227 -0
- package/vite.config.ts +3 -0
|
@@ -0,0 +1,1129 @@
|
|
|
1
|
+
const MAXHP = null;
|
|
2
|
+
const RpgPlayer = null;
|
|
3
|
+
const AiDebug = {
|
|
4
|
+
/** Enable/disable all AI debug logs */
|
|
5
|
+
enabled: typeof process !== "undefined" && process.env?.RPGJS_DEBUG_AI === "1" || false,
|
|
6
|
+
/** Filter logs to a specific event ID (null = all events) */
|
|
7
|
+
filterEventId: null,
|
|
8
|
+
/** Log categories to enable (empty = all) */
|
|
9
|
+
categories: [],
|
|
10
|
+
/**
|
|
11
|
+
* Log an AI debug message
|
|
12
|
+
*
|
|
13
|
+
* @param category - Log category (e.g., 'state', 'attack', 'movement', 'damage')
|
|
14
|
+
* @param eventId - Event ID for filtering
|
|
15
|
+
* @param message - Log message
|
|
16
|
+
* @param data - Optional additional data
|
|
17
|
+
*/
|
|
18
|
+
log(category, eventId, message, data) {
|
|
19
|
+
if (!this.enabled) return;
|
|
20
|
+
if (this.filterEventId && eventId !== this.filterEventId) return;
|
|
21
|
+
if (this.categories.length > 0 && !this.categories.includes(category)) return;
|
|
22
|
+
const prefix = `[AI:${category}]${eventId ? ` [${eventId.substring(0, 8)}]` : ""}`;
|
|
23
|
+
if (data !== void 0) {
|
|
24
|
+
console.log(prefix, message, data);
|
|
25
|
+
} else {
|
|
26
|
+
console.log(prefix, message);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
var AiState = /* @__PURE__ */ ((AiState2) => {
|
|
31
|
+
AiState2["Idle"] = "idle";
|
|
32
|
+
AiState2["Alert"] = "alert";
|
|
33
|
+
AiState2["Combat"] = "combat";
|
|
34
|
+
AiState2["Flee"] = "flee";
|
|
35
|
+
AiState2["Stunned"] = "stunned";
|
|
36
|
+
return AiState2;
|
|
37
|
+
})(AiState || {});
|
|
38
|
+
var EnemyType = /* @__PURE__ */ ((EnemyType2) => {
|
|
39
|
+
EnemyType2["Aggressive"] = "aggressive";
|
|
40
|
+
EnemyType2["Defensive"] = "defensive";
|
|
41
|
+
EnemyType2["Ranged"] = "ranged";
|
|
42
|
+
EnemyType2["Tank"] = "tank";
|
|
43
|
+
EnemyType2["Berserker"] = "berserker";
|
|
44
|
+
return EnemyType2;
|
|
45
|
+
})(EnemyType || {});
|
|
46
|
+
var AttackPattern = /* @__PURE__ */ ((AttackPattern2) => {
|
|
47
|
+
AttackPattern2["Melee"] = "melee";
|
|
48
|
+
AttackPattern2["Combo"] = "combo";
|
|
49
|
+
AttackPattern2["Charged"] = "charged";
|
|
50
|
+
AttackPattern2["Zone"] = "zone";
|
|
51
|
+
AttackPattern2["DashAttack"] = "dashAttack";
|
|
52
|
+
return AttackPattern2;
|
|
53
|
+
})(AttackPattern || {});
|
|
54
|
+
const DEFAULT_KNOCKBACK = {
|
|
55
|
+
/** Default knockback force */
|
|
56
|
+
force: 50,
|
|
57
|
+
/** Default knockback duration in milliseconds */
|
|
58
|
+
duration: 300
|
|
59
|
+
};
|
|
60
|
+
class BattleAi {
|
|
61
|
+
/**
|
|
62
|
+
* Create a new Battle AI Controller
|
|
63
|
+
*
|
|
64
|
+
* The AI controls behavior only. Stats should be set on the event
|
|
65
|
+
* using standard RPGJS methods (hp, param, learnSkill, etc.)
|
|
66
|
+
*
|
|
67
|
+
* @param event - The event to control
|
|
68
|
+
* @param options - AI behavior configuration
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* // In your event's onInit
|
|
73
|
+
* this.hp = 100;
|
|
74
|
+
* this.param[ATK] = 20;
|
|
75
|
+
* this.learnSkill(FireBall);
|
|
76
|
+
*
|
|
77
|
+
* new BattleAi(this, {
|
|
78
|
+
* enemyType: EnemyType.Ranged,
|
|
79
|
+
* attackSkill: FireBall,
|
|
80
|
+
* visionRange: 200,
|
|
81
|
+
* fleeThreshold: 0.2
|
|
82
|
+
* });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
constructor(event, options = {}) {
|
|
86
|
+
this.target = null;
|
|
87
|
+
this.lastAttackTime = 0;
|
|
88
|
+
this.state = "idle";
|
|
89
|
+
this.stateStartTime = 0;
|
|
90
|
+
this.stunnedUntil = 0;
|
|
91
|
+
this.lastDodgeTime = 0;
|
|
92
|
+
this.comboCount = 0;
|
|
93
|
+
this.comboMax = 3;
|
|
94
|
+
this.chargingAttack = false;
|
|
95
|
+
this.nearbyEnemies = [];
|
|
96
|
+
this.groupUpdateInterval = 0;
|
|
97
|
+
this.patrolWaypoints = [];
|
|
98
|
+
this.currentPatrolIndex = 0;
|
|
99
|
+
this.lastHpCheck = 0;
|
|
100
|
+
this.recentDamageTaken = 0;
|
|
101
|
+
this.damageCheckInterval = 2e3;
|
|
102
|
+
this.isMovingToTarget = false;
|
|
103
|
+
this.lastFacingDirection = null;
|
|
104
|
+
event.battleAi = this;
|
|
105
|
+
this.event = event;
|
|
106
|
+
this.enemyType = options.enemyType || "aggressive";
|
|
107
|
+
this.applyEnemyTypeBehavior(options);
|
|
108
|
+
this.attackSkill = options.attackSkill || null;
|
|
109
|
+
this.attackPatterns = options.attackPatterns || [
|
|
110
|
+
"melee",
|
|
111
|
+
"combo",
|
|
112
|
+
"dashAttack"
|
|
113
|
+
/* DashAttack */
|
|
114
|
+
];
|
|
115
|
+
this.groupBehavior = options.groupBehavior || false;
|
|
116
|
+
this.patrolWaypoints = options.patrolWaypoints || [];
|
|
117
|
+
this.currentPatrolIndex = 0;
|
|
118
|
+
this.onDefeatedCallback = options.onDefeated;
|
|
119
|
+
this.setupVision();
|
|
120
|
+
this.startAiBehaviorLoop();
|
|
121
|
+
this.changeState(
|
|
122
|
+
"idle"
|
|
123
|
+
/* Idle */
|
|
124
|
+
);
|
|
125
|
+
this.debugLog("init", `AI created (type=${this.enemyType}, visionRange=${this.visionRange}, attackRange=${this.attackRange})`);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Log AI debug message for this event
|
|
129
|
+
*/
|
|
130
|
+
debugLog(category, message, data) {
|
|
131
|
+
AiDebug.log(category, this.event.id, message, data);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Apply enemy type-specific behavior modifiers
|
|
135
|
+
*
|
|
136
|
+
* This only affects AI behavior (cooldowns, ranges, dodge).
|
|
137
|
+
* Stats should be set on the event itself.
|
|
138
|
+
*/
|
|
139
|
+
applyEnemyTypeBehavior(options) {
|
|
140
|
+
switch (this.enemyType) {
|
|
141
|
+
case "aggressive":
|
|
142
|
+
this.attackCooldown = options.attackCooldown ?? 600;
|
|
143
|
+
this.visionRange = options.visionRange ?? 150;
|
|
144
|
+
this.attackRange = options.attackRange ?? 50;
|
|
145
|
+
this.dodgeChance = options.dodgeChance ?? 0.1;
|
|
146
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 3e3;
|
|
147
|
+
this.fleeThreshold = options.fleeThreshold ?? 0.15;
|
|
148
|
+
break;
|
|
149
|
+
case "defensive":
|
|
150
|
+
this.attackCooldown = options.attackCooldown ?? 1500;
|
|
151
|
+
this.visionRange = options.visionRange ?? 120;
|
|
152
|
+
this.attackRange = options.attackRange ?? 60;
|
|
153
|
+
this.dodgeChance = options.dodgeChance ?? 0.5;
|
|
154
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 1500;
|
|
155
|
+
this.fleeThreshold = options.fleeThreshold ?? 0.3;
|
|
156
|
+
break;
|
|
157
|
+
case "ranged":
|
|
158
|
+
this.attackCooldown = options.attackCooldown ?? 1200;
|
|
159
|
+
this.visionRange = options.visionRange ?? 200;
|
|
160
|
+
this.attackRange = options.attackRange ?? 120;
|
|
161
|
+
this.dodgeChance = options.dodgeChance ?? 0.4;
|
|
162
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 2e3;
|
|
163
|
+
this.fleeThreshold = options.fleeThreshold ?? 0.25;
|
|
164
|
+
break;
|
|
165
|
+
case "tank":
|
|
166
|
+
this.attackCooldown = options.attackCooldown ?? 2e3;
|
|
167
|
+
this.visionRange = options.visionRange ?? 100;
|
|
168
|
+
this.attackRange = options.attackRange ?? 50;
|
|
169
|
+
this.dodgeChance = 0;
|
|
170
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 5e3;
|
|
171
|
+
this.fleeThreshold = options.fleeThreshold ?? 0.1;
|
|
172
|
+
break;
|
|
173
|
+
case "berserker":
|
|
174
|
+
this.attackCooldown = options.attackCooldown ?? 800;
|
|
175
|
+
this.visionRange = options.visionRange ?? 180;
|
|
176
|
+
this.attackRange = options.attackRange ?? 55;
|
|
177
|
+
this.dodgeChance = options.dodgeChance ?? 0.15;
|
|
178
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 2500;
|
|
179
|
+
this.fleeThreshold = options.fleeThreshold ?? 0.05;
|
|
180
|
+
break;
|
|
181
|
+
default:
|
|
182
|
+
this.attackCooldown = options.attackCooldown ?? 1e3;
|
|
183
|
+
this.visionRange = options.visionRange ?? 150;
|
|
184
|
+
this.attackRange = options.attackRange ?? 60;
|
|
185
|
+
this.dodgeChance = options.dodgeChance ?? 0.2;
|
|
186
|
+
this.dodgeCooldown = options.dodgeCooldown ?? 2e3;
|
|
187
|
+
this.fleeThreshold = options.fleeThreshold ?? 0.2;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Setup vision detection
|
|
192
|
+
*/
|
|
193
|
+
setupVision() {
|
|
194
|
+
const diameter = this.visionRange * 2;
|
|
195
|
+
this.event.attachShape(`vision_${this.event.id}`, {
|
|
196
|
+
radius: this.visionRange,
|
|
197
|
+
width: diameter,
|
|
198
|
+
height: diameter,
|
|
199
|
+
angle: 360
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Start the AI behavior loop
|
|
204
|
+
*/
|
|
205
|
+
startAiBehaviorLoop() {
|
|
206
|
+
const updateInterval = setInterval(() => {
|
|
207
|
+
if (!this.event.getCurrentMap()) {
|
|
208
|
+
this.destroy();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.updateAiBehavior();
|
|
212
|
+
}, 100);
|
|
213
|
+
this.updateInterval = updateInterval;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Change AI state with validated transitions
|
|
217
|
+
*/
|
|
218
|
+
changeState(newState) {
|
|
219
|
+
const validTransitions = {
|
|
220
|
+
[
|
|
221
|
+
"idle"
|
|
222
|
+
/* Idle */
|
|
223
|
+
]: [
|
|
224
|
+
"alert",
|
|
225
|
+
"combat"
|
|
226
|
+
/* Combat */
|
|
227
|
+
],
|
|
228
|
+
[
|
|
229
|
+
"alert"
|
|
230
|
+
/* Alert */
|
|
231
|
+
]: [
|
|
232
|
+
"idle",
|
|
233
|
+
"combat"
|
|
234
|
+
/* Combat */
|
|
235
|
+
],
|
|
236
|
+
[
|
|
237
|
+
"combat"
|
|
238
|
+
/* Combat */
|
|
239
|
+
]: [
|
|
240
|
+
"idle",
|
|
241
|
+
"flee",
|
|
242
|
+
"stunned"
|
|
243
|
+
/* Stunned */
|
|
244
|
+
],
|
|
245
|
+
[
|
|
246
|
+
"flee"
|
|
247
|
+
/* Flee */
|
|
248
|
+
]: [
|
|
249
|
+
"idle",
|
|
250
|
+
"combat"
|
|
251
|
+
/* Combat */
|
|
252
|
+
],
|
|
253
|
+
[
|
|
254
|
+
"stunned"
|
|
255
|
+
/* Stunned */
|
|
256
|
+
]: [
|
|
257
|
+
"combat",
|
|
258
|
+
"idle"
|
|
259
|
+
/* Idle */
|
|
260
|
+
]
|
|
261
|
+
};
|
|
262
|
+
if (!validTransitions[this.state].includes(newState)) {
|
|
263
|
+
this.debugLog("state", `INVALID transition ${this.state} -> ${newState}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
this.debugLog("state", `STATE change: ${this.state} -> ${newState}`);
|
|
267
|
+
this.state = newState;
|
|
268
|
+
this.stateStartTime = Date.now();
|
|
269
|
+
switch (newState) {
|
|
270
|
+
case "idle":
|
|
271
|
+
if (this.patrolWaypoints.length > 0) {
|
|
272
|
+
this.startPatrol();
|
|
273
|
+
}
|
|
274
|
+
break;
|
|
275
|
+
case "alert":
|
|
276
|
+
this.event.stopMoveTo();
|
|
277
|
+
break;
|
|
278
|
+
case "combat":
|
|
279
|
+
this.comboCount = 0;
|
|
280
|
+
break;
|
|
281
|
+
case "flee":
|
|
282
|
+
if (this.target) {
|
|
283
|
+
this.fleeFromTarget();
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
case "stunned":
|
|
287
|
+
this.event.stopMoveTo();
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Main AI behavior update loop
|
|
293
|
+
*/
|
|
294
|
+
updateAiBehavior() {
|
|
295
|
+
const currentTime = Date.now();
|
|
296
|
+
if (this.groupBehavior) {
|
|
297
|
+
this.updateGroupBehavior();
|
|
298
|
+
}
|
|
299
|
+
if (this.state === "stunned") {
|
|
300
|
+
if (currentTime >= this.stunnedUntil) {
|
|
301
|
+
this.changeState(
|
|
302
|
+
"combat"
|
|
303
|
+
/* Combat */
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (this.enemyType === "berserker" && this.event.param[MAXHP]) {
|
|
309
|
+
const hpPercent = this.event.hp / this.event.param[MAXHP];
|
|
310
|
+
const berserkerModifier = Math.max(0.3, hpPercent);
|
|
311
|
+
this.attackCooldown = 800 * berserkerModifier;
|
|
312
|
+
}
|
|
313
|
+
switch (this.state) {
|
|
314
|
+
case "idle":
|
|
315
|
+
this.updateIdleBehavior();
|
|
316
|
+
break;
|
|
317
|
+
case "alert":
|
|
318
|
+
this.updateAlertBehavior();
|
|
319
|
+
break;
|
|
320
|
+
case "combat":
|
|
321
|
+
this.updateCombatBehavior(currentTime);
|
|
322
|
+
break;
|
|
323
|
+
case "flee":
|
|
324
|
+
this.updateFleeBehavior();
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
this.checkDamageTaken();
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Update idle behavior (patrolling)
|
|
331
|
+
*/
|
|
332
|
+
updateIdleBehavior() {
|
|
333
|
+
if (this.patrolWaypoints.length > 0) {
|
|
334
|
+
const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
|
|
335
|
+
const distance = this.getDistance(this.event, {
|
|
336
|
+
x: () => waypoint.x,
|
|
337
|
+
y: () => waypoint.y
|
|
338
|
+
});
|
|
339
|
+
if (distance < 10) {
|
|
340
|
+
this.currentPatrolIndex = (this.currentPatrolIndex + 1) % this.patrolWaypoints.length;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Update alert behavior
|
|
346
|
+
*/
|
|
347
|
+
updateAlertBehavior() {
|
|
348
|
+
if (this.target) {
|
|
349
|
+
this.faceTarget();
|
|
350
|
+
const distance = this.getDistance(this.event, this.target);
|
|
351
|
+
if (distance <= this.attackRange * 1.5) {
|
|
352
|
+
this.changeState(
|
|
353
|
+
"combat"
|
|
354
|
+
/* Combat */
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
} else {
|
|
358
|
+
this.changeState(
|
|
359
|
+
"idle"
|
|
360
|
+
/* Idle */
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Update combat behavior
|
|
366
|
+
*/
|
|
367
|
+
updateCombatBehavior(currentTime) {
|
|
368
|
+
if (!this.target) {
|
|
369
|
+
this.debugLog("combat", "No target, returning to idle");
|
|
370
|
+
this.changeState(
|
|
371
|
+
"idle"
|
|
372
|
+
/* Idle */
|
|
373
|
+
);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const distance = this.getDistance(this.event, this.target);
|
|
377
|
+
if (distance > this.visionRange * 1.5) {
|
|
378
|
+
this.debugLog("combat", `Target out of range (dist=${distance.toFixed(1)}, maxRange=${(this.visionRange * 1.5).toFixed(1)})`);
|
|
379
|
+
this.target = null;
|
|
380
|
+
this.isMovingToTarget = false;
|
|
381
|
+
this.event.stopMoveTo();
|
|
382
|
+
this.changeState(
|
|
383
|
+
"idle"
|
|
384
|
+
/* Idle */
|
|
385
|
+
);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (this.event.param[MAXHP]) {
|
|
389
|
+
const hpPercent = this.event.hp / this.event.param[MAXHP];
|
|
390
|
+
if (hpPercent <= this.fleeThreshold) {
|
|
391
|
+
this.debugLog("combat", `HP low (${(hpPercent * 100).toFixed(0)}%), fleeing`);
|
|
392
|
+
this.isMovingToTarget = false;
|
|
393
|
+
this.changeState(
|
|
394
|
+
"flee"
|
|
395
|
+
/* Flee */
|
|
396
|
+
);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (this.canDodge() && this.shouldDodge()) {
|
|
401
|
+
this.debugLog("combat", "Attempting dodge");
|
|
402
|
+
this.isMovingToTarget = false;
|
|
403
|
+
this.tryDodge();
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (this.enemyType === "ranged") {
|
|
407
|
+
if (distance < this.attackRange * 0.6) {
|
|
408
|
+
this.debugLog("movement", `Retreating (dist=${distance.toFixed(1)}, minRange=${(this.attackRange * 0.6).toFixed(1)})`);
|
|
409
|
+
this.isMovingToTarget = false;
|
|
410
|
+
this.retreatFromTarget();
|
|
411
|
+
} else if (distance > this.attackRange) {
|
|
412
|
+
if (!this.isMovingToTarget) {
|
|
413
|
+
this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
414
|
+
this.isMovingToTarget = true;
|
|
415
|
+
this.event.moveTo(this.target);
|
|
416
|
+
}
|
|
417
|
+
} else {
|
|
418
|
+
if (this.isMovingToTarget) {
|
|
419
|
+
this.debugLog("movement", `In range, stopping (dist=${distance.toFixed(1)})`);
|
|
420
|
+
this.isMovingToTarget = false;
|
|
421
|
+
this.event.stopMoveTo();
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
} else {
|
|
425
|
+
if (distance > this.attackRange) {
|
|
426
|
+
if (!this.isMovingToTarget) {
|
|
427
|
+
this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
|
|
428
|
+
this.isMovingToTarget = true;
|
|
429
|
+
this.event.moveTo(this.target);
|
|
430
|
+
}
|
|
431
|
+
} else {
|
|
432
|
+
if (this.isMovingToTarget) {
|
|
433
|
+
this.debugLog("movement", `In range, stopping (dist=${distance.toFixed(1)})`);
|
|
434
|
+
this.isMovingToTarget = false;
|
|
435
|
+
this.event.stopMoveTo();
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (distance <= this.attackRange && currentTime - this.lastAttackTime >= this.attackCooldown) {
|
|
440
|
+
if (!this.chargingAttack) {
|
|
441
|
+
this.debugLog("attack", `Attacking (dist=${distance.toFixed(1)}, cooldown=${this.attackCooldown}ms)`);
|
|
442
|
+
this.selectAndPerformAttack();
|
|
443
|
+
this.lastAttackTime = currentTime;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Update flee behavior
|
|
449
|
+
*/
|
|
450
|
+
updateFleeBehavior() {
|
|
451
|
+
if (!this.target) {
|
|
452
|
+
this.changeState(
|
|
453
|
+
"idle"
|
|
454
|
+
/* Idle */
|
|
455
|
+
);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
const distance = this.getDistance(this.event, this.target);
|
|
459
|
+
if (this.event.param[MAXHP]) {
|
|
460
|
+
const hpPercent = this.event.hp / this.event.param[MAXHP];
|
|
461
|
+
if (hpPercent > this.fleeThreshold * 1.5 || distance > this.visionRange * 2) {
|
|
462
|
+
this.changeState(
|
|
463
|
+
"combat"
|
|
464
|
+
/* Combat */
|
|
465
|
+
);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
this.fleeFromTarget();
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Select and perform an attack pattern
|
|
473
|
+
*/
|
|
474
|
+
selectAndPerformAttack() {
|
|
475
|
+
if (!this.target) return;
|
|
476
|
+
if (this.comboCount > 0 && this.comboCount < this.comboMax) {
|
|
477
|
+
this.debugLog("attack", `Continuing combo (${this.comboCount}/${this.comboMax})`);
|
|
478
|
+
this.performComboAttack();
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
const pattern = this.selectAttackPattern();
|
|
482
|
+
this.debugLog("attack", `Selected pattern: ${pattern}`);
|
|
483
|
+
this.performAttackPattern(pattern);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Select attack pattern with weighted probability
|
|
487
|
+
*/
|
|
488
|
+
selectAttackPattern() {
|
|
489
|
+
const weights = {
|
|
490
|
+
[
|
|
491
|
+
"melee"
|
|
492
|
+
/* Melee */
|
|
493
|
+
]: 40,
|
|
494
|
+
[
|
|
495
|
+
"combo"
|
|
496
|
+
/* Combo */
|
|
497
|
+
]: 25,
|
|
498
|
+
[
|
|
499
|
+
"charged"
|
|
500
|
+
/* Charged */
|
|
501
|
+
]: 15,
|
|
502
|
+
[
|
|
503
|
+
"zone"
|
|
504
|
+
/* Zone */
|
|
505
|
+
]: 10,
|
|
506
|
+
[
|
|
507
|
+
"dashAttack"
|
|
508
|
+
/* DashAttack */
|
|
509
|
+
]: 10
|
|
510
|
+
};
|
|
511
|
+
switch (this.enemyType) {
|
|
512
|
+
case "aggressive":
|
|
513
|
+
weights[
|
|
514
|
+
"combo"
|
|
515
|
+
/* Combo */
|
|
516
|
+
] += 20;
|
|
517
|
+
weights[
|
|
518
|
+
"dashAttack"
|
|
519
|
+
/* DashAttack */
|
|
520
|
+
] += 15;
|
|
521
|
+
break;
|
|
522
|
+
case "defensive":
|
|
523
|
+
weights[
|
|
524
|
+
"charged"
|
|
525
|
+
/* Charged */
|
|
526
|
+
] += 25;
|
|
527
|
+
break;
|
|
528
|
+
case "ranged":
|
|
529
|
+
weights[
|
|
530
|
+
"zone"
|
|
531
|
+
/* Zone */
|
|
532
|
+
] += 20;
|
|
533
|
+
break;
|
|
534
|
+
case "tank":
|
|
535
|
+
weights[
|
|
536
|
+
"charged"
|
|
537
|
+
/* Charged */
|
|
538
|
+
] += 30;
|
|
539
|
+
weights[
|
|
540
|
+
"zone"
|
|
541
|
+
/* Zone */
|
|
542
|
+
] += 15;
|
|
543
|
+
break;
|
|
544
|
+
case "berserker":
|
|
545
|
+
weights[
|
|
546
|
+
"combo"
|
|
547
|
+
/* Combo */
|
|
548
|
+
] += 35;
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
let total = 0;
|
|
552
|
+
const available = [];
|
|
553
|
+
this.attackPatterns.forEach((p) => {
|
|
554
|
+
const weight = weights[p] || 10;
|
|
555
|
+
total += weight;
|
|
556
|
+
available.push({ pattern: p, weight });
|
|
557
|
+
});
|
|
558
|
+
let random = Math.random() * total;
|
|
559
|
+
for (const item of available) {
|
|
560
|
+
random -= item.weight;
|
|
561
|
+
if (random <= 0) return item.pattern;
|
|
562
|
+
}
|
|
563
|
+
return this.attackPatterns[0] || "melee";
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Perform attack pattern
|
|
567
|
+
*/
|
|
568
|
+
performAttackPattern(pattern) {
|
|
569
|
+
switch (pattern) {
|
|
570
|
+
case "melee":
|
|
571
|
+
this.performMeleeAttack();
|
|
572
|
+
break;
|
|
573
|
+
case "combo":
|
|
574
|
+
this.performComboAttack();
|
|
575
|
+
break;
|
|
576
|
+
case "charged":
|
|
577
|
+
this.performChargedAttack();
|
|
578
|
+
break;
|
|
579
|
+
case "zone":
|
|
580
|
+
this.performZoneAttack();
|
|
581
|
+
break;
|
|
582
|
+
case "dashAttack":
|
|
583
|
+
this.performDashAttack();
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Perform melee attack
|
|
589
|
+
* Uses skill if configured, otherwise creates hitbox
|
|
590
|
+
*/
|
|
591
|
+
performMeleeAttack() {
|
|
592
|
+
if (!this.target) return;
|
|
593
|
+
this.faceTarget();
|
|
594
|
+
this.event.setAnimation("attack", 1);
|
|
595
|
+
if (this.attackSkill) {
|
|
596
|
+
try {
|
|
597
|
+
this.event.useSkill(this.attackSkill, this.target);
|
|
598
|
+
} catch (e) {
|
|
599
|
+
this.performBasicHitbox();
|
|
600
|
+
}
|
|
601
|
+
} else {
|
|
602
|
+
this.performBasicHitbox();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Perform basic hitbox attack when no skill is set
|
|
607
|
+
*/
|
|
608
|
+
performBasicHitbox() {
|
|
609
|
+
if (!this.target) return;
|
|
610
|
+
const eventX = this.event.x();
|
|
611
|
+
const eventY = this.event.y();
|
|
612
|
+
const dx = this.target.x() - eventX;
|
|
613
|
+
const dy = this.target.y() - eventY;
|
|
614
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
615
|
+
if (dist === 0) return;
|
|
616
|
+
const dirX = dx / dist;
|
|
617
|
+
const dirY = dy / dist;
|
|
618
|
+
const hitboxes = [{
|
|
619
|
+
x: eventX + dirX * 30,
|
|
620
|
+
y: eventY + dirY * 30,
|
|
621
|
+
width: 40,
|
|
622
|
+
height: 40
|
|
623
|
+
}];
|
|
624
|
+
const map = this.event.getCurrentMap();
|
|
625
|
+
map?.createMovingHitbox(hitboxes, { speed: 5 }).subscribe({
|
|
626
|
+
next: (hits) => {
|
|
627
|
+
hits.forEach((hit) => {
|
|
628
|
+
if (hit instanceof RpgPlayer && hit !== this.event) {
|
|
629
|
+
this.applyHit(hit);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Apply hit to target using RPGJS damage system with knockback
|
|
637
|
+
*
|
|
638
|
+
* Calculates damage using RPGJS formula, applies knockback based on
|
|
639
|
+
* equipped weapon's knockbackForce property, and triggers visual effects.
|
|
640
|
+
* Supports hooks for customizing behavior.
|
|
641
|
+
*
|
|
642
|
+
* @param target - The player or entity being hit
|
|
643
|
+
* @param hooks - Optional hooks for customizing hit behavior
|
|
644
|
+
* @returns The hit result containing damage and knockback info
|
|
645
|
+
*
|
|
646
|
+
* @example
|
|
647
|
+
* ```ts
|
|
648
|
+
* // Basic hit
|
|
649
|
+
* this.applyHit(player);
|
|
650
|
+
*
|
|
651
|
+
* // With custom hooks
|
|
652
|
+
* this.applyHit(player, {
|
|
653
|
+
* onBeforeHit(result) {
|
|
654
|
+
* result.knockbackForce *= 1.5; // Increase knockback
|
|
655
|
+
* return result;
|
|
656
|
+
* },
|
|
657
|
+
* onAfterHit(result) {
|
|
658
|
+
* console.log(`Dealt ${result.damage} damage!`);
|
|
659
|
+
* }
|
|
660
|
+
* });
|
|
661
|
+
* ```
|
|
662
|
+
*/
|
|
663
|
+
applyHit(target, hooks) {
|
|
664
|
+
const { damage } = target.applyDamage(this.event);
|
|
665
|
+
const knockbackForce = this.getWeaponKnockbackForce();
|
|
666
|
+
const knockbackDuration = DEFAULT_KNOCKBACK.duration;
|
|
667
|
+
let hitResult = {
|
|
668
|
+
damage,
|
|
669
|
+
knockbackForce,
|
|
670
|
+
knockbackDuration,
|
|
671
|
+
defeated: target.hp <= 0,
|
|
672
|
+
attacker: this.event,
|
|
673
|
+
target
|
|
674
|
+
};
|
|
675
|
+
if (hooks?.onBeforeHit) {
|
|
676
|
+
const modified = hooks.onBeforeHit(hitResult);
|
|
677
|
+
if (modified) {
|
|
678
|
+
hitResult = modified;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
target.flash({
|
|
682
|
+
type: "tint",
|
|
683
|
+
tint: "red",
|
|
684
|
+
duration: 200,
|
|
685
|
+
cycles: 1
|
|
686
|
+
});
|
|
687
|
+
target.showHit(`-${hitResult.damage}`);
|
|
688
|
+
if (hitResult.knockbackForce > 0) {
|
|
689
|
+
const dx = target.x() - this.event.x();
|
|
690
|
+
const dy = target.y() - this.event.y();
|
|
691
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
692
|
+
if (distance > 0) {
|
|
693
|
+
const knockbackDirection = {
|
|
694
|
+
x: dx / distance,
|
|
695
|
+
y: dy / distance
|
|
696
|
+
};
|
|
697
|
+
target.knockback(knockbackDirection, hitResult.knockbackForce, hitResult.knockbackDuration);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
if (hooks?.onAfterHit) {
|
|
701
|
+
hooks.onAfterHit(hitResult);
|
|
702
|
+
}
|
|
703
|
+
return hitResult;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Get knockback force from equipped weapon
|
|
707
|
+
*
|
|
708
|
+
* Retrieves the knockbackForce property from the event's equipped weapon.
|
|
709
|
+
* Falls back to DEFAULT_KNOCKBACK.force if no weapon or property is set.
|
|
710
|
+
*
|
|
711
|
+
* @returns Knockback force value
|
|
712
|
+
*
|
|
713
|
+
* @example
|
|
714
|
+
* ```ts
|
|
715
|
+
* // Weapon with knockbackForce: 80
|
|
716
|
+
* const force = this.getWeaponKnockbackForce(); // 80
|
|
717
|
+
*
|
|
718
|
+
* // No weapon equipped
|
|
719
|
+
* const force = this.getWeaponKnockbackForce(); // 50 (default)
|
|
720
|
+
* ```
|
|
721
|
+
*/
|
|
722
|
+
getWeaponKnockbackForce() {
|
|
723
|
+
try {
|
|
724
|
+
const equipments = this.event.equipments?.() || [];
|
|
725
|
+
for (const item of equipments) {
|
|
726
|
+
const itemData = this.event.databaseById?.(item.id());
|
|
727
|
+
if (itemData?._type === "weapon" && itemData.knockbackForce !== void 0) {
|
|
728
|
+
return itemData.knockbackForce;
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
} catch {
|
|
732
|
+
}
|
|
733
|
+
return DEFAULT_KNOCKBACK.force;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Perform combo attack
|
|
737
|
+
*/
|
|
738
|
+
performComboAttack() {
|
|
739
|
+
if (!this.target) return;
|
|
740
|
+
this.comboCount++;
|
|
741
|
+
this.performMeleeAttack();
|
|
742
|
+
if (this.comboCount < this.comboMax) {
|
|
743
|
+
setTimeout(() => {
|
|
744
|
+
if (this.target && this.state === "combat") {
|
|
745
|
+
this.performComboAttack();
|
|
746
|
+
} else {
|
|
747
|
+
this.comboCount = 0;
|
|
748
|
+
}
|
|
749
|
+
}, 300);
|
|
750
|
+
} else {
|
|
751
|
+
this.comboCount = 0;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Perform charged attack
|
|
756
|
+
*/
|
|
757
|
+
performChargedAttack() {
|
|
758
|
+
if (!this.target) return;
|
|
759
|
+
this.chargingAttack = true;
|
|
760
|
+
this.faceTarget();
|
|
761
|
+
this.event.setAnimation("attack", 2);
|
|
762
|
+
setTimeout(() => {
|
|
763
|
+
if (!this.target || this.state !== "combat") {
|
|
764
|
+
this.chargingAttack = false;
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (this.attackSkill) {
|
|
768
|
+
try {
|
|
769
|
+
this.event.useSkill(this.attackSkill, this.target);
|
|
770
|
+
} catch (e) {
|
|
771
|
+
this.performBasicHitbox();
|
|
772
|
+
}
|
|
773
|
+
} else {
|
|
774
|
+
this.performBasicHitbox();
|
|
775
|
+
}
|
|
776
|
+
this.chargingAttack = false;
|
|
777
|
+
}, 800);
|
|
778
|
+
}
|
|
779
|
+
/**
|
|
780
|
+
* Perform zone attack (360 degrees)
|
|
781
|
+
*/
|
|
782
|
+
performZoneAttack() {
|
|
783
|
+
this.event.setAnimation("attack", 1);
|
|
784
|
+
const eventX = this.event.x();
|
|
785
|
+
const eventY = this.event.y();
|
|
786
|
+
const radius = 50;
|
|
787
|
+
const hitboxes = [];
|
|
788
|
+
const angles = [0, 90, 180, 270];
|
|
789
|
+
angles.forEach((angle) => {
|
|
790
|
+
const rad = angle * Math.PI / 180;
|
|
791
|
+
hitboxes.push({
|
|
792
|
+
x: eventX + Math.cos(rad) * radius,
|
|
793
|
+
y: eventY + Math.sin(rad) * radius,
|
|
794
|
+
width: 40,
|
|
795
|
+
height: 40
|
|
796
|
+
});
|
|
797
|
+
});
|
|
798
|
+
const map = this.event.getCurrentMap();
|
|
799
|
+
map?.createMovingHitbox(hitboxes, { speed: 5 }).subscribe({
|
|
800
|
+
next: (hits) => {
|
|
801
|
+
hits.forEach((hit) => {
|
|
802
|
+
if (hit instanceof RpgPlayer && hit !== this.event) {
|
|
803
|
+
this.applyHit(hit);
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Perform dash attack
|
|
811
|
+
*/
|
|
812
|
+
performDashAttack() {
|
|
813
|
+
if (!this.target) return;
|
|
814
|
+
const dx = this.target.x() - this.event.x();
|
|
815
|
+
const dy = this.target.y() - this.event.y();
|
|
816
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
817
|
+
if (dist === 0) return;
|
|
818
|
+
const dirX = dx / dist;
|
|
819
|
+
const dirY = dy / dist;
|
|
820
|
+
this.faceTarget();
|
|
821
|
+
this.event.dash({ x: dirX, y: dirY }, 10, 200);
|
|
822
|
+
setTimeout(() => {
|
|
823
|
+
if (!this.target || this.state !== "combat") return;
|
|
824
|
+
this.performMeleeAttack();
|
|
825
|
+
}, 200);
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Face the current target with hysteresis to prevent animation flickering
|
|
829
|
+
*
|
|
830
|
+
* Uses multiple strategies to prevent flickering:
|
|
831
|
+
* 1. When very close to target (collision), keep current direction
|
|
832
|
+
* 2. When near diagonal, require significant difference to change
|
|
833
|
+
* 3. Only change if direction is clearly wrong (opposite)
|
|
834
|
+
*/
|
|
835
|
+
faceTarget() {
|
|
836
|
+
if (!this.target) return;
|
|
837
|
+
const dx = this.target.x() - this.event.x();
|
|
838
|
+
const dy = this.target.y() - this.event.y();
|
|
839
|
+
const absX = Math.abs(dx);
|
|
840
|
+
const absY = Math.abs(dy);
|
|
841
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
842
|
+
const minDistanceForDirectionChange = 40;
|
|
843
|
+
if (this.lastFacingDirection && distance < minDistanceForDirectionChange) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
let newDirection;
|
|
847
|
+
if (absX >= absY) {
|
|
848
|
+
newDirection = dx >= 0 ? "right" : "left";
|
|
849
|
+
} else {
|
|
850
|
+
newDirection = dy >= 0 ? "down" : "up";
|
|
851
|
+
}
|
|
852
|
+
const hysteresisThreshold = 0.2;
|
|
853
|
+
const ratio = absX > 0 || absY > 0 ? Math.min(absX, absY) / Math.max(absX, absY) : 0;
|
|
854
|
+
if (this.lastFacingDirection && ratio > 1 - hysteresisThreshold) {
|
|
855
|
+
const isOpposite = this.lastFacingDirection === "left" && dx > 20 || this.lastFacingDirection === "right" && dx < -20 || this.lastFacingDirection === "up" && dy > 20 || this.lastFacingDirection === "down" && dy < -20;
|
|
856
|
+
if (!isOpposite) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
this.lastFacingDirection = newDirection;
|
|
861
|
+
this.event.changeDirection(newDirection);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Try to dodge
|
|
865
|
+
*/
|
|
866
|
+
tryDodge() {
|
|
867
|
+
const currentTime = Date.now();
|
|
868
|
+
if (currentTime - this.lastDodgeTime < this.dodgeCooldown) {
|
|
869
|
+
this.debugLog("dodge", `Dodge on cooldown (${this.dodgeCooldown - (currentTime - this.lastDodgeTime)}ms remaining)`);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
if (Math.random() > this.dodgeChance) {
|
|
873
|
+
this.debugLog("dodge", `Dodge roll failed (chance=${(this.dodgeChance * 100).toFixed(0)}%)`);
|
|
874
|
+
return;
|
|
875
|
+
}
|
|
876
|
+
if (!this.target) return;
|
|
877
|
+
const dx = this.target.x() - this.event.x();
|
|
878
|
+
const dy = this.target.y() - this.event.y();
|
|
879
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
880
|
+
if (dist === 0) return;
|
|
881
|
+
const dodgeDirX = -dy / dist;
|
|
882
|
+
const dodgeDirY = dx / dist;
|
|
883
|
+
const side = Math.random() > 0.5 ? 1 : -1;
|
|
884
|
+
this.debugLog("dodge", `Dodging (dir=${side > 0 ? "right" : "left"})`);
|
|
885
|
+
this.event.dash({ x: dodgeDirX * side, y: dodgeDirY * side }, 12, 300);
|
|
886
|
+
this.lastDodgeTime = currentTime;
|
|
887
|
+
if (this.enemyType === "defensive" && Math.random() < 0.5) {
|
|
888
|
+
this.debugLog("dodge", "Counter-attack after dodge");
|
|
889
|
+
setTimeout(() => {
|
|
890
|
+
if (this.target && this.state === "combat") {
|
|
891
|
+
this.selectAndPerformAttack();
|
|
892
|
+
}
|
|
893
|
+
}, 400);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
canDodge() {
|
|
897
|
+
if (this.dodgeChance === 0) return false;
|
|
898
|
+
return Date.now() - this.lastDodgeTime >= this.dodgeCooldown;
|
|
899
|
+
}
|
|
900
|
+
shouldDodge() {
|
|
901
|
+
if (!this.target) return false;
|
|
902
|
+
const distance = this.getDistance(this.event, this.target);
|
|
903
|
+
return distance < this.attackRange * 0.8;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Flee from target
|
|
907
|
+
*/
|
|
908
|
+
fleeFromTarget() {
|
|
909
|
+
if (!this.target) return;
|
|
910
|
+
const dx = this.event.x() - this.target.x();
|
|
911
|
+
const dy = this.event.y() - this.target.y();
|
|
912
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
913
|
+
if (dist === 0) return;
|
|
914
|
+
const fleeTarget = {
|
|
915
|
+
x: () => this.event.x() + dx / dist * 200,
|
|
916
|
+
y: () => this.event.y() + dy / dist * 200
|
|
917
|
+
};
|
|
918
|
+
this.event.moveTo(fleeTarget);
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Retreat from target (temporary)
|
|
922
|
+
*/
|
|
923
|
+
retreatFromTarget() {
|
|
924
|
+
if (!this.target) return;
|
|
925
|
+
const dx = this.event.x() - this.target.x();
|
|
926
|
+
const dy = this.event.y() - this.target.y();
|
|
927
|
+
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
928
|
+
if (dist === 0) return;
|
|
929
|
+
this.event.dash({ x: dx / dist, y: dy / dist }, 8, 200);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Check damage taken for retreat decision
|
|
933
|
+
*/
|
|
934
|
+
checkDamageTaken() {
|
|
935
|
+
const currentTime = Date.now();
|
|
936
|
+
if (currentTime - this.lastHpCheck >= this.damageCheckInterval) {
|
|
937
|
+
this.recentDamageTaken = 0;
|
|
938
|
+
this.lastHpCheck = currentTime;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Start patrol
|
|
943
|
+
*/
|
|
944
|
+
startPatrol() {
|
|
945
|
+
if (this.patrolWaypoints.length === 0) return;
|
|
946
|
+
const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
|
|
947
|
+
this.event.moveTo({ x: () => waypoint.x, y: () => waypoint.y });
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Update group behavior
|
|
951
|
+
*/
|
|
952
|
+
updateGroupBehavior() {
|
|
953
|
+
if (!this.groupBehavior) return;
|
|
954
|
+
this.groupUpdateInterval++;
|
|
955
|
+
if (this.groupUpdateInterval >= 20) {
|
|
956
|
+
this.groupUpdateInterval = 0;
|
|
957
|
+
this.findNearbyEnemies();
|
|
958
|
+
}
|
|
959
|
+
if (this.nearbyEnemies.length > 0 && this.target && this.state === "combat") {
|
|
960
|
+
this.applyFormation();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Find nearby enemies
|
|
965
|
+
*/
|
|
966
|
+
findNearbyEnemies() {
|
|
967
|
+
this.nearbyEnemies = [];
|
|
968
|
+
const map = this.event.getCurrentMap();
|
|
969
|
+
if (!map) return;
|
|
970
|
+
const allEvents = Object.values(map.events());
|
|
971
|
+
const groupRadius = 150;
|
|
972
|
+
allEvents.forEach((event) => {
|
|
973
|
+
if (event === this.event) return;
|
|
974
|
+
const ai = event.battleAi;
|
|
975
|
+
if (ai && ai.groupBehavior) {
|
|
976
|
+
const distance = this.getDistance(this.event, event);
|
|
977
|
+
if (distance <= groupRadius) {
|
|
978
|
+
this.nearbyEnemies.push(ai);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
/**
|
|
984
|
+
* Apply formation around target
|
|
985
|
+
*/
|
|
986
|
+
applyFormation() {
|
|
987
|
+
if (!this.target || this.nearbyEnemies.length === 0) return;
|
|
988
|
+
const totalEnemies = this.nearbyEnemies.length + 1;
|
|
989
|
+
const angleStep = 2 * Math.PI / totalEnemies;
|
|
990
|
+
let ourIndex = 0;
|
|
991
|
+
for (let i = 0; i < this.nearbyEnemies.length; i++) {
|
|
992
|
+
if (this.nearbyEnemies[i].event.id < this.event.id) {
|
|
993
|
+
ourIndex++;
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
const angle = angleStep * ourIndex;
|
|
997
|
+
const formationRadius = this.attackRange * 1.2;
|
|
998
|
+
const formationX = this.target.x() + Math.cos(angle) * formationRadius;
|
|
999
|
+
const formationY = this.target.y() + Math.sin(angle) * formationRadius;
|
|
1000
|
+
const distanceToFormation = Math.sqrt(
|
|
1001
|
+
Math.pow(this.event.x() - formationX, 2) + Math.pow(this.event.y() - formationY, 2)
|
|
1002
|
+
);
|
|
1003
|
+
if (distanceToFormation > 20) {
|
|
1004
|
+
this.event.moveTo({ x: () => formationX, y: () => formationY });
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Handle player entering vision
|
|
1009
|
+
*/
|
|
1010
|
+
onDetectInShape(player, shape) {
|
|
1011
|
+
this.debugLog("vision", `Player ${player.id} entered vision (state=${this.state})`);
|
|
1012
|
+
this.target = player;
|
|
1013
|
+
if (this.state === "idle") {
|
|
1014
|
+
this.changeState(
|
|
1015
|
+
"alert"
|
|
1016
|
+
/* Alert */
|
|
1017
|
+
);
|
|
1018
|
+
} else if (this.state === "alert") {
|
|
1019
|
+
this.changeState(
|
|
1020
|
+
"combat"
|
|
1021
|
+
/* Combat */
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Handle player leaving vision
|
|
1027
|
+
*/
|
|
1028
|
+
onDetectOutShape(player, shape) {
|
|
1029
|
+
this.debugLog("vision", `Player ${player.id} left vision (wasTarget=${this.target === player})`);
|
|
1030
|
+
if (this.target === player) {
|
|
1031
|
+
this.target = null;
|
|
1032
|
+
this.isMovingToTarget = false;
|
|
1033
|
+
this.event.stopMoveTo();
|
|
1034
|
+
this.changeState(
|
|
1035
|
+
"idle"
|
|
1036
|
+
/* Idle */
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Handle taking damage (called from server.ts)
|
|
1042
|
+
*
|
|
1043
|
+
* This triggers state changes like stun and flee check.
|
|
1044
|
+
* The actual damage is applied externally via RPGJS API.
|
|
1045
|
+
*/
|
|
1046
|
+
takeDamage(attacker) {
|
|
1047
|
+
const { damage } = this.event.applyDamage(attacker);
|
|
1048
|
+
this.debugLog("damage", `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || "?"})`);
|
|
1049
|
+
this.event.flash({
|
|
1050
|
+
type: "tint",
|
|
1051
|
+
tint: "red",
|
|
1052
|
+
duration: 200,
|
|
1053
|
+
cycles: 1
|
|
1054
|
+
});
|
|
1055
|
+
this.event.showHit(`-${damage}`);
|
|
1056
|
+
this.recentDamageTaken += damage;
|
|
1057
|
+
if (this.state !== "stunned" && this.state !== "flee") {
|
|
1058
|
+
this.debugLog("damage", "Stunned from damage");
|
|
1059
|
+
this.isMovingToTarget = false;
|
|
1060
|
+
this.stunnedUntil = Date.now() + 150;
|
|
1061
|
+
this.changeState(
|
|
1062
|
+
"stunned"
|
|
1063
|
+
/* Stunned */
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
if (this.event.hp <= 0) {
|
|
1067
|
+
this.debugLog("damage", "Defeated!");
|
|
1068
|
+
this.kill();
|
|
1069
|
+
return true;
|
|
1070
|
+
}
|
|
1071
|
+
return false;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Kill this AI
|
|
1075
|
+
*
|
|
1076
|
+
* Stops all movements, cleans up resources, calls the onDefeated hook,
|
|
1077
|
+
* and removes the event from the map.
|
|
1078
|
+
*/
|
|
1079
|
+
kill() {
|
|
1080
|
+
if (this.onDefeatedCallback) {
|
|
1081
|
+
this.onDefeatedCallback(this.event);
|
|
1082
|
+
}
|
|
1083
|
+
this.destroy();
|
|
1084
|
+
this.event.remove();
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Get distance between entities
|
|
1088
|
+
*/
|
|
1089
|
+
getDistance(entity1, entity2) {
|
|
1090
|
+
const dx = entity1.x() - entity2.x();
|
|
1091
|
+
const dy = entity1.y() - entity2.y();
|
|
1092
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
1093
|
+
}
|
|
1094
|
+
// Public getters
|
|
1095
|
+
getHealth() {
|
|
1096
|
+
return this.event.hp;
|
|
1097
|
+
}
|
|
1098
|
+
getMaxHealth() {
|
|
1099
|
+
return this.event.param[MAXHP];
|
|
1100
|
+
}
|
|
1101
|
+
getTarget() {
|
|
1102
|
+
return this.target;
|
|
1103
|
+
}
|
|
1104
|
+
getState() {
|
|
1105
|
+
return this.state;
|
|
1106
|
+
}
|
|
1107
|
+
getEnemyType() {
|
|
1108
|
+
return this.enemyType;
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Clean up
|
|
1112
|
+
*/
|
|
1113
|
+
destroy() {
|
|
1114
|
+
if (this.updateInterval) {
|
|
1115
|
+
clearInterval(this.updateInterval);
|
|
1116
|
+
this.updateInterval = void 0;
|
|
1117
|
+
}
|
|
1118
|
+
this.target = null;
|
|
1119
|
+
this.nearbyEnemies = [];
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
export {
|
|
1123
|
+
AiDebug,
|
|
1124
|
+
AiState,
|
|
1125
|
+
AttackPattern,
|
|
1126
|
+
BattleAi,
|
|
1127
|
+
DEFAULT_KNOCKBACK,
|
|
1128
|
+
EnemyType
|
|
1129
|
+
};
|