@rpgjs/client 5.0.0-beta.12 → 5.0.0-beta.13

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.
Files changed (88) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/Game/Object.d.ts +2 -0
  3. package/dist/Game/Object.js +20 -6
  4. package/dist/Game/Object.js.map +1 -1
  5. package/dist/Gui/Gui.d.ts +3 -2
  6. package/dist/Gui/Gui.js +18 -6
  7. package/dist/Gui/Gui.js.map +1 -1
  8. package/dist/RpgClient.d.ts +21 -1
  9. package/dist/RpgClientEngine.d.ts +20 -2
  10. package/dist/RpgClientEngine.js +180 -17
  11. package/dist/RpgClientEngine.js.map +1 -1
  12. package/dist/components/character.ce.js +82 -7
  13. package/dist/components/character.ce.js.map +1 -1
  14. package/dist/components/gui/dialogbox/index.ce.js +27 -12
  15. package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
  16. package/dist/components/gui/gameover.ce.js +4 -3
  17. package/dist/components/gui/gameover.ce.js.map +1 -1
  18. package/dist/components/gui/menu/equip-menu.ce.js +9 -8
  19. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
  20. package/dist/components/gui/menu/exit-menu.ce.js +7 -5
  21. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
  22. package/dist/components/gui/menu/items-menu.ce.js +8 -7
  23. package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
  24. package/dist/components/gui/menu/main-menu.ce.js +12 -11
  25. package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
  26. package/dist/components/gui/menu/options-menu.ce.js +7 -5
  27. package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
  28. package/dist/components/gui/menu/skills-menu.ce.js +4 -2
  29. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
  30. package/dist/components/gui/notification/notification.ce.js +4 -1
  31. package/dist/components/gui/notification/notification.ce.js.map +1 -1
  32. package/dist/components/gui/save-load.ce.js +10 -9
  33. package/dist/components/gui/save-load.ce.js.map +1 -1
  34. package/dist/components/gui/shop/shop.ce.js +17 -16
  35. package/dist/components/gui/shop/shop.ce.js.map +1 -1
  36. package/dist/components/gui/title-screen.ce.js +4 -3
  37. package/dist/components/gui/title-screen.ce.js.map +1 -1
  38. package/dist/components/interaction-components.ce.js +20 -0
  39. package/dist/components/interaction-components.ce.js.map +1 -0
  40. package/dist/components/scenes/canvas.ce.js +12 -7
  41. package/dist/components/scenes/canvas.ce.js.map +1 -1
  42. package/dist/components/scenes/draw-map.ce.js +18 -13
  43. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  44. package/dist/i18n.d.ts +55 -0
  45. package/dist/i18n.js +60 -0
  46. package/dist/i18n.js.map +1 -0
  47. package/dist/i18n.spec.d.ts +1 -0
  48. package/dist/index.d.ts +2 -0
  49. package/dist/index.js +3 -1
  50. package/dist/module.js +23 -3
  51. package/dist/module.js.map +1 -1
  52. package/dist/services/interactions.d.ts +159 -0
  53. package/dist/services/interactions.js +460 -0
  54. package/dist/services/interactions.js.map +1 -0
  55. package/dist/services/interactions.spec.d.ts +1 -0
  56. package/dist/services/keyboardControls.d.ts +1 -0
  57. package/dist/services/keyboardControls.js +1 -0
  58. package/dist/services/keyboardControls.js.map +1 -1
  59. package/package.json +4 -4
  60. package/src/Game/Object.spec.ts +14 -1
  61. package/src/Game/Object.ts +34 -10
  62. package/src/Gui/Gui.spec.ts +67 -0
  63. package/src/Gui/Gui.ts +24 -7
  64. package/src/RpgClient.ts +28 -1
  65. package/src/RpgClientEngine.ts +248 -29
  66. package/src/components/character.ce +90 -7
  67. package/src/components/gui/dialogbox/index.ce +35 -14
  68. package/src/components/gui/gameover.ce +4 -3
  69. package/src/components/gui/menu/equip-menu.ce +9 -8
  70. package/src/components/gui/menu/exit-menu.ce +4 -3
  71. package/src/components/gui/menu/items-menu.ce +8 -7
  72. package/src/components/gui/menu/main-menu.ce +12 -11
  73. package/src/components/gui/menu/options-menu.ce +4 -3
  74. package/src/components/gui/menu/skills-menu.ce +2 -1
  75. package/src/components/gui/notification/notification.ce +7 -1
  76. package/src/components/gui/save-load.ce +11 -10
  77. package/src/components/gui/shop/shop.ce +17 -16
  78. package/src/components/gui/title-screen.ce +4 -3
  79. package/src/components/interaction-components.ce +23 -0
  80. package/src/components/scenes/canvas.ce +12 -7
  81. package/src/components/scenes/draw-map.ce +16 -5
  82. package/src/i18n.spec.ts +39 -0
  83. package/src/i18n.ts +59 -0
  84. package/src/index.ts +2 -0
  85. package/src/module.ts +32 -10
  86. package/src/services/interactions.spec.ts +175 -0
  87. package/src/services/interactions.ts +722 -0
  88. package/src/services/keyboardControls.ts +2 -1
@@ -0,0 +1,722 @@
1
+ import { signal } from "canvasengine";
2
+ import type { RpgActionInput, RpgActionName } from "@rpgjs/common";
3
+ import type { RpgClientEngine } from "../RpgClientEngine";
4
+
5
+ export type RpgInteractionEventName =
6
+ | "pointerenter"
7
+ | "pointerleave"
8
+ | "pointerover"
9
+ | "pointerout"
10
+ | "pointerdown"
11
+ | "pointerup"
12
+ | "pointermove"
13
+ | "click"
14
+ | "dragstart"
15
+ | "dragmove"
16
+ | "drop"
17
+ | "cancel";
18
+
19
+ export type RpgInteractionPosition = {
20
+ x: number;
21
+ y: number;
22
+ };
23
+
24
+ export type RpgInteractionBounds = {
25
+ left: number;
26
+ top: number;
27
+ right: number;
28
+ bottom: number;
29
+ width: number;
30
+ height: number;
31
+ centerX: number;
32
+ centerY: number;
33
+ contains(point: RpgInteractionPosition | null | undefined): boolean;
34
+ };
35
+
36
+ export type RpgInteractionBoundsSet = {
37
+ bounds?: RpgInteractionBounds;
38
+ hitbox?: RpgInteractionBounds;
39
+ graphic?: RpgInteractionBounds;
40
+ [key: string]: RpgInteractionBounds | undefined;
41
+ };
42
+
43
+ export type RpgInteractionTile = {
44
+ x: number;
45
+ y: number;
46
+ worldX: number;
47
+ worldY: number;
48
+ width: number;
49
+ height: number;
50
+ };
51
+
52
+ export type RpgInteractionState = {
53
+ hovered: boolean;
54
+ pressed: boolean;
55
+ selected: boolean;
56
+ dragging: boolean;
57
+ data: Record<string, any>;
58
+ overlays: Record<string, RpgInteractionOverlay>;
59
+ };
60
+
61
+ export type RpgInteractionOverlay = {
62
+ component: any;
63
+ props?: Record<string, any>;
64
+ };
65
+
66
+ export type RpgInteractionMatcher =
67
+ | string
68
+ | ((ctx: RpgInteractionMatcherContext) => boolean);
69
+
70
+ export type RpgInteractionMatcherContext = {
71
+ client: RpgClientEngine;
72
+ target: any;
73
+ sprite: any;
74
+ };
75
+
76
+ export type RpgInteractionHandler = (ctx: RpgInteractionContext) => void;
77
+
78
+ export type RpgInteractionBehavior = {
79
+ component?: any;
80
+ props?: Record<string, any> | ((ctx: RpgInteractionContext) => Record<string, any>);
81
+ dependencies?: any[] | ((ctx: RpgInteractionContext) => any[]);
82
+ cursor?: string | ((ctx: RpgInteractionContext) => string | undefined);
83
+ hitTest?: (ctx: RpgInteractionContext) => boolean;
84
+ pointerenter?: RpgInteractionHandler;
85
+ pointerleave?: RpgInteractionHandler;
86
+ pointerover?: RpgInteractionHandler;
87
+ pointerout?: RpgInteractionHandler;
88
+ pointerdown?: RpgInteractionHandler;
89
+ pointerup?: RpgInteractionHandler;
90
+ pointermove?: RpgInteractionHandler;
91
+ click?: RpgInteractionHandler;
92
+ dragstart?: RpgInteractionHandler;
93
+ dragmove?: RpgInteractionHandler;
94
+ drop?: RpgInteractionHandler;
95
+ cancel?: RpgInteractionHandler;
96
+ };
97
+
98
+ export type RpgInteractionComponentEntry = {
99
+ component: any;
100
+ props: Record<string, any>;
101
+ dependencies: any[];
102
+ };
103
+
104
+ export type RpgInteractionContext = {
105
+ client: RpgClientEngine;
106
+ target: any;
107
+ sprite: any;
108
+ event?: any;
109
+ behavior: RpgInteractionBehavior;
110
+ behaviorId: string;
111
+ pointer: {
112
+ screen(): RpgInteractionPosition | null;
113
+ world(): RpgInteractionPosition | null;
114
+ tile(): RpgInteractionTile | null;
115
+ };
116
+ bounds(kind?: string): RpgInteractionBounds;
117
+ state: {
118
+ value(): RpgInteractionState;
119
+ get<T = any>(key: string): T | undefined;
120
+ set(key: string, value: any): void;
121
+ patch(patch: Partial<Omit<RpgInteractionState, "data" | "overlays">>): void;
122
+ };
123
+ overlay: {
124
+ render(component: any, props?: Record<string, any>): void;
125
+ update(props?: Record<string, any>): void;
126
+ clear(): void;
127
+ };
128
+ select(selected?: boolean): void;
129
+ action(action: RpgActionName, data?: any): void;
130
+ action(input: RpgActionInput): void;
131
+ cancel(): void;
132
+ };
133
+
134
+ type InteractionRegistration = {
135
+ id: string;
136
+ matcher: RpgInteractionMatcher;
137
+ behavior: RpgInteractionBehavior;
138
+ };
139
+
140
+ type InteractionEventInput = {
141
+ event?: any;
142
+ bounds?: RpgInteractionBoundsSet;
143
+ };
144
+
145
+ type ActiveDrag = {
146
+ sprite: any;
147
+ behavior: RpgInteractionBehavior;
148
+ behaviorId: string;
149
+ bounds?: RpgInteractionBoundsSet;
150
+ cancelled: boolean;
151
+ };
152
+
153
+ const DEFAULT_STATE: RpgInteractionState = {
154
+ hovered: false,
155
+ pressed: false,
156
+ selected: false,
157
+ dragging: false,
158
+ data: {},
159
+ overlays: {},
160
+ };
161
+
162
+ function readValue(value: any): any {
163
+ return typeof value === "function" ? value() : value;
164
+ }
165
+
166
+ function createBounds(value: any): RpgInteractionBounds {
167
+ const left = Number(value?.left ?? value?.x ?? 0);
168
+ const top = Number(value?.top ?? value?.y ?? 0);
169
+ const width = Number(value?.width ?? value?.w ?? 0);
170
+ const height = Number(value?.height ?? value?.h ?? 0);
171
+ const right = Number(value?.right ?? left + width);
172
+ const bottom = Number(value?.bottom ?? top + height);
173
+ const resolvedWidth = Number.isFinite(width) && width > 0 ? width : Math.max(0, right - left);
174
+ const resolvedHeight = Number.isFinite(height) && height > 0 ? height : Math.max(0, bottom - top);
175
+
176
+ return {
177
+ left,
178
+ top,
179
+ right,
180
+ bottom,
181
+ width: resolvedWidth,
182
+ height: resolvedHeight,
183
+ centerX: Number(value?.centerX ?? left + resolvedWidth / 2),
184
+ centerY: Number(value?.centerY ?? top + resolvedHeight / 2),
185
+ contains(point) {
186
+ if (!point) return false;
187
+ return point.x >= left && point.x <= right && point.y >= top && point.y <= bottom;
188
+ },
189
+ };
190
+ }
191
+
192
+ function normalizeBounds(bounds?: RpgInteractionBoundsSet): RpgInteractionBoundsSet {
193
+ if (!bounds) return {};
194
+
195
+ return Object.entries(bounds).reduce<RpgInteractionBoundsSet>((next, [key, value]) => {
196
+ if (value) next[key] = createBounds(value);
197
+ return next;
198
+ }, {});
199
+ }
200
+
201
+ function readNumber(value: any, fallback = 0): number {
202
+ const resolved = readValue(value);
203
+ const number = Number(resolved);
204
+ return Number.isFinite(number) ? number : fallback;
205
+ }
206
+
207
+ function offsetBounds(bounds: RpgInteractionBounds, x: number, y: number): RpgInteractionBounds {
208
+ return createBounds({
209
+ left: bounds.left + x,
210
+ top: bounds.top + y,
211
+ right: bounds.right + x,
212
+ bottom: bounds.bottom + y,
213
+ width: bounds.width,
214
+ height: bounds.height,
215
+ centerX: bounds.centerX + x,
216
+ centerY: bounds.centerY + y,
217
+ });
218
+ }
219
+
220
+ function toWorldBounds(sprite: any, bounds: RpgInteractionBoundsSet): RpgInteractionBoundsSet {
221
+ const x = readNumber(sprite?.x);
222
+ const y = readNumber(sprite?.y);
223
+
224
+ return Object.entries(bounds).reduce<RpgInteractionBoundsSet>((next, [key, value]) => {
225
+ if (value) next[key] = offsetBounds(value, x, y);
226
+ return next;
227
+ }, {});
228
+ }
229
+
230
+ function normalizeBehavior(behavior: RpgInteractionBehavior | any): RpgInteractionBehavior {
231
+ if (typeof behavior === "function") {
232
+ return { component: behavior };
233
+ }
234
+ return behavior ?? {};
235
+ }
236
+
237
+ export class RpgClientInteractions {
238
+ private registrations = signal<InteractionRegistration[]>([]);
239
+ private states = signal<Record<string, RpgInteractionState>>({});
240
+ private activeDrag?: ActiveDrag;
241
+ private nextId = 0;
242
+
243
+ constructor(private client: RpgClientEngine) {}
244
+
245
+ use(matcher: RpgInteractionMatcher, behavior: RpgInteractionBehavior | any): () => void {
246
+ const registration = {
247
+ id: `interaction:${++this.nextId}`,
248
+ matcher,
249
+ behavior: normalizeBehavior(behavior),
250
+ };
251
+
252
+ this.registrations.update((registrations) => [...registrations, registration]);
253
+
254
+ return () => {
255
+ this.registrations.update((registrations) =>
256
+ registrations.filter((entry) => entry.id !== registration.id)
257
+ );
258
+ };
259
+ }
260
+
261
+ getState(sprite: any): RpgInteractionState {
262
+ const id = this.getSpriteId(sprite);
263
+ return {
264
+ ...DEFAULT_STATE,
265
+ ...(id ? this.states()[id] : undefined),
266
+ data: id ? { ...(this.states()[id]?.data ?? {}) } : {},
267
+ overlays: id ? { ...(this.states()[id]?.overlays ?? {}) } : {},
268
+ };
269
+ }
270
+
271
+ getRenderedComponents(sprite: any, bounds?: RpgInteractionBoundsSet): RpgInteractionComponentEntry[] {
272
+ const normalizedBounds = normalizeBounds(bounds);
273
+ const matched = this.getMatches(sprite);
274
+ const state = this.getState(sprite);
275
+ const entries: RpgInteractionComponentEntry[] = [];
276
+
277
+ matched.forEach((registration) => {
278
+ if (!registration.behavior.component) return;
279
+ const ctx = this.createContext(sprite, registration, { bounds: normalizedBounds });
280
+ entries.push({
281
+ component: registration.behavior.component,
282
+ props: {
283
+ ...this.defaultComponentProps(sprite, state, normalizedBounds),
284
+ ...this.resolveProps(registration.behavior.props, ctx),
285
+ },
286
+ dependencies: this.resolveDependencies(registration.behavior.dependencies, ctx),
287
+ });
288
+ });
289
+
290
+ Object.entries(state.overlays).forEach(([id, overlay]) => {
291
+ entries.push({
292
+ component: overlay.component,
293
+ props: {
294
+ ...this.defaultComponentProps(sprite, state, normalizedBounds),
295
+ ...(overlay.props ?? {}),
296
+ overlayId: id,
297
+ },
298
+ dependencies: [],
299
+ });
300
+ });
301
+
302
+ return entries;
303
+ }
304
+
305
+ cursorFor(sprite: any, bounds?: RpgInteractionBoundsSet): string | undefined {
306
+ this.states();
307
+ for (const registration of this.getMatches(sprite)) {
308
+ const cursor = registration.behavior.cursor;
309
+ if (!cursor) continue;
310
+ const ctx = this.createContext(sprite, registration, { bounds: normalizeBounds(bounds) });
311
+ if (!this.passesHitTest(registration.behavior, ctx, "pointermove")) continue;
312
+ const resolved = typeof cursor === "function" ? cursor(ctx) : cursor;
313
+ if (resolved) return resolved;
314
+ }
315
+ return undefined;
316
+ }
317
+
318
+ handle(sprite: any, type: RpgInteractionEventName, input: InteractionEventInput = {}): void {
319
+ const matches = this.getMatches(sprite);
320
+ const bounds = normalizeBounds(input.bounds);
321
+ const entries = matches
322
+ .map((registration) => ({
323
+ registration,
324
+ ctx: this.createContext(sprite, registration, {
325
+ event: input.event,
326
+ bounds,
327
+ }),
328
+ }))
329
+ .filter(({ registration, ctx }) =>
330
+ this.passesHitTest(registration.behavior, ctx, type)
331
+ );
332
+
333
+ if (type === "pointerover" || type === "pointerenter") {
334
+ if (entries.length > 0) {
335
+ this.patchState(sprite, { hovered: true });
336
+ }
337
+ }
338
+ if (type === "pointerout" || type === "pointerleave") {
339
+ if (matches.length > 0) {
340
+ this.patchState(sprite, { hovered: false, pressed: false });
341
+ }
342
+ }
343
+ if (type === "pointerdown") {
344
+ if (entries.length > 0) {
345
+ this.patchState(sprite, { pressed: true });
346
+ }
347
+ }
348
+ if (type === "pointermove" && matches.length > 0) {
349
+ this.patchState(sprite, { hovered: entries.length > 0 });
350
+ }
351
+ if (type === "pointerup") {
352
+ if (matches.length > 0) {
353
+ this.patchState(sprite, { pressed: false });
354
+ }
355
+ }
356
+
357
+ entries.forEach(({ registration, ctx }) => {
358
+ this.callHandler(registration.behavior, type, ctx);
359
+
360
+ if (type === "pointerdown" && this.isDraggable(registration.behavior)) {
361
+ this.activeDrag = {
362
+ sprite,
363
+ behavior: registration.behavior,
364
+ behaviorId: registration.id,
365
+ bounds,
366
+ cancelled: false,
367
+ };
368
+ this.patchState(sprite, { dragging: true });
369
+ this.callHandler(registration.behavior, "dragstart", ctx);
370
+ }
371
+ });
372
+ }
373
+
374
+ handlePointerMove(event?: any): void {
375
+ if (!this.activeDrag) return;
376
+ const drag = this.activeDrag;
377
+ const registration = {
378
+ id: drag.behaviorId,
379
+ matcher: "*",
380
+ behavior: drag.behavior,
381
+ };
382
+ const ctx = this.createContext(drag.sprite, registration, {
383
+ event,
384
+ bounds: drag.bounds,
385
+ });
386
+ this.callHandler(drag.behavior, "dragmove", ctx);
387
+ }
388
+
389
+ handlePointerUp(event?: any): void {
390
+ if (!this.activeDrag) return;
391
+ const drag = this.activeDrag;
392
+ this.activeDrag = undefined;
393
+ this.patchState(drag.sprite, { dragging: false, pressed: false });
394
+
395
+ const registration = {
396
+ id: drag.behaviorId,
397
+ matcher: "*",
398
+ behavior: drag.behavior,
399
+ };
400
+ const ctx = this.createContext(drag.sprite, registration, {
401
+ event,
402
+ bounds: drag.bounds,
403
+ });
404
+
405
+ this.callHandler(drag.behavior, drag.cancelled ? "cancel" : "drop", ctx);
406
+ }
407
+
408
+ cancelDrag(event?: any): void {
409
+ if (!this.activeDrag) return;
410
+ this.activeDrag.cancelled = true;
411
+ this.handlePointerUp(event);
412
+ }
413
+
414
+ private getMatches(sprite: any): InteractionRegistration[] {
415
+ return this.registrations().filter((registration) =>
416
+ this.matches(registration.matcher, sprite)
417
+ );
418
+ }
419
+
420
+ private matches(matcher: RpgInteractionMatcher, sprite: any): boolean {
421
+ if (typeof matcher === "function") {
422
+ return !!matcher({ client: this.client, target: sprite, sprite });
423
+ }
424
+
425
+ if (matcher === "*") return true;
426
+
427
+ const candidates = [
428
+ readValue(sprite?.id),
429
+ readValue(sprite?.name),
430
+ readValue(sprite?._name),
431
+ readValue(sprite?.type),
432
+ readValue(sprite?._type),
433
+ sprite?.constructor?.name,
434
+ ].filter(Boolean);
435
+
436
+ return candidates.includes(matcher);
437
+ }
438
+
439
+ private createContext(
440
+ sprite: any,
441
+ registration: Pick<InteractionRegistration, "id" | "behavior">,
442
+ input: InteractionEventInput = {},
443
+ ): RpgInteractionContext {
444
+ const bounds = toWorldBounds(sprite, normalizeBounds(input.bounds));
445
+
446
+ const ctx = {
447
+ client: this.client,
448
+ target: sprite,
449
+ sprite,
450
+ event: input.event,
451
+ behavior: registration.behavior,
452
+ behaviorId: registration.id,
453
+ pointer: {
454
+ screen: () => this.client.pointer.screen(),
455
+ world: () => this.client.pointer.world(),
456
+ tile: () => this.getPointerTile(),
457
+ },
458
+ bounds: (kind = "bounds") =>
459
+ bounds[kind] ?? bounds.graphic ?? bounds.hitbox ?? bounds.bounds ?? createBounds({}),
460
+ state: {
461
+ value: () => this.getState(sprite),
462
+ get: <T = any>(key: string) => this.getState(sprite).data[key] as T | undefined,
463
+ set: (key: string, value: any) => this.patchStateData(sprite, { [key]: value }),
464
+ patch: (patch: Partial<Omit<RpgInteractionState, "data" | "overlays">>) =>
465
+ this.patchState(sprite, patch),
466
+ },
467
+ overlay: {
468
+ render: (component: any, props?: Record<string, any>) =>
469
+ this.patchOverlay(sprite, registration.id, { component, props }),
470
+ update: (props?: Record<string, any>) =>
471
+ this.updateOverlay(sprite, registration.id, props),
472
+ clear: () => this.clearOverlay(sprite, registration.id),
473
+ },
474
+ select: (selected = true) => this.patchState(sprite, { selected }),
475
+ action: (action: RpgActionName | RpgActionInput, data?: any) =>
476
+ this.client.processAction(action as any, data),
477
+ cancel: () => {
478
+ const activeDrag = this.activeDrag;
479
+ if (activeDrag && activeDrag.sprite === sprite) {
480
+ activeDrag.cancelled = true;
481
+ }
482
+ },
483
+ };
484
+
485
+ return ctx;
486
+ }
487
+
488
+ private callHandler(
489
+ behavior: RpgInteractionBehavior,
490
+ type: RpgInteractionEventName,
491
+ ctx: RpgInteractionContext,
492
+ ): void {
493
+ const handler = behavior[type];
494
+ handler?.(ctx);
495
+
496
+ if (type === "pointerover") {
497
+ behavior.pointerenter?.(ctx);
498
+ }
499
+ if (type === "pointerout") {
500
+ behavior.pointerleave?.(ctx);
501
+ }
502
+ }
503
+
504
+ private passesHitTest(
505
+ behavior: RpgInteractionBehavior,
506
+ ctx: RpgInteractionContext,
507
+ type: RpgInteractionEventName,
508
+ ): boolean {
509
+ if (!behavior.hitTest) return true;
510
+ if (type === "pointerout" || type === "pointerleave" || type === "cancel" || type === "drop") {
511
+ return true;
512
+ }
513
+ return behavior.hitTest(ctx);
514
+ }
515
+
516
+ private isDraggable(behavior: RpgInteractionBehavior): boolean {
517
+ return !!(behavior.dragstart || behavior.dragmove || behavior.drop || behavior.cancel);
518
+ }
519
+
520
+ private defaultComponentProps(
521
+ sprite: any,
522
+ state: RpgInteractionState,
523
+ bounds: RpgInteractionBoundsSet,
524
+ ): Record<string, any> {
525
+ return {
526
+ target: sprite,
527
+ sprite,
528
+ state,
529
+ bounds: bounds.bounds ?? bounds.graphic ?? bounds.hitbox ?? createBounds({}),
530
+ hitboxBounds: bounds.hitbox,
531
+ graphicBounds: bounds.graphic,
532
+ pointer: this.client.pointer,
533
+ client: this.client,
534
+ };
535
+ }
536
+
537
+ private resolveProps(
538
+ props: RpgInteractionBehavior["props"],
539
+ ctx: RpgInteractionContext,
540
+ ): Record<string, any> {
541
+ if (!props) return {};
542
+ return typeof props === "function" ? props(ctx) : props;
543
+ }
544
+
545
+ private resolveDependencies(
546
+ dependencies: RpgInteractionBehavior["dependencies"],
547
+ ctx: RpgInteractionContext,
548
+ ): any[] {
549
+ if (!dependencies) return [];
550
+ return typeof dependencies === "function" ? dependencies(ctx) : dependencies;
551
+ }
552
+
553
+ private getSpriteId(sprite: any): string | undefined {
554
+ const id = readValue(sprite?.id);
555
+ return id == null ? undefined : String(id);
556
+ }
557
+
558
+ private patchState(
559
+ sprite: any,
560
+ patch: Partial<Omit<RpgInteractionState, "data" | "overlays">>,
561
+ ): void {
562
+ const id = this.getSpriteId(sprite);
563
+ if (!id) return;
564
+ this.states.update((states) => ({
565
+ ...states,
566
+ [id]: {
567
+ ...DEFAULT_STATE,
568
+ ...(states[id] ?? {}),
569
+ ...patch,
570
+ data: states[id]?.data ?? {},
571
+ overlays: states[id]?.overlays ?? {},
572
+ },
573
+ }));
574
+ }
575
+
576
+ private patchStateData(sprite: any, patch: Record<string, any>): void {
577
+ const id = this.getSpriteId(sprite);
578
+ if (!id) return;
579
+ const current = this.getState(sprite);
580
+ this.states.update((states) => ({
581
+ ...states,
582
+ [id]: {
583
+ ...current,
584
+ data: {
585
+ ...current.data,
586
+ ...patch,
587
+ },
588
+ },
589
+ }));
590
+ }
591
+
592
+ private patchOverlay(sprite: any, id: string, overlay: RpgInteractionOverlay): void {
593
+ const spriteId = this.getSpriteId(sprite);
594
+ if (!spriteId) return;
595
+ const current = this.getState(sprite);
596
+ this.states.update((states) => ({
597
+ ...states,
598
+ [spriteId]: {
599
+ ...current,
600
+ overlays: {
601
+ ...current.overlays,
602
+ [id]: overlay,
603
+ },
604
+ },
605
+ }));
606
+ }
607
+
608
+ private updateOverlay(sprite: any, id: string, props?: Record<string, any>): void {
609
+ const current = this.getState(sprite);
610
+ const overlay = current.overlays[id];
611
+ if (!overlay) return;
612
+ this.patchOverlay(sprite, id, {
613
+ ...overlay,
614
+ props: {
615
+ ...(overlay.props ?? {}),
616
+ ...(props ?? {}),
617
+ },
618
+ });
619
+ }
620
+
621
+ private clearOverlay(sprite: any, id: string): void {
622
+ const spriteId = this.getSpriteId(sprite);
623
+ if (!spriteId) return;
624
+ const current = this.getState(sprite);
625
+ const { [id]: _removed, ...overlays } = current.overlays;
626
+ this.states.update((states) => ({
627
+ ...states,
628
+ [spriteId]: {
629
+ ...current,
630
+ overlays,
631
+ },
632
+ }));
633
+ }
634
+
635
+ private getPointerTile(): RpgInteractionTile | null {
636
+ const world = this.client.pointer.world();
637
+ if (!world) return null;
638
+
639
+ const map = (this.client as any).sceneMap;
640
+ const width = Number(map?.tileWidth ?? 32);
641
+ const height = Number(map?.tileHeight ?? 32);
642
+ const tileX = Math.floor(world.x / width);
643
+ const tileY = Math.floor(world.y / height);
644
+
645
+ return {
646
+ x: tileX,
647
+ y: tileY,
648
+ worldX: tileX * width,
649
+ worldY: tileY * height,
650
+ width,
651
+ height,
652
+ };
653
+ }
654
+ }
655
+
656
+ export function hoverPopover(component: any, props?: Record<string, any>): RpgInteractionBehavior {
657
+ return {
658
+ component,
659
+ props,
660
+ cursor: "pointer",
661
+ };
662
+ }
663
+
664
+ export function selectable(options: {
665
+ cursor?: string;
666
+ onSelect?: RpgInteractionHandler;
667
+ } = {}): RpgInteractionBehavior {
668
+ return {
669
+ cursor: options.cursor ?? "pointer",
670
+ click(ctx) {
671
+ ctx.select();
672
+ options.onSelect?.(ctx);
673
+ },
674
+ };
675
+ }
676
+
677
+ export function draggable(options: {
678
+ cursor?: string;
679
+ start?: RpgInteractionHandler;
680
+ move?: RpgInteractionHandler;
681
+ drop?: RpgInteractionHandler;
682
+ cancel?: RpgInteractionHandler;
683
+ } = {}): RpgInteractionBehavior {
684
+ return {
685
+ cursor: options.cursor ?? "grab",
686
+ dragstart(ctx) {
687
+ ctx.state.patch({ dragging: true });
688
+ options.start?.(ctx);
689
+ },
690
+ dragmove: options.move,
691
+ drop(ctx) {
692
+ ctx.state.patch({ dragging: false, pressed: false });
693
+ options.drop?.(ctx);
694
+ },
695
+ cancel(ctx) {
696
+ ctx.state.patch({ dragging: false, pressed: false });
697
+ options.cancel?.(ctx);
698
+ },
699
+ };
700
+ }
701
+
702
+ export function dragToTile(options: {
703
+ action?: RpgActionName;
704
+ data?: (ctx: RpgInteractionContext) => any;
705
+ onDrop?: RpgInteractionHandler;
706
+ cursor?: string;
707
+ }): RpgInteractionBehavior {
708
+ return draggable({
709
+ cursor: options.cursor,
710
+ drop(ctx) {
711
+ if (options.onDrop) {
712
+ options.onDrop(ctx);
713
+ return;
714
+ }
715
+ if (!options.action) return;
716
+ ctx.action(options.action, options.data ? options.data(ctx) : {
717
+ eventId: ctx.target.id,
718
+ position: ctx.pointer.tile(),
719
+ });
720
+ },
721
+ });
722
+ }