@rpgjs/action-battle 5.0.0-alpha.33 → 5.0.0-alpha.36

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/src/server.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  import { RpgEvent, RpgPlayer, type RpgServer } from "@rpgjs/server";
2
2
  import { Control, defineModule } from "@rpgjs/common";
3
3
  import { BattleAi, HitResult, ApplyHitHooks, DEFAULT_KNOCKBACK } from "./ai.server";
4
+ import {
5
+ ActionBattleActionBarData,
6
+ ActionBattleActionBarSkill,
7
+ ActionBattleOptions,
8
+ } from "./types";
9
+ import { normalizeActionBattleOptions } from "./config";
10
+ import { manhattanDistance, parseAoeMask } from "./targeting";
11
+
12
+ export const ACTION_BATTLE_ACTION_BAR_GUI_ID = "action-battle-action-bar";
4
13
 
5
14
  /**
6
15
  * Default player attack hitboxes offsets for each direction
@@ -134,94 +143,354 @@ export function applyPlayerHitToEvent(
134
143
  return hitResult;
135
144
  }
136
145
 
137
- export default defineModule<RpgServer>({
138
- player: {
139
- /**
140
- * Handle player input for combat actions
141
- *
142
- * When a player presses the action key, create an attack hitbox
143
- * that can damage AI enemies within range and knockback the event.
144
- * Knockback force is based on the player's equipped weapon.
145
- * Triggers attack animation and visual effects.
146
- *
147
- * @param player - The player performing the action
148
- * @param input - Input data containing pressed keys
149
- */
150
- onInput(player: RpgPlayer, input: any) {
151
- if (input.action == Control.Action) {
152
- // Trigger attack animation
153
- player.setGraphicAnimation('attack', 1);
154
-
155
- // Get player position
156
- const playerX = player.x();
157
- const playerY = player.y();
158
- const direction = player.getDirection();
159
-
160
- // Convert Direction enum to string key
161
- const directionKey = direction as string;
162
-
163
- // Get hitbox configuration for the direction
164
- const hitboxConfig = DEFAULT_PLAYER_ATTACK_HITBOXES[directionKey as keyof typeof DEFAULT_PLAYER_ATTACK_HITBOXES] || DEFAULT_PLAYER_ATTACK_HITBOXES.default;
165
-
166
- // Convert relative hitbox to absolute coordinates
167
- const hitboxes: Array<{
168
- x: number;
169
- y: number;
170
- width: number;
171
- height: number;
172
- }> = [{
173
- x: playerX + hitboxConfig.offsetX,
174
- y: playerY + hitboxConfig.offsetY,
175
- width: hitboxConfig.width,
176
- height: hitboxConfig.height
177
- }];
178
-
179
- const map = player.getCurrentMap();
180
-
181
- map?.createMovingHitbox(hitboxes, { speed: 3 }).subscribe({
182
- next(hits) {
183
- hits.forEach((hit) => {
184
- if (hit instanceof RpgEvent) {
185
- const result = applyPlayerHitToEvent(player, hit);
186
- if (result?.defeated) {
187
- console.log(`Player ${player.id} defeated AI ${hit.id}`);
188
- }
189
- }
190
- });
191
- },
192
- });
146
+ const resolveSignal = (value: any) =>
147
+ typeof value === "function" ? value() : value;
148
+
149
+ const resolveItemData = (player: RpgPlayer, itemId: string) => {
150
+ try {
151
+ return (player as any).databaseById?.(itemId);
152
+ } catch {
153
+ return null;
154
+ }
155
+ };
156
+
157
+ const resolveSkillData = (player: RpgPlayer, skillId: string) => {
158
+ try {
159
+ return (player as any).databaseById?.(skillId);
160
+ } catch {
161
+ return null;
162
+ }
163
+ };
164
+
165
+ const resolveSkillTargeting = (
166
+ player: RpgPlayer,
167
+ skillId: string,
168
+ options: ActionBattleOptions
169
+ ) => {
170
+ const skillsOptions = options.skills;
171
+ const skillData = resolveSkillData(player, skillId);
172
+ if (skillsOptions?.getTargeting) {
173
+ return skillsOptions.getTargeting(skillData);
174
+ }
175
+ const range =
176
+ skillData?.range ??
177
+ skillData?.targeting?.range ??
178
+ (skillData?.targeting?.distance as number | undefined);
179
+ const aoeMask =
180
+ skillData?.aoeMask ??
181
+ skillData?.targeting?.aoeMask ??
182
+ (skillData?.targeting?.mask as string[] | string | undefined);
183
+ if (range === undefined && aoeMask === undefined) {
184
+ return null;
185
+ }
186
+ return {
187
+ range: range ?? 0,
188
+ aoeMask,
189
+ };
190
+ };
191
+
192
+ const normalizeMaskRows = (mask: string[] | string | undefined) => {
193
+ if (!mask) return [];
194
+ if (Array.isArray(mask)) return mask;
195
+ return mask
196
+ .trim()
197
+ .split("\n")
198
+ .map((row: string) => row.replace(/\r/g, ""));
199
+ };
200
+
201
+ const buildActionBarData = (
202
+ player: RpgPlayer,
203
+ options: ActionBattleOptions
204
+ ): ActionBattleActionBarData => {
205
+ const items = (player.items?.() || []).map((item: any) => {
206
+ const id = item.id?.() ?? item.id;
207
+ const data = resolveItemData(player, id);
208
+ const name = resolveSignal(data?.name) ?? resolveSignal(item.name) ?? id;
209
+ const description =
210
+ resolveSignal(data?.description) ??
211
+ resolveSignal(item.description) ??
212
+ "";
213
+ const icon = resolveSignal(data?.icon) ?? resolveSignal(item.icon);
214
+ const quantity = resolveSignal(item.quantity) ?? 1;
215
+ const consumable = resolveSignal(data?.consumable);
216
+ const itemType = resolveSignal(data?._type);
217
+ const usable =
218
+ quantity > 0 &&
219
+ consumable !== false &&
220
+ (itemType ? itemType === "item" : true);
221
+ return {
222
+ id,
223
+ name,
224
+ description,
225
+ icon,
226
+ quantity,
227
+ usable,
228
+ };
229
+ });
230
+
231
+ const skills = (player.skills?.() || []).map((skill: any) => {
232
+ const id = skill.id?.() ?? skill.id;
233
+ const data = resolveSkillData(player, id) || skill;
234
+ const name = resolveSignal(data?.name) ?? resolveSignal(skill.name) ?? id;
235
+ const description =
236
+ resolveSignal(data?.description) ??
237
+ resolveSignal(skill.description) ??
238
+ "";
239
+ const icon = resolveSignal(data?.icon) ?? resolveSignal(skill.icon);
240
+ const spCost =
241
+ resolveSignal(data?.spCost) ?? resolveSignal(skill.spCost) ?? 0;
242
+ const usable = spCost <= player.sp;
243
+ const targeting = resolveSkillTargeting(player, id, options);
244
+ const skillEntry: ActionBattleActionBarSkill = {
245
+ id,
246
+ name,
247
+ description,
248
+ icon,
249
+ spCost,
250
+ usable,
251
+ range: targeting?.range ?? 0,
252
+ };
253
+ if (targeting) {
254
+ const mask = targeting.aoeMask ?? options.skills?.defaultAoeMask;
255
+ if (mask) {
256
+ skillEntry.aoeMask = normalizeMaskRows(mask);
193
257
  }
258
+ }
259
+ return skillEntry;
260
+ });
261
+
262
+ return { items, skills };
263
+ };
264
+
265
+ const ensureActionBarGui = (
266
+ player: RpgPlayer,
267
+ options: ActionBattleOptions
268
+ ) => {
269
+ const existing = player.getGui?.(ACTION_BATTLE_ACTION_BAR_GUI_ID);
270
+ const gui = existing || player.gui(ACTION_BATTLE_ACTION_BAR_GUI_ID);
271
+ if (!(gui as any).__actionBattleReady) {
272
+ (gui as any).__actionBattleReady = true;
273
+ gui.on("useItem", ({ id }) => {
274
+ try {
275
+ player.useItem(id);
276
+ } catch {
277
+ // Ignore failures (not usable, not enough, etc.)
278
+ }
279
+ gui.update(buildActionBarData(player, options));
280
+ });
281
+ gui.on("useSkill", ({ id, target }) => {
282
+ handleActionBattleSkillUse(player, id, target, options);
283
+ gui.update(buildActionBarData(player, options));
284
+ });
285
+ gui.on("refresh", () => {
286
+ gui.update(buildActionBarData(player, options));
287
+ });
288
+ }
289
+ return gui;
290
+ };
291
+
292
+ export const openActionBattleActionBar = (
293
+ player: RpgPlayer,
294
+ rawOptions: ActionBattleOptions = {}
295
+ ) => {
296
+ const options = normalizeActionBattleOptions(rawOptions);
297
+ const gui = ensureActionBarGui(player, options);
298
+ gui.open(buildActionBarData(player, options));
299
+ };
300
+
301
+ export const updateActionBattleActionBar = (
302
+ player: RpgPlayer,
303
+ rawOptions: ActionBattleOptions = {}
304
+ ) => {
305
+ const options = normalizeActionBattleOptions(rawOptions);
306
+ const gui = player.getGui?.(ACTION_BATTLE_ACTION_BAR_GUI_ID);
307
+ if (gui) {
308
+ gui.update(buildActionBarData(player, options));
309
+ }
310
+ };
311
+
312
+ const getTileSize = (map: any) => ({
313
+ width: map?.tileWidth ?? 32,
314
+ height: map?.tileHeight ?? 32,
315
+ });
316
+
317
+ const getEntityTile = (
318
+ entity: any,
319
+ tileSize: { width: number; height: number }
320
+ ) => {
321
+ const hitbox = entity.hitbox?.() || { w: tileSize.width, h: tileSize.height };
322
+ const x = Math.floor((entity.x() + hitbox.w / 2) / tileSize.width);
323
+ const y = Math.floor((entity.y() + hitbox.h / 2) / tileSize.height);
324
+ return { x, y };
325
+ };
326
+
327
+ const handleActionBattleSkillUse = (
328
+ player: RpgPlayer,
329
+ skillId: string,
330
+ target: { x: number; y: number } | undefined,
331
+ options: ActionBattleOptions
332
+ ) => {
333
+ const map = player.getCurrentMap();
334
+ if (!map) {
335
+ player.useSkill(skillId);
336
+ return;
337
+ }
338
+ const targeting = resolveSkillTargeting(player, skillId, options);
339
+ if (!targeting || !target) {
340
+ player.useSkill(skillId);
341
+ return;
342
+ }
343
+
344
+ const tileSize = getTileSize(map);
345
+ const origin = getEntityTile(player, tileSize);
346
+ const targetTile = { x: target.x, y: target.y };
347
+
348
+ if (manhattanDistance(origin, targetTile) > targeting.range) {
349
+ return;
350
+ }
351
+
352
+ const mask = parseAoeMask(
353
+ targeting.aoeMask || options.skills?.defaultAoeMask
354
+ );
355
+ const affected = new Set<string>();
356
+ mask.cells.forEach((cell) => {
357
+ const x = targetTile.x + cell.dx;
358
+ const y = targetTile.y + cell.dy;
359
+ affected.add(`${x},${y}`);
360
+ });
361
+
362
+ const targets: any[] = [];
363
+ const affects = options.targeting?.affects || "events";
364
+ if (affects === "events" || affects === "both") {
365
+ map.getEvents().forEach((event) => {
366
+ const tile = getEntityTile(event, tileSize);
367
+ if (affected.has(`${tile.x},${tile.y}`)) {
368
+ targets.push(event);
369
+ }
370
+ });
371
+ }
372
+ if (affects === "players" || affects === "both") {
373
+ map.getPlayers().forEach((other) => {
374
+ if (other.id === player.id) return;
375
+ const tile = getEntityTile(other, tileSize);
376
+ if (affected.has(`${tile.x},${tile.y}`)) {
377
+ targets.push(other);
378
+ }
379
+ });
380
+ }
381
+
382
+ if (!options.targeting?.allowEmptyTarget && targets.length === 0) {
383
+ return;
384
+ }
385
+
386
+ player.useSkill(skillId, targets as any);
387
+ };
388
+
389
+ export const createActionBattleServer = (
390
+ rawOptions: ActionBattleOptions = {}
391
+ ) => {
392
+ const options = normalizeActionBattleOptions(rawOptions);
393
+ return defineModule<RpgServer>({
394
+ player: {
395
+ /**
396
+ * Handle player input for combat actions
397
+ *
398
+ * When a player presses the action key, create an attack hitbox
399
+ * that can damage AI enemies within range and knockback the event.
400
+ * Knockback force is based on the player's equipped weapon.
401
+ * Triggers attack animation and visual effects.
402
+ *
403
+ * @param player - The player performing the action
404
+ * @param input - Input data containing pressed keys
405
+ */
406
+ onInput(player: RpgPlayer, input: any) {
407
+ if (input.action == Control.Action) {
408
+ // Trigger attack animation
409
+ player.setGraphicAnimation("attack", 1);
410
+
411
+ // Get player position
412
+ const playerX = player.x();
413
+ const playerY = player.y();
414
+ const direction = player.getDirection();
415
+
416
+ // Convert Direction enum to string key
417
+ const directionKey = direction as string;
418
+
419
+ // Get hitbox configuration for the direction
420
+ const hitboxConfig =
421
+ DEFAULT_PLAYER_ATTACK_HITBOXES[
422
+ directionKey as keyof typeof DEFAULT_PLAYER_ATTACK_HITBOXES
423
+ ] || DEFAULT_PLAYER_ATTACK_HITBOXES.default;
424
+
425
+ // Convert relative hitbox to absolute coordinates
426
+ const hitboxes: Array<{
427
+ x: number;
428
+ y: number;
429
+ width: number;
430
+ height: number;
431
+ }> = [
432
+ {
433
+ x: playerX + hitboxConfig.offsetX,
434
+ y: playerY + hitboxConfig.offsetY,
435
+ width: hitboxConfig.width,
436
+ height: hitboxConfig.height,
437
+ },
438
+ ];
439
+
440
+ const map = player.getCurrentMap();
441
+
442
+ map?.createMovingHitbox(hitboxes, { speed: 3 }).subscribe({
443
+ next(hits) {
444
+ hits.forEach((hit) => {
445
+ if (hit instanceof RpgEvent) {
446
+ const result = applyPlayerHitToEvent(player, hit);
447
+ if (result?.defeated) {
448
+ console.log(`Player ${player.id} defeated AI ${hit.id}`);
449
+ }
450
+ }
451
+ });
452
+ },
453
+ });
454
+ }
455
+ },
456
+ onConnected(player: RpgPlayer) {
457
+ if (options.ui?.actionBar?.enabled && options.ui?.actionBar?.autoOpen) {
458
+ openActionBattleActionBar(player, options);
459
+ }
460
+ },
194
461
  },
195
- },
196
- event: {
197
- /**
198
- * Handle player detection when entering AI vision
199
- *
200
- * Called when a player enters an AI event's vision range.
201
- * The AI will start pursuing and attacking the player.
202
- *
203
- * @param event - The AI event
204
- * @param player - The player entering vision
205
- * @param shape - The vision shape
206
- */
207
- onDetectInShape(event: RpgEvent, player: RpgPlayer, shape: any) {
208
- const ai = (event as any).battleAi as BattleAi;
209
- ai?.onDetectInShape(player, shape);
210
- },
462
+ event: {
463
+ /**
464
+ * Handle player detection when entering AI vision
465
+ *
466
+ * Called when a player enters an AI event's vision range.
467
+ * The AI will start pursuing and attacking the player.
468
+ *
469
+ * @param event - The AI event
470
+ * @param player - The player entering vision
471
+ * @param shape - The vision shape
472
+ */
473
+ onDetectInShape(event: RpgEvent, player: RpgPlayer, shape: any) {
474
+ const ai = (event as any).battleAi as BattleAi;
475
+ ai?.onDetectInShape(player, shape);
476
+ },
211
477
 
212
- /**
213
- * Handle player leaving AI vision
214
- *
215
- * Called when a player leaves an AI event's vision range.
216
- * The AI will stop pursuing the player.
217
- *
218
- * @param event - The AI event
219
- * @param player - The player leaving vision
220
- * @param shape - The vision shape
221
- */
222
- onDetectOutShape(event: RpgEvent, player: RpgPlayer, shape: any) {
223
- const ai = (event as any).battleAi as BattleAi;
224
- ai?.onDetectOutShape(player, shape);
478
+ /**
479
+ * Handle player leaving AI vision
480
+ *
481
+ * Called when a player leaves an AI event's vision range.
482
+ * The AI will stop pursuing the player.
483
+ *
484
+ * @param event - The AI event
485
+ * @param player - The player leaving vision
486
+ * @param shape - The vision shape
487
+ */
488
+ onDetectOutShape(event: RpgEvent, player: RpgPlayer, shape: any) {
489
+ const ai = (event as any).battleAi as BattleAi;
490
+ ai?.onDetectOutShape(player, shape);
491
+ },
225
492
  },
226
- },
227
- });
493
+ });
494
+ };
495
+
496
+ export default createActionBattleServer();
@@ -0,0 +1,45 @@
1
+ import { ActionBattleAoeMask } from "./types";
2
+
3
+ export interface ParsedAoeMask {
4
+ width: number;
5
+ height: number;
6
+ centerX: number;
7
+ centerY: number;
8
+ cells: Array<{ dx: number; dy: number }>;
9
+ }
10
+
11
+ const normalizeMaskRows = (mask: ActionBattleAoeMask | undefined): string[] => {
12
+ if (!mask) return ["#"];
13
+ if (Array.isArray(mask)) return mask;
14
+ return mask
15
+ .trim()
16
+ .split("\n")
17
+ .map((row) => row.replace(/\r/g, ""));
18
+ };
19
+
20
+ export const parseAoeMask = (mask: ActionBattleAoeMask | undefined): ParsedAoeMask => {
21
+ const rows = normalizeMaskRows(mask);
22
+ const height = rows.length;
23
+ const width = rows.reduce((max, row) => Math.max(max, row.length), 0);
24
+ const centerX = Math.floor(width / 2);
25
+ const centerY = Math.floor(height / 2);
26
+ const cells: Array<{ dx: number; dy: number }> = [];
27
+
28
+ rows.forEach((row, y) => {
29
+ for (let x = 0; x < row.length; x++) {
30
+ const char = row[x];
31
+ if (char && char !== "." && char !== " ") {
32
+ cells.push({ dx: x - centerX, dy: y - centerY });
33
+ }
34
+ }
35
+ });
36
+
37
+ if (cells.length === 0) {
38
+ cells.push({ dx: 0, dy: 0 });
39
+ }
40
+
41
+ return { width, height, centerX, centerY, cells };
42
+ };
43
+
44
+ export const manhattanDistance = (a: { x: number; y: number }, b: { x: number; y: number }) =>
45
+ Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
package/src/types.ts ADDED
@@ -0,0 +1,78 @@
1
+ export type ActionBattleAoeMask = string[] | string;
2
+
3
+ export type ActionBattleActionBarMode = "items" | "skills" | "both";
4
+
5
+ export type ActionBattleTargetingAffects = "events" | "players" | "both";
6
+
7
+ export interface ActionBattleSkillTargeting {
8
+ range: number;
9
+ aoeMask?: ActionBattleAoeMask;
10
+ }
11
+
12
+ export type ActionBattleSkillTargetingResolver = (
13
+ skill: any
14
+ ) => ActionBattleSkillTargeting | null | undefined;
15
+
16
+ export interface ActionBattleUiActionBarOptions {
17
+ enabled?: boolean;
18
+ autoOpen?: boolean;
19
+ mode?: ActionBattleActionBarMode;
20
+ }
21
+
22
+ export interface ActionBattleUiTargetingOptions {
23
+ enabled?: boolean;
24
+ showGrid?: boolean;
25
+ tileSize?: { width: number; height: number };
26
+ colors?: {
27
+ area?: number;
28
+ edge?: number;
29
+ cursor?: number;
30
+ };
31
+ }
32
+
33
+ export interface ActionBattleUiOptions {
34
+ actionBar?: ActionBattleUiActionBarOptions;
35
+ targeting?: ActionBattleUiTargetingOptions;
36
+ }
37
+
38
+ export interface ActionBattleSkillOptions {
39
+ getTargeting?: ActionBattleSkillTargetingResolver;
40
+ defaultAoeMask?: ActionBattleAoeMask;
41
+ }
42
+
43
+ export interface ActionBattleTargetingOptions {
44
+ affects?: ActionBattleTargetingAffects;
45
+ allowEmptyTarget?: boolean;
46
+ }
47
+
48
+ export interface ActionBattleOptions {
49
+ ui?: ActionBattleUiOptions;
50
+ skills?: ActionBattleSkillOptions;
51
+ targeting?: ActionBattleTargetingOptions;
52
+ }
53
+
54
+ export interface ActionBattleActionBarItem {
55
+ id: string;
56
+ name: string;
57
+ description?: string;
58
+ icon?: string;
59
+ quantity?: number;
60
+ usable?: boolean;
61
+ }
62
+
63
+ export interface ActionBattleActionBarSkill {
64
+ id: string;
65
+ name: string;
66
+ description?: string;
67
+ icon?: string;
68
+ spCost?: number;
69
+ usable?: boolean;
70
+ range?: number;
71
+ aoeMask?: string[];
72
+ key?: string;
73
+ }
74
+
75
+ export interface ActionBattleActionBarData {
76
+ items: ActionBattleActionBarItem[];
77
+ skills: ActionBattleActionBarSkill[];
78
+ }
@@ -0,0 +1,68 @@
1
+ import { signal } from "canvasengine";
2
+ import { ActionBattleActionBarSkill, ActionBattleOptions } from "../types";
3
+ import { DEFAULT_ACTION_BATTLE_OPTIONS, normalizeActionBattleOptions } from "../config";
4
+
5
+ export interface ActionBattleTargetingState {
6
+ active: boolean;
7
+ skill: ActionBattleActionBarSkill | null;
8
+ range: number;
9
+ offset: { x: number; y: number };
10
+ aoeMask: string[] | string;
11
+ }
12
+
13
+ const defaultTargetingState: ActionBattleTargetingState = {
14
+ active: false,
15
+ skill: null,
16
+ range: 0,
17
+ offset: { x: 0, y: 0 },
18
+ aoeMask: DEFAULT_ACTION_BATTLE_OPTIONS.skills?.defaultAoeMask || ["#"],
19
+ };
20
+
21
+ export const actionBattleUiOptions = signal(
22
+ normalizeActionBattleOptions({}).ui || {}
23
+ );
24
+ export const actionBattleSkillOptions = signal(
25
+ normalizeActionBattleOptions({}).skills || {}
26
+ );
27
+
28
+ export const actionBattleTargetingState = signal<ActionBattleTargetingState>({
29
+ ...defaultTargetingState,
30
+ });
31
+
32
+ export const setActionBattleOptions = (options: ActionBattleOptions = {}) => {
33
+ const normalized = normalizeActionBattleOptions(options);
34
+ actionBattleUiOptions.set(normalized.ui || {});
35
+ actionBattleSkillOptions.set(normalized.skills || {});
36
+ };
37
+
38
+ export const startTargeting = (skill: ActionBattleActionBarSkill) => {
39
+ const skillsOptions = actionBattleSkillOptions();
40
+ const mask = skill.aoeMask || (skillsOptions.defaultAoeMask as string[]) || ["#"];
41
+ actionBattleTargetingState.set({
42
+ active: true,
43
+ skill,
44
+ range: skill.range ?? 0,
45
+ offset: { x: 0, y: 0 },
46
+ aoeMask: mask,
47
+ });
48
+ };
49
+
50
+ export const stopTargeting = () => {
51
+ actionBattleTargetingState.set({ ...defaultTargetingState });
52
+ };
53
+
54
+ export const moveTargetingOffset = (dx: number, dy: number) => {
55
+ const state = actionBattleTargetingState();
56
+ if (!state.active) return;
57
+ const next = {
58
+ x: state.offset.x + dx,
59
+ y: state.offset.y + dy,
60
+ };
61
+ if (Math.abs(next.x) + Math.abs(next.y) > state.range) {
62
+ return;
63
+ }
64
+ actionBattleTargetingState.set({
65
+ ...state,
66
+ offset: next,
67
+ });
68
+ };