@rpgjs/action-battle 5.0.0-alpha.30 → 5.0.0-alpha.32

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 CHANGED
@@ -601,7 +601,7 @@ import {
601
601
 
602
602
  // Custom attack with hooks
603
603
  function customAttack(player: RpgPlayer) {
604
- player.setAnimation('attack', 1);
604
+ player.setGraphicAnimation('attack', 1);
605
605
 
606
606
  const direction = player.getDirection();
607
607
  const hitboxConfig = DEFAULT_PLAYER_ATTACK_HITBOXES[direction] || DEFAULT_PLAYER_ATTACK_HITBOXES.default;
@@ -235,6 +235,18 @@ export declare class BattleAi {
235
235
  private isMovingToTarget;
236
236
  private onDefeatedCallback?;
237
237
  private lastFacingDirection;
238
+ private behaviorScore;
239
+ private behaviorMode;
240
+ private behaviorLastUpdate;
241
+ private behaviorUpdateInterval;
242
+ private behaviorAssaultThreshold;
243
+ private behaviorRetreatThreshold;
244
+ private behaviorMinStateDuration;
245
+ private behaviorEnabled;
246
+ private moveToCooldown;
247
+ private lastMoveToTime;
248
+ private retreatCooldown;
249
+ private lastRetreatTime;
238
250
  /**
239
251
  * Create a new Battle AI Controller
240
252
  *
@@ -274,6 +286,15 @@ export declare class BattleAi {
274
286
  y: number;
275
287
  }>;
276
288
  groupBehavior?: boolean;
289
+ moveToCooldown?: number;
290
+ retreatCooldown?: number;
291
+ behavior?: {
292
+ baseScore?: number;
293
+ updateInterval?: number;
294
+ minStateDuration?: number;
295
+ assaultThreshold?: number;
296
+ retreatThreshold?: number;
297
+ };
277
298
  /** Callback called when the AI is defeated */
278
299
  onDefeated?: (event: RpgEvent) => void;
279
300
  });
@@ -469,6 +490,10 @@ export declare class BattleAi {
469
490
  * Get distance between entities
470
491
  */
471
492
  private getDistance;
493
+ private updateBehavior;
494
+ private handleTacticalMovement;
495
+ private handleAssaultMovement;
496
+ private requestMoveTo;
472
497
  getHealth(): number;
473
498
  getMaxHealth(): number;
474
499
  getTarget(): InstanceType<typeof RpgPlayer> | null;
@@ -101,6 +101,18 @@ class BattleAi {
101
101
  this.damageCheckInterval = 2e3;
102
102
  this.isMovingToTarget = false;
103
103
  this.lastFacingDirection = null;
104
+ this.behaviorScore = 50;
105
+ this.behaviorMode = "tactical";
106
+ this.behaviorLastUpdate = 0;
107
+ this.behaviorUpdateInterval = 400;
108
+ this.behaviorAssaultThreshold = 65;
109
+ this.behaviorRetreatThreshold = 35;
110
+ this.behaviorMinStateDuration = 600;
111
+ this.behaviorEnabled = false;
112
+ this.moveToCooldown = 400;
113
+ this.lastMoveToTime = 0;
114
+ this.retreatCooldown = 600;
115
+ this.lastRetreatTime = 0;
104
116
  event.battleAi = this;
105
117
  this.event = event;
106
118
  this.enemyType = options.enemyType || "aggressive";
@@ -116,6 +128,30 @@ class BattleAi {
116
128
  this.patrolWaypoints = options.patrolWaypoints || [];
117
129
  this.currentPatrolIndex = 0;
118
130
  this.onDefeatedCallback = options.onDefeated;
131
+ if (options.behavior) {
132
+ this.behaviorEnabled = true;
133
+ if (options.behavior.baseScore !== void 0) {
134
+ this.behaviorScore = options.behavior.baseScore;
135
+ }
136
+ if (options.behavior.updateInterval !== void 0) {
137
+ this.behaviorUpdateInterval = options.behavior.updateInterval;
138
+ }
139
+ if (options.behavior.minStateDuration !== void 0) {
140
+ this.behaviorMinStateDuration = options.behavior.minStateDuration;
141
+ }
142
+ if (options.behavior.assaultThreshold !== void 0) {
143
+ this.behaviorAssaultThreshold = options.behavior.assaultThreshold;
144
+ }
145
+ if (options.behavior.retreatThreshold !== void 0) {
146
+ this.behaviorRetreatThreshold = options.behavior.retreatThreshold;
147
+ }
148
+ }
149
+ if (options.moveToCooldown !== void 0) {
150
+ this.moveToCooldown = options.moveToCooldown;
151
+ }
152
+ if (options.retreatCooldown !== void 0) {
153
+ this.retreatCooldown = options.retreatCooldown;
154
+ }
119
155
  this.setupVision();
120
156
  this.startAiBehaviorLoop();
121
157
  this.changeState(
@@ -310,6 +346,9 @@ class BattleAi {
310
346
  const berserkerModifier = Math.max(0.3, hpPercent);
311
347
  this.attackCooldown = 800 * berserkerModifier;
312
348
  }
349
+ if (this.behaviorEnabled) {
350
+ this.updateBehavior(currentTime);
351
+ }
313
352
  switch (this.state) {
314
353
  case "idle":
315
354
  this.updateIdleBehavior();
@@ -403,7 +442,20 @@ class BattleAi {
403
442
  this.tryDodge();
404
443
  return;
405
444
  }
406
- if (this.enemyType === "ranged") {
445
+ if (this.behaviorEnabled) {
446
+ if (this.behaviorMode === "tactical") {
447
+ this.handleTacticalMovement(distance);
448
+ } else if (this.behaviorMode === "assault") {
449
+ this.handleAssaultMovement(distance);
450
+ } else if (this.behaviorMode === "retreat") {
451
+ this.isMovingToTarget = false;
452
+ this.fleeFromTarget();
453
+ return;
454
+ }
455
+ }
456
+ if (this.behaviorEnabled && this.behaviorMode === "assault") ;
457
+ else if (this.behaviorEnabled && this.behaviorMode === "tactical") ;
458
+ else if (this.enemyType === "ranged") {
407
459
  if (distance < this.attackRange * 0.6) {
408
460
  this.debugLog("movement", `Retreating (dist=${distance.toFixed(1)}, minRange=${(this.attackRange * 0.6).toFixed(1)})`);
409
461
  this.isMovingToTarget = false;
@@ -412,7 +464,7 @@ class BattleAi {
412
464
  if (!this.isMovingToTarget) {
413
465
  this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
414
466
  this.isMovingToTarget = true;
415
- this.event.moveTo(this.target);
467
+ this.requestMoveTo(this.target);
416
468
  }
417
469
  } else {
418
470
  if (this.isMovingToTarget) {
@@ -426,7 +478,7 @@ class BattleAi {
426
478
  if (!this.isMovingToTarget) {
427
479
  this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
428
480
  this.isMovingToTarget = true;
429
- this.event.moveTo(this.target);
481
+ this.requestMoveTo(this.target);
430
482
  }
431
483
  } else {
432
484
  if (this.isMovingToTarget) {
@@ -591,7 +643,7 @@ class BattleAi {
591
643
  performMeleeAttack() {
592
644
  if (!this.target) return;
593
645
  this.faceTarget();
594
- this.event.setAnimation("attack", 1);
646
+ this.event.setGraphicAnimation("attack", 1);
595
647
  if (this.attackSkill) {
596
648
  try {
597
649
  this.event.useSkill(this.attackSkill, this.target);
@@ -758,7 +810,7 @@ class BattleAi {
758
810
  if (!this.target) return;
759
811
  this.chargingAttack = true;
760
812
  this.faceTarget();
761
- this.event.setAnimation("attack", 2);
813
+ this.event.setGraphicAnimation("attack", 2);
762
814
  setTimeout(() => {
763
815
  if (!this.target || this.state !== "combat") {
764
816
  this.chargingAttack = false;
@@ -780,7 +832,7 @@ class BattleAi {
780
832
  * Perform zone attack (360 degrees)
781
833
  */
782
834
  performZoneAttack() {
783
- this.event.setAnimation("attack", 1);
835
+ this.event.setGraphicAnimation("attack", 1);
784
836
  const eventX = this.event.x();
785
837
  const eventY = this.event.y();
786
838
  const radius = 50;
@@ -915,18 +967,23 @@ class BattleAi {
915
967
  x: () => this.event.x() + dx / dist * 200,
916
968
  y: () => this.event.y() + dy / dist * 200
917
969
  };
918
- this.event.moveTo(fleeTarget);
970
+ this.requestMoveTo(fleeTarget);
919
971
  }
920
972
  /**
921
973
  * Retreat from target (temporary)
922
974
  */
923
975
  retreatFromTarget() {
924
976
  if (!this.target) return;
977
+ const currentTime = Date.now();
978
+ if (currentTime - this.lastRetreatTime < this.retreatCooldown) {
979
+ return;
980
+ }
925
981
  const dx = this.event.x() - this.target.x();
926
982
  const dy = this.event.y() - this.target.y();
927
983
  const dist = Math.sqrt(dx * dx + dy * dy);
928
984
  if (dist === 0) return;
929
985
  this.event.dash({ x: dx / dist, y: dy / dist }, 8, 200);
986
+ this.lastRetreatTime = currentTime;
930
987
  }
931
988
  /**
932
989
  * Check damage taken for retreat decision
@@ -944,7 +1001,7 @@ class BattleAi {
944
1001
  startPatrol() {
945
1002
  if (this.patrolWaypoints.length === 0) return;
946
1003
  const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
947
- this.event.moveTo({ x: () => waypoint.x, y: () => waypoint.y });
1004
+ this.requestMoveTo({ x: () => waypoint.x, y: () => waypoint.y });
948
1005
  }
949
1006
  /**
950
1007
  * Update group behavior
@@ -1001,7 +1058,7 @@ class BattleAi {
1001
1058
  Math.pow(this.event.x() - formationX, 2) + Math.pow(this.event.y() - formationY, 2)
1002
1059
  );
1003
1060
  if (distanceToFormation > 20) {
1004
- this.event.moveTo({ x: () => formationX, y: () => formationY });
1061
+ this.requestMoveTo({ x: () => formationX, y: () => formationY });
1005
1062
  }
1006
1063
  }
1007
1064
  /**
@@ -1091,6 +1148,110 @@ class BattleAi {
1091
1148
  const dy = entity1.y() - entity2.y();
1092
1149
  return Math.sqrt(dx * dx + dy * dy);
1093
1150
  }
1151
+ updateBehavior(currentTime) {
1152
+ if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) {
1153
+ return;
1154
+ }
1155
+ this.behaviorLastUpdate = currentTime;
1156
+ let score = this.behaviorScore;
1157
+ const maxHp = this.event.param[MAXHP];
1158
+ if (maxHp) {
1159
+ const hpPercent = this.event.hp / maxHp;
1160
+ score += (hpPercent - 0.5) * 40;
1161
+ }
1162
+ if (this.recentDamageTaken > 0) {
1163
+ score -= Math.min(30, this.recentDamageTaken * 0.5);
1164
+ }
1165
+ if (this.target) {
1166
+ const distance = this.getDistance(this.event, this.target);
1167
+ if (distance <= this.attackRange) {
1168
+ score += 10;
1169
+ } else if (distance > this.visionRange) {
1170
+ score -= 10;
1171
+ }
1172
+ }
1173
+ if (this.groupBehavior && this.nearbyEnemies.length > 0) {
1174
+ score += Math.min(15, this.nearbyEnemies.length * 5);
1175
+ }
1176
+ score = Math.max(0, Math.min(100, score));
1177
+ this.behaviorScore = score;
1178
+ const previousMode = this.behaviorMode;
1179
+ if (score >= this.behaviorAssaultThreshold) {
1180
+ this.behaviorMode = "assault";
1181
+ } else if (score <= this.behaviorRetreatThreshold) {
1182
+ this.behaviorMode = "retreat";
1183
+ } else {
1184
+ this.behaviorMode = "tactical";
1185
+ }
1186
+ if (previousMode !== this.behaviorMode) {
1187
+ this.debugLog("state", `Behavior mode: ${previousMode} -> ${this.behaviorMode} (score=${score.toFixed(0)})`);
1188
+ }
1189
+ if (this.behaviorMode === "retreat" && this.state === "combat") {
1190
+ if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) {
1191
+ this.isMovingToTarget = false;
1192
+ this.changeState(
1193
+ "flee"
1194
+ /* Flee */
1195
+ );
1196
+ }
1197
+ } else if (this.behaviorMode === "assault" && this.state === "flee") {
1198
+ if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) {
1199
+ this.changeState(
1200
+ "combat"
1201
+ /* Combat */
1202
+ );
1203
+ }
1204
+ }
1205
+ }
1206
+ handleTacticalMovement(distance) {
1207
+ if (!this.target) return;
1208
+ const minRange = this.attackRange * 0.7;
1209
+ const maxRange = this.attackRange * 1.2;
1210
+ if (distance < minRange) {
1211
+ this.debugLog("movement", `Tactical retreat (dist=${distance.toFixed(1)}, minRange=${minRange.toFixed(1)})`);
1212
+ this.isMovingToTarget = false;
1213
+ this.retreatFromTarget();
1214
+ return;
1215
+ }
1216
+ if (distance > maxRange) {
1217
+ if (!this.isMovingToTarget) {
1218
+ this.debugLog("movement", `Tactical approach (dist=${distance.toFixed(1)}, maxRange=${maxRange.toFixed(1)})`);
1219
+ this.isMovingToTarget = true;
1220
+ this.requestMoveTo(this.target);
1221
+ }
1222
+ return;
1223
+ }
1224
+ if (this.isMovingToTarget) {
1225
+ this.debugLog("movement", `Tactical hold (dist=${distance.toFixed(1)})`);
1226
+ this.isMovingToTarget = false;
1227
+ this.event.stopMoveTo();
1228
+ }
1229
+ }
1230
+ handleAssaultMovement(distance) {
1231
+ if (!this.target) return;
1232
+ if (distance > this.attackRange) {
1233
+ if (!this.isMovingToTarget) {
1234
+ this.debugLog("movement", `Assault approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1235
+ this.isMovingToTarget = true;
1236
+ this.requestMoveTo(this.target);
1237
+ }
1238
+ return;
1239
+ }
1240
+ if (this.isMovingToTarget) {
1241
+ this.debugLog("movement", `Assault hold (dist=${distance.toFixed(1)})`);
1242
+ this.isMovingToTarget = false;
1243
+ this.event.stopMoveTo();
1244
+ }
1245
+ }
1246
+ requestMoveTo(target) {
1247
+ const currentTime = Date.now();
1248
+ if (currentTime - this.lastMoveToTime < this.moveToCooldown) {
1249
+ return false;
1250
+ }
1251
+ this.event.moveTo(target);
1252
+ this.lastMoveToTime = currentTime;
1253
+ return true;
1254
+ }
1094
1255
  // Public getters
1095
1256
  getHealth() {
1096
1257
  return this.event.hp;
@@ -1,4 +1,4 @@
1
- import { defineModule } from "@rpgjs/common";
1
+ import { defineModule, Control } from "@rpgjs/common";
2
2
  const RpgEvent = null;
3
3
  const DEFAULT_KNOCKBACK = null;
4
4
  const DEFAULT_PLAYER_ATTACK_HITBOXES = {
@@ -70,8 +70,8 @@ defineModule({
70
70
  * @param input - Input data containing pressed keys
71
71
  */
72
72
  onInput(player, input) {
73
- if (input.action) {
74
- player.setAnimation("attack", 1);
73
+ if (input.action == Control.Action) {
74
+ player.setGraphicAnimation("attack", 1);
75
75
  const playerX = player.x();
76
76
  const playerY = player.y();
77
77
  const direction = player.getDirection();
@@ -1,5 +1,5 @@
1
1
  import { RpgEvent } from "@rpgjs/server";
2
- import { defineModule } from "@rpgjs/common";
2
+ import { defineModule, Control } from "@rpgjs/common";
3
3
  import { DEFAULT_KNOCKBACK } from "./index3.js";
4
4
  const DEFAULT_PLAYER_ATTACK_HITBOXES = {
5
5
  up: { offsetX: -16, offsetY: -48, width: 32, height: 32 },
@@ -70,8 +70,8 @@ const server = defineModule({
70
70
  * @param input - Input data containing pressed keys
71
71
  */
72
72
  onInput(player, input) {
73
- if (input.action) {
74
- player.setAnimation("attack", 1);
73
+ if (input.action == Control.Action) {
74
+ player.setGraphicAnimation("attack", 1);
75
75
  const playerX = player.x();
76
76
  const playerY = player.y();
77
77
  const direction = player.getDirection();
@@ -100,6 +100,18 @@ class BattleAi {
100
100
  this.damageCheckInterval = 2e3;
101
101
  this.isMovingToTarget = false;
102
102
  this.lastFacingDirection = null;
103
+ this.behaviorScore = 50;
104
+ this.behaviorMode = "tactical";
105
+ this.behaviorLastUpdate = 0;
106
+ this.behaviorUpdateInterval = 400;
107
+ this.behaviorAssaultThreshold = 65;
108
+ this.behaviorRetreatThreshold = 35;
109
+ this.behaviorMinStateDuration = 600;
110
+ this.behaviorEnabled = false;
111
+ this.moveToCooldown = 400;
112
+ this.lastMoveToTime = 0;
113
+ this.retreatCooldown = 600;
114
+ this.lastRetreatTime = 0;
103
115
  event.battleAi = this;
104
116
  this.event = event;
105
117
  this.enemyType = options.enemyType || "aggressive";
@@ -115,6 +127,30 @@ class BattleAi {
115
127
  this.patrolWaypoints = options.patrolWaypoints || [];
116
128
  this.currentPatrolIndex = 0;
117
129
  this.onDefeatedCallback = options.onDefeated;
130
+ if (options.behavior) {
131
+ this.behaviorEnabled = true;
132
+ if (options.behavior.baseScore !== void 0) {
133
+ this.behaviorScore = options.behavior.baseScore;
134
+ }
135
+ if (options.behavior.updateInterval !== void 0) {
136
+ this.behaviorUpdateInterval = options.behavior.updateInterval;
137
+ }
138
+ if (options.behavior.minStateDuration !== void 0) {
139
+ this.behaviorMinStateDuration = options.behavior.minStateDuration;
140
+ }
141
+ if (options.behavior.assaultThreshold !== void 0) {
142
+ this.behaviorAssaultThreshold = options.behavior.assaultThreshold;
143
+ }
144
+ if (options.behavior.retreatThreshold !== void 0) {
145
+ this.behaviorRetreatThreshold = options.behavior.retreatThreshold;
146
+ }
147
+ }
148
+ if (options.moveToCooldown !== void 0) {
149
+ this.moveToCooldown = options.moveToCooldown;
150
+ }
151
+ if (options.retreatCooldown !== void 0) {
152
+ this.retreatCooldown = options.retreatCooldown;
153
+ }
118
154
  this.setupVision();
119
155
  this.startAiBehaviorLoop();
120
156
  this.changeState(
@@ -309,6 +345,9 @@ class BattleAi {
309
345
  const berserkerModifier = Math.max(0.3, hpPercent);
310
346
  this.attackCooldown = 800 * berserkerModifier;
311
347
  }
348
+ if (this.behaviorEnabled) {
349
+ this.updateBehavior(currentTime);
350
+ }
312
351
  switch (this.state) {
313
352
  case "idle":
314
353
  this.updateIdleBehavior();
@@ -402,7 +441,20 @@ class BattleAi {
402
441
  this.tryDodge();
403
442
  return;
404
443
  }
405
- if (this.enemyType === "ranged") {
444
+ if (this.behaviorEnabled) {
445
+ if (this.behaviorMode === "tactical") {
446
+ this.handleTacticalMovement(distance);
447
+ } else if (this.behaviorMode === "assault") {
448
+ this.handleAssaultMovement(distance);
449
+ } else if (this.behaviorMode === "retreat") {
450
+ this.isMovingToTarget = false;
451
+ this.fleeFromTarget();
452
+ return;
453
+ }
454
+ }
455
+ if (this.behaviorEnabled && this.behaviorMode === "assault") ;
456
+ else if (this.behaviorEnabled && this.behaviorMode === "tactical") ;
457
+ else if (this.enemyType === "ranged") {
406
458
  if (distance < this.attackRange * 0.6) {
407
459
  this.debugLog("movement", `Retreating (dist=${distance.toFixed(1)}, minRange=${(this.attackRange * 0.6).toFixed(1)})`);
408
460
  this.isMovingToTarget = false;
@@ -411,7 +463,7 @@ class BattleAi {
411
463
  if (!this.isMovingToTarget) {
412
464
  this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
413
465
  this.isMovingToTarget = true;
414
- this.event.moveTo(this.target);
466
+ this.requestMoveTo(this.target);
415
467
  }
416
468
  } else {
417
469
  if (this.isMovingToTarget) {
@@ -425,7 +477,7 @@ class BattleAi {
425
477
  if (!this.isMovingToTarget) {
426
478
  this.debugLog("movement", `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
427
479
  this.isMovingToTarget = true;
428
- this.event.moveTo(this.target);
480
+ this.requestMoveTo(this.target);
429
481
  }
430
482
  } else {
431
483
  if (this.isMovingToTarget) {
@@ -590,7 +642,7 @@ class BattleAi {
590
642
  performMeleeAttack() {
591
643
  if (!this.target) return;
592
644
  this.faceTarget();
593
- this.event.setAnimation("attack", 1);
645
+ this.event.setGraphicAnimation("attack", 1);
594
646
  if (this.attackSkill) {
595
647
  try {
596
648
  this.event.useSkill(this.attackSkill, this.target);
@@ -757,7 +809,7 @@ class BattleAi {
757
809
  if (!this.target) return;
758
810
  this.chargingAttack = true;
759
811
  this.faceTarget();
760
- this.event.setAnimation("attack", 2);
812
+ this.event.setGraphicAnimation("attack", 2);
761
813
  setTimeout(() => {
762
814
  if (!this.target || this.state !== "combat") {
763
815
  this.chargingAttack = false;
@@ -779,7 +831,7 @@ class BattleAi {
779
831
  * Perform zone attack (360 degrees)
780
832
  */
781
833
  performZoneAttack() {
782
- this.event.setAnimation("attack", 1);
834
+ this.event.setGraphicAnimation("attack", 1);
783
835
  const eventX = this.event.x();
784
836
  const eventY = this.event.y();
785
837
  const radius = 50;
@@ -914,18 +966,23 @@ class BattleAi {
914
966
  x: () => this.event.x() + dx / dist * 200,
915
967
  y: () => this.event.y() + dy / dist * 200
916
968
  };
917
- this.event.moveTo(fleeTarget);
969
+ this.requestMoveTo(fleeTarget);
918
970
  }
919
971
  /**
920
972
  * Retreat from target (temporary)
921
973
  */
922
974
  retreatFromTarget() {
923
975
  if (!this.target) return;
976
+ const currentTime = Date.now();
977
+ if (currentTime - this.lastRetreatTime < this.retreatCooldown) {
978
+ return;
979
+ }
924
980
  const dx = this.event.x() - this.target.x();
925
981
  const dy = this.event.y() - this.target.y();
926
982
  const dist = Math.sqrt(dx * dx + dy * dy);
927
983
  if (dist === 0) return;
928
984
  this.event.dash({ x: dx / dist, y: dy / dist }, 8, 200);
985
+ this.lastRetreatTime = currentTime;
929
986
  }
930
987
  /**
931
988
  * Check damage taken for retreat decision
@@ -943,7 +1000,7 @@ class BattleAi {
943
1000
  startPatrol() {
944
1001
  if (this.patrolWaypoints.length === 0) return;
945
1002
  const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
946
- this.event.moveTo({ x: () => waypoint.x, y: () => waypoint.y });
1003
+ this.requestMoveTo({ x: () => waypoint.x, y: () => waypoint.y });
947
1004
  }
948
1005
  /**
949
1006
  * Update group behavior
@@ -1000,7 +1057,7 @@ class BattleAi {
1000
1057
  Math.pow(this.event.x() - formationX, 2) + Math.pow(this.event.y() - formationY, 2)
1001
1058
  );
1002
1059
  if (distanceToFormation > 20) {
1003
- this.event.moveTo({ x: () => formationX, y: () => formationY });
1060
+ this.requestMoveTo({ x: () => formationX, y: () => formationY });
1004
1061
  }
1005
1062
  }
1006
1063
  /**
@@ -1090,6 +1147,110 @@ class BattleAi {
1090
1147
  const dy = entity1.y() - entity2.y();
1091
1148
  return Math.sqrt(dx * dx + dy * dy);
1092
1149
  }
1150
+ updateBehavior(currentTime) {
1151
+ if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) {
1152
+ return;
1153
+ }
1154
+ this.behaviorLastUpdate = currentTime;
1155
+ let score = this.behaviorScore;
1156
+ const maxHp = this.event.param[MAXHP];
1157
+ if (maxHp) {
1158
+ const hpPercent = this.event.hp / maxHp;
1159
+ score += (hpPercent - 0.5) * 40;
1160
+ }
1161
+ if (this.recentDamageTaken > 0) {
1162
+ score -= Math.min(30, this.recentDamageTaken * 0.5);
1163
+ }
1164
+ if (this.target) {
1165
+ const distance = this.getDistance(this.event, this.target);
1166
+ if (distance <= this.attackRange) {
1167
+ score += 10;
1168
+ } else if (distance > this.visionRange) {
1169
+ score -= 10;
1170
+ }
1171
+ }
1172
+ if (this.groupBehavior && this.nearbyEnemies.length > 0) {
1173
+ score += Math.min(15, this.nearbyEnemies.length * 5);
1174
+ }
1175
+ score = Math.max(0, Math.min(100, score));
1176
+ this.behaviorScore = score;
1177
+ const previousMode = this.behaviorMode;
1178
+ if (score >= this.behaviorAssaultThreshold) {
1179
+ this.behaviorMode = "assault";
1180
+ } else if (score <= this.behaviorRetreatThreshold) {
1181
+ this.behaviorMode = "retreat";
1182
+ } else {
1183
+ this.behaviorMode = "tactical";
1184
+ }
1185
+ if (previousMode !== this.behaviorMode) {
1186
+ this.debugLog("state", `Behavior mode: ${previousMode} -> ${this.behaviorMode} (score=${score.toFixed(0)})`);
1187
+ }
1188
+ if (this.behaviorMode === "retreat" && this.state === "combat") {
1189
+ if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) {
1190
+ this.isMovingToTarget = false;
1191
+ this.changeState(
1192
+ "flee"
1193
+ /* Flee */
1194
+ );
1195
+ }
1196
+ } else if (this.behaviorMode === "assault" && this.state === "flee") {
1197
+ if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) {
1198
+ this.changeState(
1199
+ "combat"
1200
+ /* Combat */
1201
+ );
1202
+ }
1203
+ }
1204
+ }
1205
+ handleTacticalMovement(distance) {
1206
+ if (!this.target) return;
1207
+ const minRange = this.attackRange * 0.7;
1208
+ const maxRange = this.attackRange * 1.2;
1209
+ if (distance < minRange) {
1210
+ this.debugLog("movement", `Tactical retreat (dist=${distance.toFixed(1)}, minRange=${minRange.toFixed(1)})`);
1211
+ this.isMovingToTarget = false;
1212
+ this.retreatFromTarget();
1213
+ return;
1214
+ }
1215
+ if (distance > maxRange) {
1216
+ if (!this.isMovingToTarget) {
1217
+ this.debugLog("movement", `Tactical approach (dist=${distance.toFixed(1)}, maxRange=${maxRange.toFixed(1)})`);
1218
+ this.isMovingToTarget = true;
1219
+ this.requestMoveTo(this.target);
1220
+ }
1221
+ return;
1222
+ }
1223
+ if (this.isMovingToTarget) {
1224
+ this.debugLog("movement", `Tactical hold (dist=${distance.toFixed(1)})`);
1225
+ this.isMovingToTarget = false;
1226
+ this.event.stopMoveTo();
1227
+ }
1228
+ }
1229
+ handleAssaultMovement(distance) {
1230
+ if (!this.target) return;
1231
+ if (distance > this.attackRange) {
1232
+ if (!this.isMovingToTarget) {
1233
+ this.debugLog("movement", `Assault approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1234
+ this.isMovingToTarget = true;
1235
+ this.requestMoveTo(this.target);
1236
+ }
1237
+ return;
1238
+ }
1239
+ if (this.isMovingToTarget) {
1240
+ this.debugLog("movement", `Assault hold (dist=${distance.toFixed(1)})`);
1241
+ this.isMovingToTarget = false;
1242
+ this.event.stopMoveTo();
1243
+ }
1244
+ }
1245
+ requestMoveTo(target) {
1246
+ const currentTime = Date.now();
1247
+ if (currentTime - this.lastMoveToTime < this.moveToCooldown) {
1248
+ return false;
1249
+ }
1250
+ this.event.moveTo(target);
1251
+ this.lastMoveToTime = currentTime;
1252
+ return true;
1253
+ }
1093
1254
  // Public getters
1094
1255
  getHealth() {
1095
1256
  return this.event.hp;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgjs/action-battle",
3
- "version": "5.0.0-alpha.30",
3
+ "version": "5.0.0-alpha.32",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "exports": {
@@ -23,10 +23,10 @@
23
23
  "description": "RPGJS is a framework for creating RPG/MMORPG games",
24
24
  "peerDependencies": {
25
25
  "@canvasengine/presets": "*",
26
- "@rpgjs/client": "5.0.0-alpha.30",
27
- "@rpgjs/common": "5.0.0-alpha.30",
28
- "@rpgjs/server": "5.0.0-alpha.30",
29
- "@rpgjs/vite": "5.0.0-alpha.30",
26
+ "@rpgjs/client": "5.0.0-alpha.32",
27
+ "@rpgjs/common": "5.0.0-alpha.32",
28
+ "@rpgjs/server": "5.0.0-alpha.32",
29
+ "@rpgjs/vite": "5.0.0-alpha.32",
30
30
  "canvasengine": "*"
31
31
  },
32
32
  "publishConfig": {
package/src/ai.server.ts CHANGED
@@ -285,6 +285,21 @@ export class BattleAi {
285
285
  // Direction hysteresis to prevent animation flickering
286
286
  private lastFacingDirection: string | null = null;
287
287
 
288
+ // Behavior gauge (0-100)
289
+ private behaviorScore: number = 50;
290
+ private behaviorMode: 'assault' | 'tactical' | 'retreat' = 'tactical';
291
+ private behaviorLastUpdate: number = 0;
292
+ private behaviorUpdateInterval: number = 400;
293
+ private behaviorAssaultThreshold: number = 65;
294
+ private behaviorRetreatThreshold: number = 35;
295
+ private behaviorMinStateDuration: number = 600;
296
+ private behaviorEnabled: boolean = false;
297
+
298
+ // Movement throttling
299
+ private moveToCooldown: number = 400;
300
+ private lastMoveToTime: number = 0;
301
+ private retreatCooldown: number = 600;
302
+ private lastRetreatTime: number = 0;
288
303
 
289
304
  /**
290
305
  * Create a new Battle AI Controller
@@ -324,6 +339,15 @@ export class BattleAi {
324
339
  attackPatterns?: AttackPattern[];
325
340
  patrolWaypoints?: Array<{ x: number; y: number }>;
326
341
  groupBehavior?: boolean;
342
+ moveToCooldown?: number;
343
+ retreatCooldown?: number;
344
+ behavior?: {
345
+ baseScore?: number;
346
+ updateInterval?: number;
347
+ minStateDuration?: number;
348
+ assaultThreshold?: number;
349
+ retreatThreshold?: number;
350
+ };
327
351
  /** Callback called when the AI is defeated */
328
352
  onDefeated?: (event: RpgEvent) => void;
329
353
  } = {}
@@ -355,6 +379,33 @@ export class BattleAi {
355
379
  // Initialize defeat callback
356
380
  this.onDefeatedCallback = options.onDefeated;
357
381
 
382
+ // Behavior gauge settings
383
+ if (options.behavior) {
384
+ this.behaviorEnabled = true;
385
+ if (options.behavior.baseScore !== undefined) {
386
+ this.behaviorScore = options.behavior.baseScore;
387
+ }
388
+ if (options.behavior.updateInterval !== undefined) {
389
+ this.behaviorUpdateInterval = options.behavior.updateInterval;
390
+ }
391
+ if (options.behavior.minStateDuration !== undefined) {
392
+ this.behaviorMinStateDuration = options.behavior.minStateDuration;
393
+ }
394
+ if (options.behavior.assaultThreshold !== undefined) {
395
+ this.behaviorAssaultThreshold = options.behavior.assaultThreshold;
396
+ }
397
+ if (options.behavior.retreatThreshold !== undefined) {
398
+ this.behaviorRetreatThreshold = options.behavior.retreatThreshold;
399
+ }
400
+ }
401
+
402
+ if (options.moveToCooldown !== undefined) {
403
+ this.moveToCooldown = options.moveToCooldown;
404
+ }
405
+ if (options.retreatCooldown !== undefined) {
406
+ this.retreatCooldown = options.retreatCooldown;
407
+ }
408
+
358
409
  // Setup AI systems
359
410
  this.setupVision();
360
411
  this.startAiBehaviorLoop();
@@ -531,6 +582,11 @@ export class BattleAi {
531
582
  this.attackCooldown = 800 * berserkerModifier;
532
583
  }
533
584
 
585
+ // Update behavior gauge and state decision
586
+ if (this.behaviorEnabled) {
587
+ this.updateBehavior(currentTime);
588
+ }
589
+
534
590
  // State-specific behavior
535
591
  switch (this.state) {
536
592
  case AiState.Idle:
@@ -625,8 +681,24 @@ export class BattleAi {
625
681
  return;
626
682
  }
627
683
 
684
+ if (this.behaviorEnabled) {
685
+ if (this.behaviorMode === 'tactical') {
686
+ this.handleTacticalMovement(distance);
687
+ } else if (this.behaviorMode === 'assault') {
688
+ this.handleAssaultMovement(distance);
689
+ } else if (this.behaviorMode === 'retreat') {
690
+ this.isMovingToTarget = false;
691
+ this.fleeFromTarget();
692
+ return;
693
+ }
694
+ }
695
+
628
696
  // Movement based on enemy type
629
- if (this.enemyType === EnemyType.Ranged) {
697
+ if (this.behaviorEnabled && this.behaviorMode === 'assault') {
698
+ // Assault mode already handled movement
699
+ } else if (this.behaviorEnabled && this.behaviorMode === 'tactical') {
700
+ // Tactical mode already handled movement
701
+ } else if (this.enemyType === EnemyType.Ranged) {
630
702
  if (distance < this.attackRange * 0.6) {
631
703
  this.debugLog('movement', `Retreating (dist=${distance.toFixed(1)}, minRange=${(this.attackRange * 0.6).toFixed(1)})`);
632
704
  this.isMovingToTarget = false;
@@ -635,7 +707,7 @@ export class BattleAi {
635
707
  if (!this.isMovingToTarget) {
636
708
  this.debugLog('movement', `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
637
709
  this.isMovingToTarget = true;
638
- this.event.moveTo(this.target);
710
+ this.requestMoveTo(this.target);
639
711
  }
640
712
  } else {
641
713
  if (this.isMovingToTarget) {
@@ -649,7 +721,7 @@ export class BattleAi {
649
721
  if (!this.isMovingToTarget) {
650
722
  this.debugLog('movement', `Moving to target (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
651
723
  this.isMovingToTarget = true;
652
- this.event.moveTo(this.target);
724
+ this.requestMoveTo(this.target);
653
725
  }
654
726
  } else {
655
727
  if (this.isMovingToTarget) {
@@ -795,7 +867,7 @@ export class BattleAi {
795
867
  if (!this.target) return;
796
868
 
797
869
  this.faceTarget();
798
- this.event.setAnimation('attack', 1);
870
+ this.event.setGraphicAnimation('attack', 1);
799
871
 
800
872
  // Use skill if available
801
873
  if (this.attackSkill) {
@@ -994,7 +1066,7 @@ export class BattleAi {
994
1066
 
995
1067
  this.chargingAttack = true;
996
1068
  this.faceTarget();
997
- this.event.setAnimation('attack', 2);
1069
+ this.event.setGraphicAnimation('attack', 2);
998
1070
 
999
1071
  setTimeout(() => {
1000
1072
  if (!this.target || this.state !== AiState.Combat) {
@@ -1021,7 +1093,7 @@ export class BattleAi {
1021
1093
  * Perform zone attack (360 degrees)
1022
1094
  */
1023
1095
  private performZoneAttack() {
1024
- this.event.setAnimation('attack', 1);
1096
+ this.event.setGraphicAnimation('attack', 1);
1025
1097
 
1026
1098
  const eventX = this.event.x();
1027
1099
  const eventY = this.event.y();
@@ -1202,7 +1274,7 @@ export class BattleAi {
1202
1274
  y: () => this.event.y() + (dy / dist) * 200
1203
1275
  };
1204
1276
 
1205
- this.event.moveTo(fleeTarget as any);
1277
+ this.requestMoveTo(fleeTarget as any);
1206
1278
  }
1207
1279
 
1208
1280
  /**
@@ -1210,6 +1282,10 @@ export class BattleAi {
1210
1282
  */
1211
1283
  private retreatFromTarget() {
1212
1284
  if (!this.target) return;
1285
+ const currentTime = Date.now();
1286
+ if (currentTime - this.lastRetreatTime < this.retreatCooldown) {
1287
+ return;
1288
+ }
1213
1289
 
1214
1290
  const dx = this.event.x() - this.target.x();
1215
1291
  const dy = this.event.y() - this.target.y();
@@ -1218,6 +1294,7 @@ export class BattleAi {
1218
1294
  if (dist === 0) return;
1219
1295
 
1220
1296
  this.event.dash({ x: dx / dist, y: dy / dist }, 8, 200);
1297
+ this.lastRetreatTime = currentTime;
1221
1298
  }
1222
1299
 
1223
1300
  /**
@@ -1239,7 +1316,7 @@ export class BattleAi {
1239
1316
  if (this.patrolWaypoints.length === 0) return;
1240
1317
 
1241
1318
  const waypoint = this.patrolWaypoints[this.currentPatrolIndex];
1242
- this.event.moveTo({ x: () => waypoint.x, y: () => waypoint.y } as any);
1319
+ this.requestMoveTo({ x: () => waypoint.x, y: () => waypoint.y } as any);
1243
1320
  }
1244
1321
 
1245
1322
  /**
@@ -1311,7 +1388,7 @@ export class BattleAi {
1311
1388
  );
1312
1389
 
1313
1390
  if (distanceToFormation > 20) {
1314
- this.event.moveTo({ x: () => formationX, y: () => formationY } as any);
1391
+ this.requestMoveTo({ x: () => formationX, y: () => formationY } as any);
1315
1392
  }
1316
1393
  }
1317
1394
 
@@ -1409,6 +1486,120 @@ export class BattleAi {
1409
1486
  return Math.sqrt(dx * dx + dy * dy);
1410
1487
  }
1411
1488
 
1489
+ private updateBehavior(currentTime: number) {
1490
+ if (currentTime - this.behaviorLastUpdate < this.behaviorUpdateInterval) {
1491
+ return;
1492
+ }
1493
+ this.behaviorLastUpdate = currentTime;
1494
+
1495
+ let score = this.behaviorScore;
1496
+ const maxHp = this.event.param[MAXHP];
1497
+ if (maxHp) {
1498
+ const hpPercent = this.event.hp / maxHp;
1499
+ score += (hpPercent - 0.5) * 40;
1500
+ }
1501
+
1502
+ if (this.recentDamageTaken > 0) {
1503
+ score -= Math.min(30, this.recentDamageTaken * 0.5);
1504
+ }
1505
+
1506
+ if (this.target) {
1507
+ const distance = this.getDistance(this.event, this.target);
1508
+ if (distance <= this.attackRange) {
1509
+ score += 10;
1510
+ } else if (distance > this.visionRange) {
1511
+ score -= 10;
1512
+ }
1513
+ }
1514
+
1515
+ if (this.groupBehavior && this.nearbyEnemies.length > 0) {
1516
+ score += Math.min(15, this.nearbyEnemies.length * 5);
1517
+ }
1518
+
1519
+ score = Math.max(0, Math.min(100, score));
1520
+ this.behaviorScore = score;
1521
+
1522
+ const previousMode = this.behaviorMode;
1523
+ if (score >= this.behaviorAssaultThreshold) {
1524
+ this.behaviorMode = 'assault';
1525
+ } else if (score <= this.behaviorRetreatThreshold) {
1526
+ this.behaviorMode = 'retreat';
1527
+ } else {
1528
+ this.behaviorMode = 'tactical';
1529
+ }
1530
+
1531
+ if (previousMode !== this.behaviorMode) {
1532
+ this.debugLog('state', `Behavior mode: ${previousMode} -> ${this.behaviorMode} (score=${score.toFixed(0)})`);
1533
+ }
1534
+
1535
+ if (this.behaviorMode === 'retreat' && this.state === AiState.Combat) {
1536
+ if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) {
1537
+ this.isMovingToTarget = false;
1538
+ this.changeState(AiState.Flee);
1539
+ }
1540
+ } else if (this.behaviorMode === 'assault' && this.state === AiState.Flee) {
1541
+ if (currentTime - this.stateStartTime >= this.behaviorMinStateDuration) {
1542
+ this.changeState(AiState.Combat);
1543
+ }
1544
+ }
1545
+ }
1546
+
1547
+ private handleTacticalMovement(distance: number) {
1548
+ if (!this.target) return;
1549
+ const minRange = this.attackRange * 0.7;
1550
+ const maxRange = this.attackRange * 1.2;
1551
+
1552
+ if (distance < minRange) {
1553
+ this.debugLog('movement', `Tactical retreat (dist=${distance.toFixed(1)}, minRange=${minRange.toFixed(1)})`);
1554
+ this.isMovingToTarget = false;
1555
+ this.retreatFromTarget();
1556
+ return;
1557
+ }
1558
+
1559
+ if (distance > maxRange) {
1560
+ if (!this.isMovingToTarget) {
1561
+ this.debugLog('movement', `Tactical approach (dist=${distance.toFixed(1)}, maxRange=${maxRange.toFixed(1)})`);
1562
+ this.isMovingToTarget = true;
1563
+ this.requestMoveTo(this.target);
1564
+ }
1565
+ return;
1566
+ }
1567
+
1568
+ if (this.isMovingToTarget) {
1569
+ this.debugLog('movement', `Tactical hold (dist=${distance.toFixed(1)})`);
1570
+ this.isMovingToTarget = false;
1571
+ this.event.stopMoveTo();
1572
+ }
1573
+ }
1574
+
1575
+ private handleAssaultMovement(distance: number) {
1576
+ if (!this.target) return;
1577
+ if (distance > this.attackRange) {
1578
+ if (!this.isMovingToTarget) {
1579
+ this.debugLog('movement', `Assault approach (dist=${distance.toFixed(1)}, attackRange=${this.attackRange})`);
1580
+ this.isMovingToTarget = true;
1581
+ this.requestMoveTo(this.target);
1582
+ }
1583
+ return;
1584
+ }
1585
+
1586
+ if (this.isMovingToTarget) {
1587
+ this.debugLog('movement', `Assault hold (dist=${distance.toFixed(1)})`);
1588
+ this.isMovingToTarget = false;
1589
+ this.event.stopMoveTo();
1590
+ }
1591
+ }
1592
+
1593
+ private requestMoveTo(target: any): boolean {
1594
+ const currentTime = Date.now();
1595
+ if (currentTime - this.lastMoveToTime < this.moveToCooldown) {
1596
+ return false;
1597
+ }
1598
+ this.event.moveTo(target as any);
1599
+ this.lastMoveToTime = currentTime;
1600
+ return true;
1601
+ }
1602
+
1412
1603
  // Public getters
1413
1604
  getHealth(): number { return this.event.hp; }
1414
1605
  getMaxHealth(): number { return this.event.param[MAXHP]; }
package/src/server.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { RpgEvent, RpgPlayer, type RpgServer } from "@rpgjs/server";
2
- import { defineModule } from "@rpgjs/common";
2
+ import { Control, defineModule } from "@rpgjs/common";
3
3
  import { BattleAi, HitResult, ApplyHitHooks, DEFAULT_KNOCKBACK } from "./ai.server";
4
4
 
5
5
  /**
@@ -148,9 +148,9 @@ export default defineModule<RpgServer>({
148
148
  * @param input - Input data containing pressed keys
149
149
  */
150
150
  onInput(player: RpgPlayer, input: any) {
151
- if (input.action) {
151
+ if (input.action == Control.Action) {
152
152
  // Trigger attack animation
153
- player.setAnimation('attack', 1);
153
+ player.setGraphicAnimation('attack', 1);
154
154
 
155
155
  // Get player position
156
156
  const playerX = player.x();