@rpgjs/action-battle 5.0.0-beta.3 → 5.0.0-beta.5
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 +137 -0
- package/dist/ai.server.d.ts +8 -1
- package/dist/client/index.js +8 -4
- package/dist/client/index10.js +97 -330
- package/dist/client/index11.js +25 -0
- package/dist/client/index12.js +1222 -0
- package/dist/client/index13.js +46 -0
- package/dist/client/index14.js +10 -0
- package/dist/client/index15.js +448 -0
- package/dist/client/index2.js +30 -0
- package/dist/client/index3.js +33 -1
- package/dist/client/index4.js +7 -3
- package/dist/client/index7.js +76 -32
- package/dist/client/index8.js +24 -4
- package/dist/client/index9.js +94 -1165
- package/dist/core/context.d.ts +5 -0
- package/dist/core/defaults.d.ts +81 -0
- package/dist/core/hit.d.ts +2 -0
- package/dist/enemies/factory.d.ts +7 -0
- package/dist/index.d.ts +6 -1
- package/dist/server/index.js +7 -3
- package/dist/server/index10.js +10 -0
- package/dist/server/index2.js +23 -3
- package/dist/server/index3.js +30 -0
- package/dist/server/index4.js +137 -1163
- package/dist/server/index5.js +22 -34
- package/dist/server/index6.js +1190 -345
- package/dist/server/index7.js +37 -0
- package/dist/server/index8.js +46 -0
- package/dist/server/index9.js +447 -0
- package/dist/server.d.ts +2 -0
- package/dist/ui/state.d.ts +17 -0
- package/package.json +5 -5
- package/src/ai.server.ts +91 -24
- package/src/animations.ts +43 -4
- package/src/canvas-engine-shim.ts +4 -0
- package/src/client.ts +122 -2
- package/src/components/action-bar.ce +5 -3
- package/src/components/attack-preview.ce +90 -0
- package/src/config.ts +30 -0
- package/src/core/context.ts +35 -0
- package/src/core/contracts.ts +123 -0
- package/src/core/defaults.ts +162 -0
- package/src/core/hit.spec.ts +58 -0
- package/src/core/hit.ts +66 -0
- package/src/enemies/factory.ts +25 -0
- package/src/index.ts +40 -0
- package/src/server.ts +235 -71
- package/src/targeting.spec.ts +24 -0
- package/src/types/canvas-engine.d.ts +4 -0
- package/src/types.ts +46 -1
- package/src/ui/state.ts +57 -0
package/src/ai.server.ts
CHANGED
|
@@ -4,10 +4,12 @@ import {
|
|
|
4
4
|
playActionBattleAnimation,
|
|
5
5
|
} from "./animations";
|
|
6
6
|
import { getActionBattleOptions } from "./config";
|
|
7
|
+
import { getActionBattleSystems } from "./core/context";
|
|
8
|
+
import type { ActionBattleDamageResult } from "./core/contracts";
|
|
7
9
|
import type { ActionBattleAnimationOptions } from "./types";
|
|
8
10
|
|
|
9
11
|
type RpgEventWithBattleAi = RpgEvent & {
|
|
10
|
-
battleAi
|
|
12
|
+
battleAi?: BattleAi;
|
|
11
13
|
};
|
|
12
14
|
|
|
13
15
|
export interface BattleAiOptions {
|
|
@@ -31,6 +33,7 @@ export interface BattleAiOptions {
|
|
|
31
33
|
assaultThreshold?: number;
|
|
32
34
|
retreatThreshold?: number;
|
|
33
35
|
};
|
|
36
|
+
behaviorKey?: string;
|
|
34
37
|
animations?: ActionBattleAnimationOptions;
|
|
35
38
|
/** Callback called when the AI is defeated */
|
|
36
39
|
onDefeated?: (event: RpgEvent, attacker?: RpgPlayer) => void;
|
|
@@ -131,7 +134,9 @@ export interface ApplyHitHooks {
|
|
|
131
134
|
*/
|
|
132
135
|
export const AiDebug = {
|
|
133
136
|
/** Enable/disable all AI debug logs */
|
|
134
|
-
enabled:
|
|
137
|
+
enabled:
|
|
138
|
+
((globalThis as { process?: { env?: Record<string, string> } }).process
|
|
139
|
+
?.env?.RPGJS_DEBUG_AI === "1") || false,
|
|
135
140
|
|
|
136
141
|
/** Filter logs to a specific event ID (null = all events) */
|
|
137
142
|
filterEventId: null as string | null,
|
|
@@ -275,17 +280,17 @@ export class BattleAi {
|
|
|
275
280
|
|
|
276
281
|
// Enemy type and behavior
|
|
277
282
|
private enemyType: EnemyType;
|
|
278
|
-
private attackCooldown: number;
|
|
279
|
-
private visionRange: number;
|
|
280
|
-
private attackRange: number;
|
|
283
|
+
private attackCooldown: number = 1000;
|
|
284
|
+
private visionRange: number = 150;
|
|
285
|
+
private attackRange: number = 60;
|
|
281
286
|
|
|
282
287
|
// Dodge system
|
|
283
|
-
private dodgeChance: number;
|
|
284
|
-
private dodgeCooldown: number;
|
|
288
|
+
private dodgeChance: number = 0.2;
|
|
289
|
+
private dodgeCooldown: number = 2000;
|
|
285
290
|
private lastDodgeTime: number = 0;
|
|
286
291
|
|
|
287
292
|
// Flee threshold (HP percentage)
|
|
288
|
-
private fleeThreshold: number;
|
|
293
|
+
private fleeThreshold: number = 0.2;
|
|
289
294
|
|
|
290
295
|
// Attack configuration
|
|
291
296
|
private attackSkill: any | null; // Skill to use for attacks
|
|
@@ -333,6 +338,8 @@ export class BattleAi {
|
|
|
333
338
|
private lastMoveToTime: number = 0;
|
|
334
339
|
private retreatCooldown: number = 600;
|
|
335
340
|
private lastRetreatTime: number = 0;
|
|
341
|
+
private timers: ReturnType<typeof setTimeout>[] = [];
|
|
342
|
+
private behaviorKey?: string;
|
|
336
343
|
|
|
337
344
|
/**
|
|
338
345
|
* Create a new Battle AI Controller
|
|
@@ -367,6 +374,7 @@ export class BattleAi {
|
|
|
367
374
|
|
|
368
375
|
// Set enemy type and apply behavior modifiers
|
|
369
376
|
this.enemyType = options.enemyType || EnemyType.Aggressive;
|
|
377
|
+
this.behaviorKey = options.behaviorKey ?? this.enemyType;
|
|
370
378
|
this.applyEnemyTypeBehavior(options);
|
|
371
379
|
|
|
372
380
|
// Store attack skill reference
|
|
@@ -423,7 +431,9 @@ export class BattleAi {
|
|
|
423
431
|
// Setup AI systems
|
|
424
432
|
this.setupVision();
|
|
425
433
|
this.startAiBehaviorLoop();
|
|
426
|
-
this.
|
|
434
|
+
if (this.patrolWaypoints.length > 0) {
|
|
435
|
+
this.startPatrol();
|
|
436
|
+
}
|
|
427
437
|
|
|
428
438
|
this.debugLog('init', `AI created (type=${this.enemyType}, visionRange=${this.visionRange}, attackRange=${this.attackRange})`);
|
|
429
439
|
}
|
|
@@ -530,6 +540,9 @@ export class BattleAi {
|
|
|
530
540
|
* Change AI state with validated transitions
|
|
531
541
|
*/
|
|
532
542
|
private changeState(newState: AiState) {
|
|
543
|
+
if (newState === this.state) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
533
546
|
const validTransitions: Record<AiState, AiState[]> = {
|
|
534
547
|
[AiState.Idle]: [AiState.Alert, AiState.Combat],
|
|
535
548
|
[AiState.Alert]: [AiState.Idle, AiState.Combat],
|
|
@@ -601,6 +614,8 @@ export class BattleAi {
|
|
|
601
614
|
this.updateBehavior(currentTime);
|
|
602
615
|
}
|
|
603
616
|
|
|
617
|
+
this.applyCustomBehavior(currentTime);
|
|
618
|
+
|
|
604
619
|
// State-specific behavior
|
|
605
620
|
switch (this.state) {
|
|
606
621
|
case AiState.Idle:
|
|
@@ -634,6 +649,7 @@ export class BattleAi {
|
|
|
634
649
|
|
|
635
650
|
if (distance < 10) {
|
|
636
651
|
this.currentPatrolIndex = (this.currentPatrolIndex + 1) % this.patrolWaypoints.length;
|
|
652
|
+
this.startPatrol();
|
|
637
653
|
}
|
|
638
654
|
}
|
|
639
655
|
}
|
|
@@ -690,9 +706,10 @@ export class BattleAi {
|
|
|
690
706
|
// Try dodge
|
|
691
707
|
if (this.canDodge() && this.shouldDodge()) {
|
|
692
708
|
this.debugLog('combat', 'Attempting dodge');
|
|
693
|
-
this.
|
|
694
|
-
|
|
695
|
-
|
|
709
|
+
if (this.tryDodge()) {
|
|
710
|
+
this.isMovingToTarget = false;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
696
713
|
}
|
|
697
714
|
|
|
698
715
|
if (this.behaviorEnabled) {
|
|
@@ -1066,7 +1083,7 @@ export class BattleAi {
|
|
|
1066
1083
|
this.performMeleeAttack();
|
|
1067
1084
|
|
|
1068
1085
|
if (this.comboCount < this.comboMax) {
|
|
1069
|
-
|
|
1086
|
+
this.schedule(() => {
|
|
1070
1087
|
if (this.target && this.state === AiState.Combat) {
|
|
1071
1088
|
this.performComboAttack();
|
|
1072
1089
|
} else {
|
|
@@ -1096,7 +1113,7 @@ export class BattleAi {
|
|
|
1096
1113
|
{ repeat: 2 }
|
|
1097
1114
|
);
|
|
1098
1115
|
|
|
1099
|
-
|
|
1116
|
+
this.schedule(() => {
|
|
1100
1117
|
if (!this.target || this.state !== AiState.Combat) {
|
|
1101
1118
|
this.chargingAttack = false;
|
|
1102
1119
|
return;
|
|
@@ -1176,7 +1193,7 @@ export class BattleAi {
|
|
|
1176
1193
|
this.faceTarget();
|
|
1177
1194
|
this.event.dash({ x: dirX, y: dirY }, 10, 200);
|
|
1178
1195
|
|
|
1179
|
-
|
|
1196
|
+
this.schedule(() => {
|
|
1180
1197
|
if (!this.target || this.state !== AiState.Combat) return;
|
|
1181
1198
|
this.performMeleeAttack();
|
|
1182
1199
|
}, 200);
|
|
@@ -1241,24 +1258,24 @@ export class BattleAi {
|
|
|
1241
1258
|
/**
|
|
1242
1259
|
* Try to dodge
|
|
1243
1260
|
*/
|
|
1244
|
-
private tryDodge() {
|
|
1261
|
+
private tryDodge(): boolean {
|
|
1245
1262
|
const currentTime = Date.now();
|
|
1246
1263
|
|
|
1247
1264
|
if (currentTime - this.lastDodgeTime < this.dodgeCooldown) {
|
|
1248
1265
|
this.debugLog('dodge', `Dodge on cooldown (${this.dodgeCooldown - (currentTime - this.lastDodgeTime)}ms remaining)`);
|
|
1249
|
-
return;
|
|
1266
|
+
return false;
|
|
1250
1267
|
}
|
|
1251
1268
|
if (Math.random() > this.dodgeChance) {
|
|
1252
1269
|
this.debugLog('dodge', `Dodge roll failed (chance=${(this.dodgeChance * 100).toFixed(0)}%)`);
|
|
1253
|
-
return;
|
|
1270
|
+
return false;
|
|
1254
1271
|
}
|
|
1255
|
-
if (!this.target) return;
|
|
1272
|
+
if (!this.target) return false;
|
|
1256
1273
|
|
|
1257
1274
|
const dx = this.target.x() - this.event.x();
|
|
1258
1275
|
const dy = this.target.y() - this.event.y();
|
|
1259
1276
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
1260
1277
|
|
|
1261
|
-
if (dist === 0) return;
|
|
1278
|
+
if (dist === 0) return false;
|
|
1262
1279
|
|
|
1263
1280
|
// Perpendicular direction
|
|
1264
1281
|
const dodgeDirX = -dy / dist;
|
|
@@ -1272,12 +1289,13 @@ export class BattleAi {
|
|
|
1272
1289
|
// Counter-attack for defensive types
|
|
1273
1290
|
if (this.enemyType === EnemyType.Defensive && Math.random() < 0.5) {
|
|
1274
1291
|
this.debugLog('dodge', 'Counter-attack after dodge');
|
|
1275
|
-
|
|
1292
|
+
this.schedule(() => {
|
|
1276
1293
|
if (this.target && this.state === AiState.Combat) {
|
|
1277
1294
|
this.selectAndPerformAttack();
|
|
1278
1295
|
}
|
|
1279
1296
|
}, 400);
|
|
1280
1297
|
}
|
|
1298
|
+
return true;
|
|
1281
1299
|
}
|
|
1282
1300
|
|
|
1283
1301
|
private canDodge(): boolean {
|
|
@@ -1461,8 +1479,16 @@ export class BattleAi {
|
|
|
1461
1479
|
*/
|
|
1462
1480
|
takeDamage(attacker: RpgPlayer): boolean {
|
|
1463
1481
|
// Apply damage using RPGJS system
|
|
1464
|
-
const
|
|
1482
|
+
const raw = this.event.applyDamage(attacker);
|
|
1483
|
+
return this.handleDamage(attacker, {
|
|
1484
|
+
damage: raw.damage ?? 0,
|
|
1485
|
+
defeated: this.event.hp <= 0,
|
|
1486
|
+
raw,
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1465
1489
|
|
|
1490
|
+
handleDamage(attacker: RpgPlayer, damageResult: ActionBattleDamageResult): boolean {
|
|
1491
|
+
const damage = damageResult.damage;
|
|
1466
1492
|
this.debugLog('damage', `Took ${damage} damage from ${attacker.id} (HP: ${this.event.hp}/${this.event.param[MAXHP] || '?'})`);
|
|
1467
1493
|
|
|
1468
1494
|
// Visual feedback
|
|
@@ -1489,7 +1515,7 @@ export class BattleAi {
|
|
|
1489
1515
|
}
|
|
1490
1516
|
|
|
1491
1517
|
// Check death
|
|
1492
|
-
if (this.event.hp <= 0) {
|
|
1518
|
+
if (damageResult.defeated || this.event.hp <= 0) {
|
|
1493
1519
|
this.debugLog('damage', 'Defeated!');
|
|
1494
1520
|
this.kill(attacker);
|
|
1495
1521
|
return true;
|
|
@@ -1522,7 +1548,7 @@ export class BattleAi {
|
|
|
1522
1548
|
|
|
1523
1549
|
this.destroy();
|
|
1524
1550
|
if (removeDelay > 0) {
|
|
1525
|
-
|
|
1551
|
+
this.schedule(() => this.event.remove(), removeDelay);
|
|
1526
1552
|
} else {
|
|
1527
1553
|
this.event.remove();
|
|
1528
1554
|
}
|
|
@@ -1595,6 +1621,36 @@ export class BattleAi {
|
|
|
1595
1621
|
}
|
|
1596
1622
|
}
|
|
1597
1623
|
|
|
1624
|
+
private applyCustomBehavior(currentTime: number) {
|
|
1625
|
+
if (!this.behaviorKey) return;
|
|
1626
|
+
const behavior = getActionBattleSystems().ai.behaviors[this.behaviorKey];
|
|
1627
|
+
if (!behavior) return;
|
|
1628
|
+
const maxHp = this.event.param[MAXHP];
|
|
1629
|
+
const decision = behavior({
|
|
1630
|
+
event: this.event,
|
|
1631
|
+
target: this.target,
|
|
1632
|
+
state: this.state,
|
|
1633
|
+
enemyType: this.enemyType,
|
|
1634
|
+
distance: this.target ? this.getDistance(this.event, this.target) : null,
|
|
1635
|
+
hpPercent: maxHp ? this.event.hp / maxHp : null,
|
|
1636
|
+
now: currentTime,
|
|
1637
|
+
});
|
|
1638
|
+
if (!decision) return;
|
|
1639
|
+
if (decision.attackCooldown !== undefined) {
|
|
1640
|
+
this.attackCooldown = decision.attackCooldown;
|
|
1641
|
+
}
|
|
1642
|
+
if (decision.moveToCooldown !== undefined) {
|
|
1643
|
+
this.moveToCooldown = decision.moveToCooldown;
|
|
1644
|
+
}
|
|
1645
|
+
if (decision.attackPatterns?.length) {
|
|
1646
|
+
this.attackPatterns = decision.attackPatterns;
|
|
1647
|
+
}
|
|
1648
|
+
if (decision.mode) {
|
|
1649
|
+
this.behaviorMode = decision.mode;
|
|
1650
|
+
this.behaviorEnabled = true;
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1598
1654
|
private handleTacticalMovement(distance: number) {
|
|
1599
1655
|
if (!this.target) return;
|
|
1600
1656
|
const minRange = this.attackRange * 0.7;
|
|
@@ -1651,6 +1707,15 @@ export class BattleAi {
|
|
|
1651
1707
|
return true;
|
|
1652
1708
|
}
|
|
1653
1709
|
|
|
1710
|
+
private schedule(callback: () => void, delay: number) {
|
|
1711
|
+
const timer = setTimeout(() => {
|
|
1712
|
+
this.timers = this.timers.filter((entry) => entry !== timer);
|
|
1713
|
+
callback();
|
|
1714
|
+
}, delay);
|
|
1715
|
+
this.timers.push(timer);
|
|
1716
|
+
return timer;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1654
1719
|
// Public getters
|
|
1655
1720
|
getHealth(): number { return this.event.hp; }
|
|
1656
1721
|
getMaxHealth(): number { return this.event.param[MAXHP]; }
|
|
@@ -1668,5 +1733,7 @@ export class BattleAi {
|
|
|
1668
1733
|
}
|
|
1669
1734
|
this.target = null;
|
|
1670
1735
|
this.nearbyEnemies = [];
|
|
1736
|
+
this.timers.forEach((timer) => clearTimeout(timer));
|
|
1737
|
+
this.timers = [];
|
|
1671
1738
|
}
|
|
1672
1739
|
}
|
package/src/animations.ts
CHANGED
|
@@ -25,6 +25,46 @@ const DEFAULT_ANIMATION_BY_KEY: Record<ActionBattleAnimationKey, string> = {
|
|
|
25
25
|
hurt: "hurt",
|
|
26
26
|
die: "die",
|
|
27
27
|
castSkill: "skill",
|
|
28
|
+
castSpell: "skill",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const getConfiguredAnimation = (
|
|
32
|
+
key: ActionBattleAnimationKey,
|
|
33
|
+
animations?: ActionBattleAnimationOptions,
|
|
34
|
+
) => {
|
|
35
|
+
if (!animations) {
|
|
36
|
+
return {
|
|
37
|
+
hasConfiguredAnimation: false,
|
|
38
|
+
configured: undefined,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const hasConfiguredAnimation = Object.prototype.hasOwnProperty.call(
|
|
43
|
+
animations,
|
|
44
|
+
key,
|
|
45
|
+
);
|
|
46
|
+
if (hasConfiguredAnimation) {
|
|
47
|
+
return {
|
|
48
|
+
hasConfiguredAnimation,
|
|
49
|
+
configured: animations[key],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (key === "castSkill") {
|
|
54
|
+
const hasCastSpellAlias = Object.prototype.hasOwnProperty.call(
|
|
55
|
+
animations,
|
|
56
|
+
"castSpell",
|
|
57
|
+
);
|
|
58
|
+
return {
|
|
59
|
+
hasConfiguredAnimation: hasCastSpellAlias,
|
|
60
|
+
configured: animations.castSpell,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
hasConfiguredAnimation: false,
|
|
66
|
+
configured: undefined,
|
|
67
|
+
};
|
|
28
68
|
};
|
|
29
69
|
|
|
30
70
|
export function resolveActionBattleAnimation(
|
|
@@ -37,15 +77,14 @@ export function resolveActionBattleAnimation(
|
|
|
37
77
|
const defaultAnimationName =
|
|
38
78
|
defaults.animationName ?? DEFAULT_ANIMATION_BY_KEY[key];
|
|
39
79
|
const defaultRepeat = defaults.repeat ?? 1;
|
|
40
|
-
const hasConfiguredAnimation =
|
|
41
|
-
|
|
42
|
-
: false;
|
|
80
|
+
const { hasConfiguredAnimation, configured: configuredAnimation } =
|
|
81
|
+
getConfiguredAnimation(key, animations);
|
|
43
82
|
if (!hasConfiguredAnimation && key !== "attack") {
|
|
44
83
|
return null;
|
|
45
84
|
}
|
|
46
85
|
|
|
47
86
|
const configured = hasConfiguredAnimation
|
|
48
|
-
?
|
|
87
|
+
? configuredAnimation
|
|
49
88
|
: defaultAnimationName;
|
|
50
89
|
const result =
|
|
51
90
|
typeof configured === "function"
|
package/src/client.ts
CHANGED
|
@@ -1,10 +1,109 @@
|
|
|
1
1
|
import { inject, PrebuiltComponentAnimations, RpgClient, RpgClientEngine, RpgGui } from "@rpgjs/client";
|
|
2
2
|
import { defineModule } from "@rpgjs/common";
|
|
3
|
+
// @ts-ignore CanvasEngine components are compiled by @canvasengine/compiler.
|
|
3
4
|
import ActionBarComponent from "./components/action-bar.ce";
|
|
5
|
+
// @ts-ignore CanvasEngine components are compiled by @canvasengine/compiler.
|
|
4
6
|
import TargetingOverlayComponent from "./components/targeting-overlay.ce";
|
|
5
|
-
|
|
7
|
+
// @ts-ignore CanvasEngine components are compiled by @canvasengine/compiler.
|
|
8
|
+
import AttackPreviewComponent from "./components/attack-preview.ce";
|
|
9
|
+
import {
|
|
10
|
+
setActionBattleOptions,
|
|
11
|
+
startAttackPreview,
|
|
12
|
+
stopAttackPreview,
|
|
13
|
+
} from "./ui/state";
|
|
6
14
|
import { ActionBattleOptions } from "./types";
|
|
7
15
|
import { normalizeActionBattleOptions } from "./config";
|
|
16
|
+
import { resolveActionBattleAnimation } from "./animations";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_ATTACK_LOCK_DURATION_MS = 350;
|
|
19
|
+
|
|
20
|
+
const beginLocalPlayerAttackLock = (
|
|
21
|
+
engine: RpgClientEngine,
|
|
22
|
+
durationMs: number
|
|
23
|
+
): boolean => {
|
|
24
|
+
if (durationMs <= 0) return true;
|
|
25
|
+
|
|
26
|
+
const player = engine.scene?.getCurrentPlayer?.() as any;
|
|
27
|
+
if (!player) return true;
|
|
28
|
+
|
|
29
|
+
const runtimePlayer = player as any;
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
if (
|
|
32
|
+
typeof runtimePlayer.__actionBattleAttackLockedUntil === "number" &&
|
|
33
|
+
runtimePlayer.__actionBattleAttackLockedUntil > now
|
|
34
|
+
) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const lockId = (runtimePlayer.__actionBattleAttackLockId ?? 0) + 1;
|
|
39
|
+
runtimePlayer.__actionBattleAttackLockId = lockId;
|
|
40
|
+
runtimePlayer.__actionBattleAttackLockedUntil = now + durationMs;
|
|
41
|
+
|
|
42
|
+
const previousCanMove =
|
|
43
|
+
typeof player.canMove === "function" ? player.canMove() : true;
|
|
44
|
+
const previousDirectionFixed = player.directionFixed;
|
|
45
|
+
const previousAnimationFixed = player.animationFixed;
|
|
46
|
+
|
|
47
|
+
if (typeof engine.interruptCurrentPlayerMovement === "function") {
|
|
48
|
+
engine.interruptCurrentPlayerMovement(player);
|
|
49
|
+
} else {
|
|
50
|
+
(engine.scene as any)?.stopMovement?.(player);
|
|
51
|
+
}
|
|
52
|
+
player.canMove.set(false);
|
|
53
|
+
player.directionFixed = true;
|
|
54
|
+
player.animationFixed = true;
|
|
55
|
+
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
if (runtimePlayer.__actionBattleAttackLockId !== lockId) return;
|
|
58
|
+
runtimePlayer.__actionBattleAttackLockedUntil = 0;
|
|
59
|
+
player.canMove.set(previousCanMove);
|
|
60
|
+
player.directionFixed = previousDirectionFixed;
|
|
61
|
+
player.animationFixed = previousAnimationFixed;
|
|
62
|
+
}, durationMs);
|
|
63
|
+
|
|
64
|
+
return true;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const resolveLocalPlayerDirection = (player: any) => {
|
|
68
|
+
if (typeof player.getDirection === "function") return player.getDirection();
|
|
69
|
+
if (typeof player.direction === "function") return player.direction();
|
|
70
|
+
return player.direction ?? "down";
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const playLocalPlayerAttackAnimation = (
|
|
74
|
+
player: any,
|
|
75
|
+
options: ActionBattleOptions
|
|
76
|
+
) => {
|
|
77
|
+
if (!player || typeof player.setAnimation !== "function") return;
|
|
78
|
+
const animation = resolveActionBattleAnimation(
|
|
79
|
+
"attack",
|
|
80
|
+
player,
|
|
81
|
+
options.animations
|
|
82
|
+
);
|
|
83
|
+
if (!animation) return;
|
|
84
|
+
|
|
85
|
+
if (animation.graphic !== undefined) {
|
|
86
|
+
player.setAnimation(
|
|
87
|
+
animation.animationName,
|
|
88
|
+
animation.graphic,
|
|
89
|
+
animation.repeat
|
|
90
|
+
);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
player.setAnimation(animation.animationName, animation.repeat);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const showLocalAttackPreview = (player: any, options: ActionBattleOptions) => {
|
|
97
|
+
if (!player || options.attack?.showPreview === false) return;
|
|
98
|
+
const durationMs = Math.max(1, options.attack?.previewDurationMs ?? 180);
|
|
99
|
+
const previewId = startAttackPreview({
|
|
100
|
+
direction: resolveLocalPlayerDirection(player),
|
|
101
|
+
durationMs,
|
|
102
|
+
color: options.attack?.previewColor,
|
|
103
|
+
accentColor: options.attack?.previewAccentColor,
|
|
104
|
+
});
|
|
105
|
+
setTimeout(() => stopAttackPreview(previewId), durationMs);
|
|
106
|
+
};
|
|
8
107
|
|
|
9
108
|
export const createActionBattleClient = (
|
|
10
109
|
options: ActionBattleOptions = {}
|
|
@@ -13,6 +112,10 @@ export const createActionBattleClient = (
|
|
|
13
112
|
setActionBattleOptions(normalized);
|
|
14
113
|
const actionBarEnabled = normalized.ui?.actionBar?.enabled;
|
|
15
114
|
const targetingEnabled = normalized.ui?.targeting?.enabled;
|
|
115
|
+
const componentsInFront = [
|
|
116
|
+
...(targetingEnabled ? [TargetingOverlayComponent] : []),
|
|
117
|
+
AttackPreviewComponent,
|
|
118
|
+
];
|
|
16
119
|
const hitComponent = PrebuiltComponentAnimations?.Hit;
|
|
17
120
|
return defineModule<RpgClient>({
|
|
18
121
|
componentAnimations: hitComponent
|
|
@@ -36,7 +139,7 @@ export const createActionBattleClient = (
|
|
|
36
139
|
]
|
|
37
140
|
: [],
|
|
38
141
|
sprite: {
|
|
39
|
-
componentsInFront
|
|
142
|
+
componentsInFront,
|
|
40
143
|
},
|
|
41
144
|
sceneMap: {
|
|
42
145
|
onAfterLoading() {
|
|
@@ -45,6 +148,23 @@ export const createActionBattleClient = (
|
|
|
45
148
|
gui.display('action-battle-action-bar')
|
|
46
149
|
}
|
|
47
150
|
}
|
|
151
|
+
},
|
|
152
|
+
engine: {
|
|
153
|
+
onInput(engine: RpgClientEngine, { input }: { input: string }) {
|
|
154
|
+
if (input !== "action") return;
|
|
155
|
+
const player = engine.scene?.getCurrentPlayer?.() as any;
|
|
156
|
+
if (!player) return;
|
|
157
|
+
const lockDurationMs = Math.max(
|
|
158
|
+
0,
|
|
159
|
+
normalized.attack?.lockDurationMs ?? DEFAULT_ATTACK_LOCK_DURATION_MS
|
|
160
|
+
);
|
|
161
|
+
beginLocalPlayerAttackLock(
|
|
162
|
+
engine,
|
|
163
|
+
normalized.attack?.lockMovement === false ? 0 : lockDurationMs
|
|
164
|
+
);
|
|
165
|
+
playLocalPlayerAttackAnimation(player, normalized);
|
|
166
|
+
showLocalAttackPreview(player, normalized);
|
|
167
|
+
},
|
|
48
168
|
}
|
|
49
169
|
});
|
|
50
170
|
};
|
|
@@ -72,7 +72,6 @@
|
|
|
72
72
|
const engine = inject(RpgClientEngine);
|
|
73
73
|
const keyboardControls = engine.globalConfig.keyboardControls;
|
|
74
74
|
const { data, onInteraction, onBack } = defineProps();
|
|
75
|
-
const currentPlayer = engine.getCurrentPlayer();
|
|
76
75
|
const ACTION_BAR_SIZE = 10;
|
|
77
76
|
const SLOT_LABELS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
|
|
78
77
|
const SLOT_CONFIG_KEYS = [
|
|
@@ -107,10 +106,13 @@
|
|
|
107
106
|
playing: "default"
|
|
108
107
|
});
|
|
109
108
|
|
|
109
|
+
const resolveProp = (value) => typeof value === "function" ? value() : value;
|
|
110
|
+
const actionBarData = computed(() => resolveProp(data) || { items: [], skills: [] });
|
|
111
|
+
|
|
110
112
|
const actionBarSlots = computed(() => {
|
|
111
113
|
const entries = [];
|
|
112
114
|
if (showSkills()) {
|
|
113
|
-
|
|
115
|
+
(actionBarData().skills || []).forEach((skill, index) => {
|
|
114
116
|
entries.push({
|
|
115
117
|
type: "skill",
|
|
116
118
|
skill,
|
|
@@ -120,7 +122,7 @@
|
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
124
|
if (showItems()) {
|
|
123
|
-
|
|
125
|
+
(actionBarData().items || []).forEach((item, index) => {
|
|
124
126
|
entries.push({
|
|
125
127
|
type: "item",
|
|
126
128
|
skill: null,
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<Container>
|
|
2
|
+
@if (shouldRender) {
|
|
3
|
+
<Graphics draw={drawSlash} />
|
|
4
|
+
}
|
|
5
|
+
</Container>
|
|
6
|
+
|
|
7
|
+
<script>
|
|
8
|
+
import { computed, signal, tick } from "canvasengine";
|
|
9
|
+
import { inject, RpgClientEngine } from "@rpgjs/client";
|
|
10
|
+
import { actionBattleAttackPreviewState } from "../ui/state";
|
|
11
|
+
|
|
12
|
+
const { object } = defineProps();
|
|
13
|
+
const engine = inject(RpgClientEngine);
|
|
14
|
+
const now = signal(Date.now());
|
|
15
|
+
|
|
16
|
+
tick(() => {
|
|
17
|
+
if (actionBattleAttackPreviewState().active) {
|
|
18
|
+
now.set(Date.now());
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const isCurrentPlayer = computed(() => {
|
|
23
|
+
if (!object?.id) return false;
|
|
24
|
+
const idValue = typeof object.id === "function" ? object.id() : object.id;
|
|
25
|
+
return idValue === engine.playerId;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const preview = computed(() => actionBattleAttackPreviewState());
|
|
29
|
+
const progress = computed(() => {
|
|
30
|
+
const state = preview();
|
|
31
|
+
if (!state.active) return 1;
|
|
32
|
+
const elapsed = now() - state.startedAt;
|
|
33
|
+
return Math.max(0, Math.min(1, elapsed / state.durationMs));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const shouldRender = computed(() => {
|
|
37
|
+
const state = preview();
|
|
38
|
+
return isCurrentPlayer() && state.active && progress() < 1;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const getHitbox = () => object.hitbox?.() || { w: 32, h: 32 };
|
|
42
|
+
|
|
43
|
+
const drawRect = (g, x, y, width, height, color, alpha) => {
|
|
44
|
+
g.rect(x, y, width, height);
|
|
45
|
+
g.fill({ color, alpha });
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const drawSlash = (g) => {
|
|
49
|
+
g.clear();
|
|
50
|
+
if (!shouldRender()) return;
|
|
51
|
+
|
|
52
|
+
const state = preview();
|
|
53
|
+
const p = progress();
|
|
54
|
+
const alpha = Math.sin(Math.PI * p);
|
|
55
|
+
if (alpha <= 0) return;
|
|
56
|
+
|
|
57
|
+
const hitbox = getHitbox();
|
|
58
|
+
const width = hitbox.w || 32;
|
|
59
|
+
const height = hitbox.h || 32;
|
|
60
|
+
const reach = 16 + 18 * p;
|
|
61
|
+
const thickness = 4 + 3 * (1 - p);
|
|
62
|
+
const color = state.color;
|
|
63
|
+
const accent = state.accentColor;
|
|
64
|
+
|
|
65
|
+
if (state.direction === "left") {
|
|
66
|
+
drawRect(g, -reach - 6, height * 0.24, reach, thickness, accent, alpha * 0.55);
|
|
67
|
+
drawRect(g, -reach - 10, height * 0.46, reach + 4, thickness + 2, color, alpha);
|
|
68
|
+
drawRect(g, -reach - 6, height * 0.70, reach, thickness, accent, alpha * 0.4);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (state.direction === "right") {
|
|
73
|
+
drawRect(g, width + 6, height * 0.24, reach, thickness, accent, alpha * 0.55);
|
|
74
|
+
drawRect(g, width + 6, height * 0.46, reach + 4, thickness + 2, color, alpha);
|
|
75
|
+
drawRect(g, width + 6, height * 0.70, reach, thickness, accent, alpha * 0.4);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (state.direction === "up") {
|
|
80
|
+
drawRect(g, width * 0.24, -reach - 6, thickness, reach, accent, alpha * 0.55);
|
|
81
|
+
drawRect(g, width * 0.46, -reach - 10, thickness + 2, reach + 4, color, alpha);
|
|
82
|
+
drawRect(g, width * 0.70, -reach - 6, thickness, reach, accent, alpha * 0.4);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
drawRect(g, width * 0.24, height + 6, thickness, reach, accent, alpha * 0.55);
|
|
87
|
+
drawRect(g, width * 0.46, height + 6, thickness + 2, reach + 4, color, alpha);
|
|
88
|
+
drawRect(g, width * 0.70, height + 6, thickness, reach, accent, alpha * 0.4);
|
|
89
|
+
};
|
|
90
|
+
</script>
|
package/src/config.ts
CHANGED
|
@@ -24,6 +24,14 @@ export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = {
|
|
|
24
24
|
affects: "events",
|
|
25
25
|
allowEmptyTarget: true,
|
|
26
26
|
},
|
|
27
|
+
attack: {
|
|
28
|
+
lockMovement: true,
|
|
29
|
+
lockDurationMs: 350,
|
|
30
|
+
showPreview: true,
|
|
31
|
+
previewDurationMs: 180,
|
|
32
|
+
previewColor: 0xfff3b0,
|
|
33
|
+
previewAccentColor: 0xffffff,
|
|
34
|
+
},
|
|
27
35
|
animations: {},
|
|
28
36
|
};
|
|
29
37
|
|
|
@@ -56,10 +64,32 @@ export function normalizeActionBattleOptions(
|
|
|
56
64
|
...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
|
|
57
65
|
...options.targeting,
|
|
58
66
|
},
|
|
67
|
+
attack: {
|
|
68
|
+
...DEFAULT_ACTION_BATTLE_OPTIONS.attack,
|
|
69
|
+
...options.attack,
|
|
70
|
+
},
|
|
59
71
|
animations: {
|
|
60
72
|
...DEFAULT_ACTION_BATTLE_OPTIONS.animations,
|
|
61
73
|
...options.animations,
|
|
62
74
|
},
|
|
75
|
+
systems: {
|
|
76
|
+
combat: {
|
|
77
|
+
...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.combat,
|
|
78
|
+
...options.systems?.combat,
|
|
79
|
+
hooks: {
|
|
80
|
+
...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.combat?.hooks,
|
|
81
|
+
...options.systems?.combat?.hooks,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
ai: {
|
|
85
|
+
...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai,
|
|
86
|
+
...options.systems?.ai,
|
|
87
|
+
behaviors: {
|
|
88
|
+
...DEFAULT_ACTION_BATTLE_OPTIONS.systems?.ai?.behaviors,
|
|
89
|
+
...options.systems?.ai?.behaviors,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
63
93
|
};
|
|
64
94
|
}
|
|
65
95
|
|