@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.
@@ -0,0 +1,329 @@
1
+ <DOMContainer width="100%" height="100%" class="action-battle-actionbar-root" controls={controls}>
2
+ <div class="action-battle-actionbar">
3
+ <div class="rpg-ui-hotbar action-battle-actionbar-plate">
4
+ <div class="rpg-ui-hotbar-track action-battle-actionbar-track">
5
+ @for ((slot, index) of actionBarSlots) {
6
+ <div
7
+ class="rpg-ui-hotbar-slot action-battle-actionbar-slot"
8
+ data-selected={selectedSlotIndex() === index ? "true" : "false"}
9
+ data-disabled={isSlotDisabled(slot) ? "true" : "false"}
10
+ data-empty={hasSlotEntry(slot) ? "false" : "true"}
11
+ data-type={slot.type}
12
+ tabindex={hasSlotEntry(slot) ? index : -1}
13
+ click={hasSlotEntry(slot) ? onSelectSlot(index) : undefined}
14
+ >
15
+ <span class="rpg-ui-hotbar-key action-battle-actionbar-key">{slot.label}</span>
16
+ @if (slot.type === "skill" && slot.skill?.icon) {
17
+ <DOMSprite sheet={iconSheet(slot.skill.icon)} width="60px" height="60px" scale={2} objectFit="contain" />
18
+ } @else if (slot.type === "item" && slot.item?.icon) {
19
+ <DOMSprite sheet={iconSheet(slot.item.icon)} width="60px" height="60px" scale={2} objectFit="contain" />
20
+ } @else if (slot.type === "skill" && slot.skill) {
21
+ <span class="rpg-ui-hotbar-text action-battle-actionbar-text">{slot.skill.name}</span>
22
+ } @else if (slot.type === "item" && slot.item) {
23
+ <span class="rpg-ui-hotbar-text action-battle-actionbar-text">{slot.item.name}</span>
24
+ }
25
+ @if (slot.type === "item" && slot.item) {
26
+ <span class="rpg-ui-hotbar-count action-battle-actionbar-count">x{getItemQuantity(slot.item)}</span>
27
+ }
28
+ </div>
29
+ }
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </DOMContainer>
34
+
35
+ <style>
36
+ .action-battle-actionbar-root {
37
+ pointer-events: none;
38
+ }
39
+
40
+ .action-battle-actionbar {
41
+ position: absolute;
42
+ left: 12px;
43
+ right: 12px;
44
+ bottom: 12px;
45
+ pointer-events: auto;
46
+ display: flex;
47
+ justify-content: center;
48
+ }
49
+
50
+ .action-battle-actionbar-plate {
51
+ width: min(760px, 100%);
52
+ --rpg-ui-hotbar-size: clamp(38px, 8vw, 52px);
53
+ }
54
+
55
+ .action-battle-actionbar-track {
56
+ --rpg-ui-hotbar-slots: 10;
57
+ }
58
+ </style>
59
+
60
+ <script>
61
+ import { signal, computed, effect } from "canvasengine";
62
+ import { inject } from "@rpgjs/client";
63
+ import { RpgClientEngine } from "@rpgjs/client";
64
+ import {
65
+ actionBattleTargetingState,
66
+ actionBattleUiOptions,
67
+ startTargeting,
68
+ stopTargeting,
69
+ moveTargetingOffset
70
+ } from "../ui/state";
71
+
72
+ const engine = inject(RpgClientEngine);
73
+ const keyboardControls = engine.globalConfig.keyboardControls;
74
+ const { data, onInteraction, onBack } = defineProps();
75
+ const currentPlayer = engine.getCurrentPlayer();
76
+ const ACTION_BAR_SIZE = 10;
77
+ const SLOT_LABELS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"];
78
+ const SLOT_CONFIG_KEYS = [
79
+ "hotbar1",
80
+ "hotbar2",
81
+ "hotbar3",
82
+ "hotbar4",
83
+ "hotbar5",
84
+ "hotbar6",
85
+ "hotbar7",
86
+ "hotbar8",
87
+ "hotbar9",
88
+ "hotbar0"
89
+ ];
90
+
91
+ const selectedSlotIndex = signal(-1);
92
+
93
+ const uiOptions = computed(() => actionBattleUiOptions());
94
+ const showItems = computed(() => {
95
+ const mode = uiOptions().actionBar?.mode || "both";
96
+ return mode === "items" || mode === "both";
97
+ });
98
+ const showSkills = computed(() => {
99
+ const mode = uiOptions().actionBar?.mode || "both";
100
+ return mode === "skills" || mode === "both";
101
+ });
102
+
103
+ const isTargeting = computed(() => actionBattleTargetingState().active);
104
+
105
+ const iconSheet = (iconId) => ({
106
+ definition: engine.getSpriteSheet(iconId),
107
+ playing: "default"
108
+ });
109
+
110
+ const actionBarSlots = computed(() => {
111
+ const entries = [];
112
+ if (showSkills()) {
113
+ currentPlayer.skills().forEach((skill, index) => {
114
+ entries.push({
115
+ type: "skill",
116
+ skill,
117
+ item: null,
118
+ sourceIndex: index
119
+ });
120
+ });
121
+ }
122
+ if (showItems()) {
123
+ currentPlayer.items().forEach((item, index) => {
124
+ entries.push({
125
+ type: "item",
126
+ skill: null,
127
+ item,
128
+ sourceIndex: index
129
+ });
130
+ });
131
+ }
132
+ const slots = entries
133
+ .slice(0, ACTION_BAR_SIZE)
134
+ .map((entry, index) => ({
135
+ ...entry,
136
+ label: SLOT_LABELS[index]
137
+ }));
138
+ while (slots.length < ACTION_BAR_SIZE) {
139
+ slots.push({
140
+ type: "empty",
141
+ skill: null,
142
+ item: null,
143
+ sourceIndex: -1,
144
+ label: SLOT_LABELS[slots.length]
145
+ });
146
+ }
147
+ return slots;
148
+ });
149
+
150
+ const hasSlotEntry = (slot) => slot.type === "skill" || slot.type === "item";
151
+ const isSlotDisabled = (slot) => {
152
+ if (!hasSlotEntry(slot)) return true;
153
+ const entry = slot.type === "skill" ? slot.skill : slot.item;
154
+ return !entry || !entry.usable;
155
+ };
156
+
157
+ const getItemQuantity = (item) => {
158
+ if (!item) return 0;
159
+ if (typeof item.quantity === "function") return item.quantity();
160
+ return item.quantity || 0;
161
+ };
162
+
163
+ const selectItem = (item) => {
164
+ if (!item || !item.usable) return;
165
+ if (onInteraction) onInteraction("useItem", { id: item.id });
166
+ };
167
+
168
+ const selectSkill = (skill) => {
169
+ if (!skill || !skill.usable) return;
170
+ const range = skill.range ?? 0;
171
+ if (range > 0 || skill.aoeMask) {
172
+ startTargeting(skill);
173
+ return;
174
+ }
175
+ if (onInteraction) onInteraction("useSkill", { id: skill.id });
176
+ };
177
+
178
+ const selectSlot = (index) => {
179
+ const slot = actionBarSlots()[index];
180
+ if (!slot || !hasSlotEntry(slot)) return;
181
+ if (slot.type === "skill") {
182
+ selectSkill(slot.skill);
183
+ return;
184
+ }
185
+ selectItem(slot.item);
186
+ };
187
+
188
+ const onSelectSlot = (index) => () => {
189
+ selectedSlotIndex.set(index);
190
+ selectSlot(index);
191
+ };
192
+
193
+ const getPlayerTile = () => {
194
+ const player = engine.scene.getCurrentPlayer();
195
+ if (!player) return null;
196
+ const hitbox = player.hitbox?.() || { w: 32, h: 32 };
197
+ const tileWidth = hitbox.w || 32;
198
+ const tileHeight = hitbox.h || 32;
199
+ const x = Math.floor((player.x() + hitbox.w / 2) / tileWidth);
200
+ const y = Math.floor((player.y() + hitbox.h / 2) / tileHeight);
201
+ return { x, y };
202
+ };
203
+
204
+ const confirmTargeting = () => {
205
+ const state = actionBattleTargetingState();
206
+ if (!state.skill) return;
207
+ const origin = getPlayerTile();
208
+ if (!origin) return;
209
+ const target = {
210
+ x: origin.x + state.offset.x,
211
+ y: origin.y + state.offset.y
212
+ };
213
+ if (onInteraction) {
214
+ onInteraction("useSkill", {
215
+ id: state.skill.id,
216
+ target
217
+ });
218
+ }
219
+ stopTargeting();
220
+ };
221
+
222
+ const resolveKeyBind = (key) => {
223
+ if (!key) return null;
224
+ if (typeof key === "string" && keyboardControls?.[key]) {
225
+ return keyboardControls[key];
226
+ }
227
+ return key;
228
+ };
229
+
230
+ const slotBind = (index) => keyboardControls?.[SLOT_CONFIG_KEYS[index]] || SLOT_LABELS[index];
231
+
232
+ const buildControls = () => {
233
+ const hotbarControls = {};
234
+ actionBarSlots().forEach((slot, index) => {
235
+ if (!hasSlotEntry(slot)) return;
236
+ const bind = slotBind(index);
237
+ hotbarControls[`slot-${index}`] = {
238
+ bind,
239
+ keyDown() {
240
+ if (isTargeting()) return;
241
+ selectedSlotIndex.set(index);
242
+ selectSlot(index);
243
+ }
244
+ };
245
+ if (slot.type === "skill") {
246
+ const skillBind = resolveKeyBind(slot.skill?.key);
247
+ if (!skillBind || skillBind === bind) return;
248
+ hotbarControls[`skill-${index}`] = {
249
+ bind: skillBind,
250
+ keyDown() {
251
+ if (isTargeting()) return;
252
+ selectedSlotIndex.set(index);
253
+ selectSlot(index);
254
+ }
255
+ };
256
+ }
257
+ });
258
+ return {
259
+ left: {
260
+ repeat: true,
261
+ bind: keyboardControls.left,
262
+ throttle: 150,
263
+ keyDown() {
264
+ if (isTargeting()) {
265
+ moveTargetingOffset(-1, 0);
266
+ }
267
+ }
268
+ },
269
+ right: {
270
+ repeat: true,
271
+ bind: keyboardControls.right,
272
+ throttle: 150,
273
+ keyDown() {
274
+ if (isTargeting()) {
275
+ moveTargetingOffset(1, 0);
276
+ }
277
+ }
278
+ },
279
+ up: {
280
+ repeat: true,
281
+ bind: keyboardControls.up,
282
+ throttle: 150,
283
+ keyDown() {
284
+ if (isTargeting()) {
285
+ moveTargetingOffset(0, -1);
286
+ }
287
+ }
288
+ },
289
+ down: {
290
+ repeat: true,
291
+ bind: keyboardControls.down,
292
+ throttle: 150,
293
+ keyDown() {
294
+ if (isTargeting()) {
295
+ moveTargetingOffset(0, 1);
296
+ }
297
+ }
298
+ },
299
+ action: {
300
+ bind: keyboardControls.action,
301
+ keyDown() {
302
+ if (isTargeting()) {
303
+ confirmTargeting();
304
+ }
305
+ }
306
+ },
307
+ escape: {
308
+ bind: keyboardControls.escape,
309
+ keyDown() {
310
+ if (isTargeting()) {
311
+ stopTargeting();
312
+ return;
313
+ }
314
+ if (onBack) onBack();
315
+ }
316
+ },
317
+ ...hotbarControls,
318
+ gamepad: {
319
+ enabled: true
320
+ }
321
+ };
322
+ };
323
+
324
+ const controls = signal(buildControls());
325
+
326
+ effect(() => {
327
+ controls.set(buildControls());
328
+ });
329
+ </script>
@@ -0,0 +1,99 @@
1
+ <Container>
2
+ @if (shouldRender) {
3
+ <Graphics draw={drawGrid} />
4
+ }
5
+ </Container>
6
+
7
+ <script>
8
+ import { computed } from "canvasengine";
9
+ import { inject } from "@rpgjs/client";
10
+ import { RpgClientEngine } from "@rpgjs/client";
11
+ import { actionBattleTargetingState, actionBattleUiOptions } from "../ui/state";
12
+ import { parseAoeMask } from "../targeting";
13
+
14
+ const { object } = defineProps();
15
+ const engine = inject(RpgClientEngine);
16
+
17
+ const isCurrentPlayer = computed(() => {
18
+ if (!object?.id) return false;
19
+ const idValue = typeof object.id === "function" ? object.id() : object.id;
20
+ return idValue === engine.playerId;
21
+ });
22
+ const uiOptions = computed(() => actionBattleUiOptions());
23
+ const targetingState = computed(() => actionBattleTargetingState());
24
+
25
+ const shouldRender = computed(() => {
26
+ if (!isCurrentPlayer()) return false;
27
+ if (!uiOptions().targeting?.enabled) return false;
28
+ return targetingState().active;
29
+ });
30
+
31
+ const getTileSize = () => {
32
+ const uiTile = uiOptions().targeting?.tileSize;
33
+ if (uiTile?.width && uiTile?.height) return uiTile;
34
+ const hitbox = object.hitbox?.() || { w: 32, h: 32 };
35
+ return { width: hitbox.w || 32, height: hitbox.h || 32 };
36
+ };
37
+
38
+ const getOriginTile = () => {
39
+ const hitbox = object.hitbox?.() || { w: 32, h: 32 };
40
+ const tileSize = getTileSize();
41
+ const x = Math.floor((object.x() + hitbox.w / 2) / tileSize.width);
42
+ const y = Math.floor((object.y() + hitbox.h / 2) / tileSize.height);
43
+ return { x, y };
44
+ };
45
+
46
+ const toColor = (value, fallback) => {
47
+ if (typeof value === "number") return value;
48
+ return fallback;
49
+ };
50
+
51
+ const drawGrid = (g) => {
52
+ const state = targetingState();
53
+ if (!state.active) return;
54
+
55
+ const tileSize = getTileSize();
56
+ const origin = getOriginTile();
57
+ const target = {
58
+ x: origin.x + state.offset.x,
59
+ y: origin.y + state.offset.y
60
+ };
61
+
62
+ const mask = parseAoeMask(state.aoeMask);
63
+ const colors = uiOptions().targeting?.colors || {};
64
+ const areaColor = toColor(colors.area, 0x2f9ef7);
65
+ const edgeColor = toColor(colors.edge, 0x1b6a98);
66
+ const cursorColor = toColor(colors.cursor, 0xffd166);
67
+
68
+ const showGrid = uiOptions().targeting?.showGrid !== false;
69
+ const playerX = object.x();
70
+ const playerY = object.y();
71
+
72
+ g.clear();
73
+
74
+ mask.cells.forEach((cell) => {
75
+ const tileX = target.x + cell.dx;
76
+ const tileY = target.y + cell.dy;
77
+ const worldX = tileX * tileSize.width;
78
+ const worldY = tileY * tileSize.height;
79
+ const relX = worldX - playerX;
80
+ const relY = worldY - playerY;
81
+
82
+ g.rect(relX, relY, tileSize.width, tileSize.height);
83
+ g.fill({ color: areaColor, alpha: 0.35 });
84
+
85
+ if (showGrid) {
86
+ g.rect(relX, relY, tileSize.width, tileSize.height);
87
+ g.stroke({ color: edgeColor, alpha: 0.9, width: 1 });
88
+ }
89
+ });
90
+
91
+ const cursorWorldX = target.x * tileSize.width;
92
+ const cursorWorldY = target.y * tileSize.height;
93
+ const cursorRelX = cursorWorldX - playerX;
94
+ const cursorRelY = cursorWorldY - playerY;
95
+
96
+ g.rect(cursorRelX, cursorRelY, tileSize.width, tileSize.height);
97
+ g.stroke({ color: cursorColor, alpha: 1, width: 2 });
98
+ };
99
+ </script>
package/src/config.ts ADDED
@@ -0,0 +1,56 @@
1
+ import { ActionBattleOptions } from "./types";
2
+
3
+ export const DEFAULT_ACTION_BATTLE_OPTIONS: ActionBattleOptions = {
4
+ ui: {
5
+ actionBar: {
6
+ enabled: false,
7
+ autoOpen: false,
8
+ mode: "both",
9
+ },
10
+ targeting: {
11
+ enabled: true,
12
+ showGrid: true,
13
+ colors: {
14
+ area: 0x2f9ef7,
15
+ edge: 0x1b6a98,
16
+ cursor: 0xffd166,
17
+ },
18
+ },
19
+ },
20
+ skills: {
21
+ defaultAoeMask: ["#"],
22
+ },
23
+ targeting: {
24
+ affects: "events",
25
+ allowEmptyTarget: true,
26
+ },
27
+ };
28
+
29
+ export function normalizeActionBattleOptions(
30
+ options: ActionBattleOptions = {}
31
+ ): ActionBattleOptions {
32
+ return {
33
+ ui: {
34
+ actionBar: {
35
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.ui?.actionBar,
36
+ ...options.ui?.actionBar,
37
+ },
38
+ targeting: {
39
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.ui?.targeting,
40
+ ...options.ui?.targeting,
41
+ colors: {
42
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.ui?.targeting?.colors,
43
+ ...options.ui?.targeting?.colors,
44
+ },
45
+ },
46
+ },
47
+ skills: {
48
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.skills,
49
+ ...options.skills,
50
+ },
51
+ targeting: {
52
+ ...DEFAULT_ACTION_BATTLE_OPTIONS.targeting,
53
+ ...options.targeting,
54
+ },
55
+ };
56
+ }
package/src/index.ts CHANGED
@@ -1,21 +1,46 @@
1
- import server from "./server";
2
- import client from "./client";
1
+ import server, { createActionBattleServer } from "./server";
2
+ import client, { createActionBattleClient } from "./client";
3
3
  import { createModule } from "@rpgjs/common";
4
+ import type { ActionBattleOptions } from "./types";
4
5
 
5
6
  // AI exports
6
7
  export { BattleAi, AiState, EnemyType, AttackPattern, AiDebug, DEFAULT_KNOCKBACK } from "./ai.server";
7
8
 
8
9
  // Types exports
9
10
  export type { HitResult, ApplyHitHooks } from "./ai.server";
11
+ export type {
12
+ ActionBattleOptions,
13
+ ActionBattleActionBarData,
14
+ ActionBattleActionBarItem,
15
+ ActionBattleActionBarSkill,
16
+ ActionBattleSkillTargeting,
17
+ ActionBattleSkillTargetingResolver,
18
+ ActionBattleUiOptions,
19
+ ActionBattleUiActionBarOptions,
20
+ ActionBattleUiTargetingOptions,
21
+ } from "./types";
10
22
 
11
23
  // Server exports
12
- export { DEFAULT_PLAYER_ATTACK_HITBOXES, getPlayerWeaponKnockbackForce, applyPlayerHitToEvent } from "./server";
24
+ export {
25
+ DEFAULT_PLAYER_ATTACK_HITBOXES,
26
+ getPlayerWeaponKnockbackForce,
27
+ applyPlayerHitToEvent,
28
+ ACTION_BATTLE_ACTION_BAR_GUI_ID,
29
+ openActionBattleActionBar,
30
+ updateActionBattleActionBar,
31
+ createActionBattleServer,
32
+ } from "./server";
13
33
 
14
- export function provideActionBattle() {
34
+ export function provideActionBattle(options: ActionBattleOptions = {}) {
15
35
  return createModule("ActionBattle", [
16
36
  {
17
- server,
18
- client,
37
+ server: createActionBattleServer?.(options),
38
+ client: createActionBattleClient?.(options),
19
39
  },
20
40
  ]);
21
- }
41
+ }
42
+
43
+ export default {
44
+ server,
45
+ client,
46
+ };