@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/README.md +112 -24
- package/dist/ai.server.d.ts +1 -1
- package/dist/client/index.js +16 -6
- package/dist/client/index2.js +42 -8
- package/dist/client/index3.js +3 -3
- package/dist/client/index4.js +267 -71
- package/dist/client/index5.js +292 -0
- package/dist/client/index6.js +97 -0
- package/dist/client/index7.js +61 -0
- package/dist/client/index8.js +55 -0
- package/dist/client/index9.js +30 -0
- package/dist/client.d.ts +2 -0
- package/dist/config.d.ts +3 -0
- package/dist/index.d.ts +9 -2
- package/dist/server/index.js +16 -6
- package/dist/server/index2.js +267 -71
- package/dist/server/index3.js +3 -3
- package/dist/server/index4.js +55 -0
- package/dist/server/index5.js +30 -0
- package/dist/server.d.ts +5 -0
- package/dist/targeting.d.ts +19 -0
- package/dist/ui/state.d.ts +18 -0
- package/package.json +7 -7
- package/src/ai.server.ts +5 -5
- package/src/client.ts +49 -8
- package/src/components/action-bar.ce +329 -0
- package/src/components/targeting-overlay.ce +99 -0
- package/src/config.ts +56 -0
- package/src/index.ts +32 -7
- package/src/server.ts +356 -87
- package/src/targeting.ts +45 -0
- package/src/types.ts +78 -0
- package/src/ui/state.ts +68 -0
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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();
|
package/src/targeting.ts
ADDED
|
@@ -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
|
+
}
|
package/src/ui/state.ts
ADDED
|
@@ -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
|
+
};
|