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

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 (94) hide show
  1. package/CHANGELOG.md +18 -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 +182 -17
  11. package/dist/RpgClientEngine.js.map +1 -1
  12. package/dist/components/character.ce.js +84 -9
  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 +66 -33
  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/components/scenes/event-layer.ce.js +42 -3
  45. package/dist/components/scenes/event-layer.ce.js.map +1 -1
  46. package/dist/i18n.d.ts +55 -0
  47. package/dist/i18n.js +60 -0
  48. package/dist/i18n.js.map +1 -0
  49. package/dist/i18n.spec.d.ts +1 -0
  50. package/dist/index.d.ts +2 -0
  51. package/dist/index.js +3 -1
  52. package/dist/module.js +23 -3
  53. package/dist/module.js.map +1 -1
  54. package/dist/services/interactions.d.ts +159 -0
  55. package/dist/services/interactions.js +460 -0
  56. package/dist/services/interactions.js.map +1 -0
  57. package/dist/services/interactions.spec.d.ts +1 -0
  58. package/dist/services/keyboardControls.d.ts +1 -0
  59. package/dist/services/keyboardControls.js +1 -0
  60. package/dist/services/keyboardControls.js.map +1 -1
  61. package/dist/services/loadMap.d.ts +3 -0
  62. package/dist/services/loadMap.js.map +1 -1
  63. package/package.json +4 -4
  64. package/src/Game/Object.spec.ts +14 -1
  65. package/src/Game/Object.ts +34 -10
  66. package/src/Gui/Gui.spec.ts +67 -0
  67. package/src/Gui/Gui.ts +24 -7
  68. package/src/RpgClient.ts +28 -1
  69. package/src/RpgClientEngine.ts +254 -29
  70. package/src/components/character.ce +92 -9
  71. package/src/components/gui/dialogbox/index.ce +35 -14
  72. package/src/components/gui/gameover.ce +4 -3
  73. package/src/components/gui/menu/equip-menu.ce +9 -8
  74. package/src/components/gui/menu/exit-menu.ce +4 -3
  75. package/src/components/gui/menu/items-menu.ce +8 -7
  76. package/src/components/gui/menu/main-menu.ce +12 -11
  77. package/src/components/gui/menu/options-menu.ce +4 -3
  78. package/src/components/gui/menu/skills-menu.ce +2 -1
  79. package/src/components/gui/notification/notification.ce +7 -1
  80. package/src/components/gui/save-load.ce +11 -10
  81. package/src/components/gui/shop/shop.ce +17 -16
  82. package/src/components/gui/title-screen.ce +4 -3
  83. package/src/components/interaction-components.ce +23 -0
  84. package/src/components/scenes/canvas.ce +68 -31
  85. package/src/components/scenes/draw-map.ce +16 -5
  86. package/src/components/scenes/event-layer.ce +54 -2
  87. package/src/i18n.spec.ts +39 -0
  88. package/src/i18n.ts +59 -0
  89. package/src/index.ts +2 -0
  90. package/src/module.ts +32 -10
  91. package/src/services/interactions.spec.ts +175 -0
  92. package/src/services/interactions.ts +722 -0
  93. package/src/services/keyboardControls.ts +2 -1
  94. package/src/services/loadMap.ts +3 -1
@@ -33,8 +33,8 @@
33
33
  height={engine.height}
34
34
  >
35
35
  @if (gui.display) {
36
- <gui.component data={gui.data} dependencies={gui.dependencies} onFinish={(data) => {
37
- onGuiFinish(gui, data)
36
+ <gui.component data={gui.data} dependencies={gui.dependencies} guiOpenId={gui.openId} onFinish={(data, guiOpenId) => {
37
+ onGuiFinish(gui, data, guiOpenId)
38
38
  }} onInteraction={(name, data) => {
39
39
  onGuiInteraction(gui, name, data)
40
40
  }} />
@@ -48,8 +48,8 @@
48
48
  import { inject } from "../../core/inject";
49
49
  import { RpgClientEngine } from "../../RpgClientEngine";
50
50
  import { RpgGui } from "../../Gui/Gui";
51
- import { delay } from "@rpgjs/common";
52
51
  import { NightAmbiant, SpriteShadows } from '@canvasengine/presets'
52
+ import { shouldRenderLightingShadows } from "@rpgjs/common";
53
53
 
54
54
  const engine = inject(RpgClientEngine);
55
55
  const SceneMap = engine.sceneMapComponent;
@@ -76,10 +76,16 @@
76
76
  }
77
77
  })
78
78
 
79
- const onGuiFinish = (gui, data) => {
80
- delay(() => {
81
- guiService.guiClose(gui.name, data)
82
- })
79
+ const normalizeOpenId = (value) => {
80
+ const resolved = typeof value === "function" ? value() : value
81
+ return typeof resolved === "string" && resolved.length > 0 ? resolved : undefined
82
+ }
83
+
84
+ const onGuiFinish = (gui, data, guiOpenId) => {
85
+ const completedOpenId = normalizeOpenId(guiOpenId)
86
+ const currentOpenId = normalizeOpenId(gui.openId)
87
+ if (completedOpenId && currentOpenId && completedOpenId !== currentOpenId) return
88
+ guiService.guiClose(gui.name, data, completedOpenId ?? currentOpenId)
83
89
  }
84
90
 
85
91
  const onGuiInteraction = (gui, name, data) => {
@@ -94,12 +100,20 @@
94
100
  const NIGHT_SPOT_MIN_INTENSITY = 1
95
101
  const SHADOW_SPOT_RADIUS_SCALE = 12
96
102
  const SHADOW_SPOT_MIN_RADIUS = 480
97
- const SHADOW_SPOT_MIN_INTENSITY = 1.35
98
103
 
104
+ const toFiniteNumber = (value, fallback = null) => {
105
+ const number = Number(value)
106
+ return Number.isFinite(number) ? number : fallback
107
+ }
108
+ const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value))
99
109
  const nightSpotRadius = (radius) => Math.max(radius * NIGHT_SPOT_RADIUS_SCALE, NIGHT_SPOT_MIN_RADIUS)
100
110
  const shadowSpotRadius = (radius) => Math.max(radius * SHADOW_SPOT_RADIUS_SCALE, SHADOW_SPOT_MIN_RADIUS)
101
111
  const nightSpotIntensity = (intensity, fallback) => Math.max(intensity ?? fallback, NIGHT_SPOT_MIN_INTENSITY)
102
- const shadowSpotIntensity = (intensity) => Math.max(intensity ?? 1.3, SHADOW_SPOT_MIN_INTENSITY)
112
+ const shadowLightIntensity = (intensity, fallback = 1) => {
113
+ const value = Number(intensity ?? fallback)
114
+ return Number.isFinite(value) ? Math.max(0, value) : fallback
115
+ }
116
+ const shadowSpotIntensity = (intensity) => shadowLightIntensity(intensity, 1)
103
117
 
104
118
  const lightingAmbient = computed(() => {
105
119
  const state = lighting?.()
@@ -123,11 +137,6 @@
123
137
  }
124
138
  })
125
139
  })
126
- const hasLightSpots = computed(() => {
127
- const state = lighting?.()
128
- return (state?.spots?.length ?? 0) > 0
129
- })
130
-
131
140
  const lightingDarkness = computed(() => {
132
141
  const darkness = lightingAmbient().darkness
133
142
  return typeof darkness === "number" ? darkness : 0
@@ -156,18 +165,55 @@
156
165
  const scale = Number(data?.params?.scale ?? 1) || 1
157
166
  const mapWidth = width * scale
158
167
  const mapHeight = height * scale
168
+ const projectionBase = Math.max(1, mapWidth, mapHeight)
159
169
 
160
170
  return {
161
- x: -mapWidth * 0.35,
162
- y: -mapHeight * 0.45,
171
+ x: -projectionBase * 24,
172
+ y: -projectionBase * 24,
163
173
  z: 520,
164
- radius: Math.max(mapWidth, mapHeight) * 2.5,
174
+ radius: projectionBase * 160,
165
175
  intensity: 0.85,
166
176
  shadowWeight: lightingDarkness() > 0 ? 2.2 : 1,
167
177
  enabled: true,
168
178
  }
169
179
  }
170
180
 
181
+ const normalizeSunDirection = (sun) => {
182
+ const x = toFiniteNumber(sun?.x, null)
183
+ const y = toFiniteNumber(sun?.y, null)
184
+ if (x !== null && y !== null && Math.hypot(x, y) > 0.001) {
185
+ return { x, y }
186
+ }
187
+ return { x: -0.45, y: -1 }
188
+ }
189
+
190
+ const defaultSunAmbientLight = () => {
191
+ const state = lighting?.()
192
+ if (!state?.sun || state.sun.enabled === false) return null
193
+
194
+ const defaultSun = defaultSunLight()
195
+ const sun = {
196
+ ...defaultSun,
197
+ ...state.sun,
198
+ intensity: shadowLightIntensity(state.sun.intensity, defaultSun.intensity),
199
+ shadowWeight: state.sun.shadowWeight ?? defaultSun.shadowWeight,
200
+ }
201
+ if (sun.intensity <= 0) return null
202
+
203
+ const direction = normalizeSunDirection(sun)
204
+ const shadowWeight = clampNumber(toFiniteNumber(sun.shadowWeight, lightingDarkness() > 0 ? 1.35 : 1) ?? 1, 0, 4)
205
+ const length = clampNumber(30 + sun.intensity * 38 * Math.max(0.75, shadowWeight), 30, 86)
206
+
207
+ return {
208
+ x: direction.x,
209
+ y: direction.y,
210
+ z: toFiniteNumber(sun.z, 520) ?? 520,
211
+ intensity: clampNumber(sun.intensity, 0, 2),
212
+ shadowWeight,
213
+ length,
214
+ }
215
+ }
216
+
171
217
  const shadowState = computed(() => {
172
218
  const state = lighting?.()
173
219
  return state?.shadows ?? null
@@ -175,12 +221,6 @@
175
221
 
176
222
  const shadowLights = computed(() => {
177
223
  const state = lighting?.()
178
- const defaultSun = defaultSunLight()
179
- const sun = {
180
- ...defaultSun,
181
- ...(state?.sun ?? {}),
182
- shadowWeight: state?.sun?.shadowWeight ?? defaultSun.shadowWeight,
183
- }
184
224
  const spotLights = (state?.spots ?? []).map((spot) => {
185
225
  const radius = spot.radius ?? 180
186
226
  return {
@@ -189,15 +229,12 @@
189
229
  z: 170,
190
230
  radius: shadowSpotRadius(radius),
191
231
  intensity: shadowSpotIntensity(spot.intensity),
192
- shadowWeight: 2.4,
232
+ shadowWeight: 1,
193
233
  enabled: true,
194
234
  }
195
235
  })
196
236
 
197
- return [
198
- ...((sun.enabled === false || sun.intensity <= 0) ? [] : [sun]),
199
- ...spotLights,
200
- ]
237
+ return spotLights
201
238
  })
202
239
 
203
240
  const shadowAmbientLight = computed(() => {
@@ -205,12 +242,12 @@
205
242
  if (shadows?.ambientLight === null || shadows?.ambientLight?.enabled === false) {
206
243
  return null
207
244
  }
208
- return shadows?.ambientLight ?? { x: -0.18, y: -1, z: 420, intensity: 0.32, shadowWeight: 1 }
245
+ return shadows?.ambientLight ?? defaultSunAmbientLight()
209
246
  })
210
247
 
211
248
  const shadowEnabled = computed(() => {
212
- const shadows = shadowState()
213
- return Boolean((shadows?.enabled || hasLightSpots()) && (shadowLights().length > 0 || shadowAmbientLight()))
249
+ const state = lighting?.()
250
+ return Boolean(shouldRenderLightingShadows(state) && (shadowLights().length > 0 || shadowAmbientLight()))
214
251
  })
215
252
 
216
253
  const shadowMode = computed(() => shadowState()?.mode ?? "strongest")
@@ -1,5 +1,11 @@
1
- <Container sound={backgroundMusic} shake={shakeConfig} freeze={engine.gamePause}>
2
- <Container sound={backgroundAmbientSound} />
1
+ <Container shake={shakeConfig} freeze={engine.gamePause}>
2
+ @if (backgroundMusic()) {
3
+ <Container sound={backgroundMusic()} />
4
+ }
5
+
6
+ @if (backgroundAmbientSound()) {
7
+ <Container sound={backgroundAmbientSound()} />
8
+ }
3
9
 
4
10
  <Container>
5
11
  @if (map() && sceneComponent()) {
@@ -42,10 +48,15 @@
42
48
  const projectiles = engine.projectiles.current
43
49
  const map = engine.sceneMap?.data
44
50
  const sceneComponent = computed(() => map()?.component)
45
- const mapParams = map()?.params
46
51
  const weather = engine.sceneMap.weather
47
- const backgroundMusic = { src: mapParams?.backgroundMusic, autoplay: true, loop: true }
48
- const backgroundAmbientSound = { src: mapParams?.backgroundAmbientSound, autoplay: true, loop: true }
52
+ const backgroundMusic = computed(() => {
53
+ const src = map()?.params?.backgroundMusic
54
+ return src ? { src, autoplay: true, loop: true } : undefined
55
+ })
56
+ const backgroundAmbientSound = computed(() => {
57
+ const src = map()?.params?.backgroundAmbientSound
58
+ return src ? { src, autoplay: true, loop: true } : undefined
59
+ })
49
60
 
50
61
  const shakeConfig = {
51
62
  trigger: engine.mapShakeTrigger,
@@ -1,4 +1,4 @@
1
- <Container sortableChildren={true}>
1
+ <Container sortableChildren={true} onBeforeDestroy={detachPixiChildren}>
2
2
  @for ((event,id) of events) {
3
3
  <Character id={id} object={event} />
4
4
  }
@@ -13,14 +13,66 @@
13
13
  </Container>
14
14
 
15
15
  <script>
16
+ import { effect, mount } from "canvasengine";
16
17
  import { inject } from "../../core/inject";
17
18
  import { RpgClientEngine } from "../../RpgClientEngine";
18
19
  import Character from "../character.ce";
19
20
  import LightHalo from "../prebuilt/light-halo.ce";
20
21
 
21
22
  const engine = inject(RpgClientEngine);
22
- const { children } = defineProps()
23
+ const { children, pixiChildren } = defineProps({
24
+ pixiChildren: {
25
+ default: []
26
+ }
27
+ })
28
+ const readValue = (value) => typeof value === "function" ? value() : value
29
+ let rootContainer = null
30
+ let mountedPixiChildren = []
23
31
 
24
32
  const players = engine.sceneMap.players
25
33
  const events = engine.sceneMap.events
34
+
35
+ const getPixiChildren = () => {
36
+ const value = readValue(pixiChildren)
37
+ return Array.isArray(value) ? value.filter(Boolean) : []
38
+ }
39
+
40
+ const syncPixiChildren = () => {
41
+ if (!rootContainer) return
42
+ const nextPixiChildren = getPixiChildren()
43
+ mountedPixiChildren.forEach((child) => {
44
+ if (!nextPixiChildren.includes(child) && child.parent === rootContainer) {
45
+ rootContainer.removeChild(child)
46
+ }
47
+ })
48
+ nextPixiChildren.forEach((child) => {
49
+ if (child.parent === rootContainer) return
50
+ if (child.parent) {
51
+ child.parent.removeChild(child)
52
+ }
53
+ rootContainer.addChild(child)
54
+ })
55
+ mountedPixiChildren = nextPixiChildren
56
+ }
57
+
58
+ const detachPixiChildren = () => {
59
+ if (!rootContainer) return
60
+ mountedPixiChildren.forEach((child) => {
61
+ if (child.parent === rootContainer) {
62
+ rootContainer.removeChild(child)
63
+ }
64
+ })
65
+ mountedPixiChildren = []
66
+ }
67
+
68
+ effect(() => {
69
+ readValue(pixiChildren)
70
+ syncPixiChildren()
71
+ })
72
+
73
+ mount((element) => {
74
+ rootContainer = element.componentInstance
75
+ syncPixiChildren()
76
+ return detachPixiChildren
77
+ })
26
78
  </script>
@@ -0,0 +1,39 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { Context, injector } from "@signe/di";
5
+ import { getOrCreateI18nService } from "@rpgjs/common";
6
+ import { provideClientModules } from "./module";
7
+ import { provideI18n } from "./i18n";
8
+
9
+ describe("client i18n", () => {
10
+ test("merges client module translations with game overrides", async () => {
11
+ const context = new Context();
12
+
13
+ await injector(context, [
14
+ provideClientModules([
15
+ {
16
+ i18n: {
17
+ fr: {
18
+ "menu.title": "Titre du module",
19
+ "menu.module-only": "Module",
20
+ },
21
+ },
22
+ },
23
+ ]),
24
+ provideI18n({
25
+ defaultLocale: "fr",
26
+ messages: {
27
+ fr: {
28
+ "menu.title": "Titre du jeu",
29
+ },
30
+ },
31
+ }),
32
+ ]);
33
+
34
+ const service = getOrCreateI18nService(context);
35
+
36
+ expect(service.t("menu.title", undefined, "fr")).toBe("Titre du jeu");
37
+ expect(service.t("menu.module-only", undefined, "fr")).toBe("Module");
38
+ });
39
+ });
package/src/i18n.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { createI18nProvider, type I18nConfig } from "@rpgjs/common";
2
+
3
+ export const RpgClientBuiltinI18n = {
4
+ en: {
5
+ "rpg.menu.title": "Menu",
6
+ "rpg.menu.status": "Status",
7
+ "rpg.menu.level": "Level",
8
+ "rpg.menu.gold": "Gold",
9
+ "rpg.menu.parameters": "Parameters",
10
+ "rpg.menu.items": "Items",
11
+ "rpg.menu.skills": "Skills",
12
+ "rpg.menu.equip": "Equip",
13
+ "rpg.menu.options": "Options",
14
+ "rpg.menu.save": "Save",
15
+ "rpg.menu.exit": "Exit",
16
+ "rpg.menu.weapons": "Weapons",
17
+ "rpg.menu.armor": "Armor",
18
+ "rpg.menu.use": "Use",
19
+ "rpg.menu.cancel": "Cancel",
20
+ "rpg.menu.unequip": "Unequip",
21
+ "rpg.menu.remove-equipment": "Remove the current equipment",
22
+ "rpg.menu.empty": "Empty",
23
+ "rpg.menu.leave-game": "Leave the game?",
24
+ "rpg.menu.exit-help": "Press Action to confirm or Escape to go back.",
25
+ "rpg.menu.options-help": "Configure your preferences here.",
26
+ "rpg.save.title": "Save Game",
27
+ "rpg.save.subtitle": "Choose a slot to overwrite or create.",
28
+ "rpg.load.title": "Load Game",
29
+ "rpg.load.subtitle": "Select a slot to load your progress.",
30
+ "rpg.save.auto": "Auto Save",
31
+ "rpg.save.slot": "Slot {index}",
32
+ "rpg.save.empty-slot": "Empty Slot",
33
+ "rpg.save.level": "Level",
34
+ "rpg.save.exp": "Exp",
35
+ "rpg.save.map": "Map",
36
+ "rpg.save.date": "Date",
37
+ "rpg.title.default": "RPG",
38
+ "rpg.title.start": "Start",
39
+ "rpg.title.load": "Load",
40
+ "rpg.gameover.title": "Game Over",
41
+ "rpg.gameover.title-screen": "Title Screen",
42
+ "rpg.gameover.load-game": "Load Game",
43
+ "rpg.shop.default-message": "Welcome to my shop!",
44
+ "rpg.shop.choose-action": "Choose an action",
45
+ "rpg.shop.buy": "Buy",
46
+ "rpg.shop.sell": "Sell",
47
+ "rpg.shop.back": "Back",
48
+ "rpg.shop.equipped": "Equipped",
49
+ "rpg.shop.already-equipped": "Already equipped",
50
+ "rpg.shop.qty": "Qty",
51
+ "rpg.shop.available": "Available",
52
+ "rpg.shop.quantity": "Quantity",
53
+ "rpg.shop.total": "Total",
54
+ },
55
+ };
56
+
57
+ export function provideI18n(config: I18nConfig = {}) {
58
+ return createI18nProvider(config);
59
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./core/inject";
8
8
  export * from "./services/loadMap";
9
9
  export * from "./services/actionInput";
10
10
  export * from "./services/pointerContext";
11
+ export * from "./services/interactions";
11
12
  export * from "./module";
12
13
  export * from "./Gui/Gui";
13
14
  export * from "./components/gui";
@@ -30,3 +31,4 @@ export * from "./Game/ProjectileManager";
30
31
  export * from "./Game/ClientVisuals";
31
32
  export { withMobile } from "./components/gui/mobile";
32
33
  export * from "./services/AbstractSocket";
34
+ export * from "./i18n";
package/src/module.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { findModules, provideModules } from "@rpgjs/common";
1
+ import { findModules, provideModules, registerI18nMessages } from "@rpgjs/common";
2
2
  import { FactoryProvider } from "@signe/di";
3
3
  import { RpgClientEngine } from "./RpgClientEngine";
4
4
  import { RpgClient } from "./RpgClient";
@@ -67,6 +67,9 @@ export function provideClientModules(modules: RpgClientModule[]): FactoryProvide
67
67
  if ('client' in module) {
68
68
  module = module.client as any;
69
69
  }
70
+ if (module.i18n) {
71
+ registerI18nMessages(context, module.i18n, "client-module", 10);
72
+ }
70
73
  if (module.spritesheets) {
71
74
  const spritesheets = [...module.spritesheets];
72
75
  module.spritesheets = {
@@ -176,6 +179,25 @@ export function provideClientModules(modules: RpgClientModule[]): FactoryProvide
176
179
  },
177
180
  };
178
181
  }
182
+ if (module.interactions) {
183
+ const interactions = module.interactions;
184
+ module.interactions = {
185
+ ...interactions,
186
+ load: (engine: RpgClientEngine) => {
187
+ if (typeof interactions === "function") {
188
+ interactions(engine);
189
+ return;
190
+ }
191
+ interactions.load?.(engine);
192
+ interactions.setup?.(engine);
193
+ if (Array.isArray(interactions.use)) {
194
+ interactions.use.forEach(([matcher, behavior]) => {
195
+ engine.interactions.use(matcher, behavior);
196
+ });
197
+ }
198
+ },
199
+ };
200
+ }
179
201
  if (module.transitions) {
180
202
  const transitions = [...module.transitions];
181
203
  module.transitions = {
@@ -243,15 +265,15 @@ export function provideGlobalConfig(config: any) {
243
265
  }
244
266
 
245
267
  export function provideClientGlobalConfig(config: any = {}) {
246
- if (!config.keyboardControls) {
247
- config.keyboardControls = {
248
- up: 'up',
249
- down: 'down',
250
- left: 'left',
251
- right: 'right',
252
- action: 'space',
253
- escape: 'escape'
254
- }
268
+ config.keyboardControls = {
269
+ up: 'up',
270
+ down: 'down',
271
+ left: 'left',
272
+ right: 'right',
273
+ action: 'space',
274
+ dash: 'shift',
275
+ escape: 'escape',
276
+ ...(config.keyboardControls ?? {}),
255
277
  }
256
278
  return provideGlobalConfig(config)
257
279
  }
@@ -0,0 +1,175 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import {
3
+ dragToTile,
4
+ hoverPopover,
5
+ RpgClientInteractions,
6
+ selectable,
7
+ } from "./interactions";
8
+ import { createClientPointerContext } from "./pointerContext";
9
+
10
+ function createClient() {
11
+ const client = {
12
+ pointer: createClientPointerContext(),
13
+ processAction: vi.fn(),
14
+ sceneMap: {
15
+ tileWidth: 16,
16
+ tileHeight: 16,
17
+ },
18
+ } as any;
19
+ client.interactions = new RpgClientInteractions(client);
20
+ return client;
21
+ }
22
+
23
+ describe("RpgClientInteractions", () => {
24
+ test("renders registered components with sprite state and bounds", () => {
25
+ const client = createClient();
26
+ const Popover = () => null;
27
+ const sprite = { id: "event-1", name: "Guard" };
28
+
29
+ client.interactions.use("Guard", hoverPopover(Popover, { label: "Talk" }));
30
+ client.interactions.handle(sprite, "pointerover", {
31
+ bounds: {
32
+ graphic: { left: 1, top: 2, right: 11, bottom: 22, width: 10, height: 20, centerX: 6, centerY: 12 } as any,
33
+ },
34
+ });
35
+
36
+ const entries = client.interactions.getRenderedComponents(sprite, {
37
+ graphic: { left: 1, top: 2, right: 11, bottom: 22, width: 10, height: 20, centerX: 6, centerY: 12 } as any,
38
+ });
39
+
40
+ expect(entries).toHaveLength(1);
41
+ expect(entries[0].component).toBe(Popover);
42
+ expect(entries[0].props.label).toBe("Talk");
43
+ expect(entries[0].props.state.hovered).toBe(true);
44
+ expect(entries[0].props.bounds.centerX).toBe(6);
45
+ });
46
+
47
+ test("keeps clicks client-only unless a behavior sends an action", () => {
48
+ const client = createClient();
49
+ const sprite = { id: "event-1", name: "Guard" };
50
+
51
+ client.interactions.use("Guard", selectable());
52
+ client.interactions.handle(sprite, "click");
53
+
54
+ expect(client.interactions.getState(sprite).selected).toBe(true);
55
+ expect(client.processAction).not.toHaveBeenCalled();
56
+
57
+ client.interactions.use("Guard", {
58
+ click(ctx) {
59
+ ctx.action("guard:talk", { eventId: ctx.target.id });
60
+ },
61
+ });
62
+ client.interactions.handle(sprite, "click");
63
+
64
+ expect(client.processAction).toHaveBeenCalledWith("guard:talk", { eventId: "event-1" });
65
+ });
66
+
67
+ test("uses behavior hit tests before changing hover state", () => {
68
+ const client = createClient();
69
+ const sprite = { id: "event-1", name: "Tree" };
70
+
71
+ client.pointer.update({ x: 0, y: 0 }, { x: 40, y: 40 });
72
+ client.interactions.use("Tree", {
73
+ cursor: "pointer",
74
+ hitTest(ctx) {
75
+ return ctx.bounds("hitbox").contains(ctx.pointer.world());
76
+ },
77
+ });
78
+
79
+ client.interactions.handle(sprite, "pointerover", {
80
+ bounds: {
81
+ hitbox: { left: 0, top: 0, right: 16, bottom: 16, width: 16, height: 16, centerX: 8, centerY: 8 } as any,
82
+ },
83
+ });
84
+
85
+ expect(client.interactions.getState(sprite).hovered).toBe(false);
86
+ expect(client.interactions.cursorFor(sprite, {
87
+ hitbox: { left: 0, top: 0, right: 16, bottom: 16, width: 16, height: 16, centerX: 8, centerY: 8 } as any,
88
+ })).toBeUndefined();
89
+ });
90
+
91
+ test("exposes handler bounds in world coordinates", () => {
92
+ const client = createClient();
93
+ const sprite = { id: "crate-1", name: "Crate", x: () => 100, y: () => 80 };
94
+
95
+ client.pointer.update({ x: 0, y: 0 }, { x: 112, y: 92 });
96
+ client.interactions.use("Crate", {
97
+ component: () => null,
98
+ cursor: "grab",
99
+ hitTest(ctx) {
100
+ return ctx.bounds("hitbox").contains(ctx.pointer.world());
101
+ },
102
+ });
103
+
104
+ client.interactions.handle(sprite, "pointerover", {
105
+ bounds: {
106
+ hitbox: { left: 0, top: 0, right: 16, bottom: 16, width: 16, height: 16, centerX: 8, centerY: 8 } as any,
107
+ },
108
+ });
109
+
110
+ expect(client.interactions.getState(sprite).hovered).toBe(true);
111
+ expect(client.interactions.cursorFor(sprite, {
112
+ hitbox: { left: 0, top: 0, right: 16, bottom: 16, width: 16, height: 16, centerX: 8, centerY: 8 } as any,
113
+ })).toBe("grab");
114
+
115
+ const [entry] = client.interactions.getRenderedComponents(sprite, {
116
+ hitbox: { left: 0, top: 0, right: 16, bottom: 16, width: 16, height: 16, centerX: 8, centerY: 8 } as any,
117
+ });
118
+ expect(entry?.props.bounds.centerX).toBe(8);
119
+ });
120
+
121
+ test("updates hit-tested hover while moving inside an already hovered sprite", () => {
122
+ const client = createClient();
123
+ const sprite = { id: "crate-1", name: "Crate", x: () => 100, y: () => 80 };
124
+
125
+ client.interactions.use("Crate", {
126
+ hitTest(ctx) {
127
+ return ctx.bounds("hitbox").contains(ctx.pointer.world());
128
+ },
129
+ });
130
+
131
+ client.pointer.update({ x: 0, y: 0 }, { x: 140, y: 120 });
132
+ client.interactions.handle(sprite, "pointerover", {
133
+ bounds: {
134
+ hitbox: { left: 0, top: 0, right: 16, bottom: 16, width: 16, height: 16, centerX: 8, centerY: 8 } as any,
135
+ },
136
+ });
137
+ expect(client.interactions.getState(sprite).hovered).toBe(false);
138
+
139
+ client.pointer.update({ x: 0, y: 0 }, { x: 112, y: 92 });
140
+ client.interactions.handle(sprite, "pointermove", {
141
+ bounds: {
142
+ hitbox: { left: 0, top: 0, right: 16, bottom: 16, width: 16, height: 16, centerX: 8, centerY: 8 } as any,
143
+ },
144
+ });
145
+
146
+ expect(client.interactions.getState(sprite).hovered).toBe(true);
147
+ });
148
+
149
+ test("runs drag lifecycle and resolves pointer tile on drop", () => {
150
+ const client = createClient();
151
+ const sprite = { id: "crate-1", name: "Crate" };
152
+
153
+ client.interactions.use("Crate", dragToTile({ action: "crate:move" }));
154
+ client.pointer.update({ x: 0, y: 0 }, { x: 18, y: 35 });
155
+ client.interactions.handle(sprite, "pointerdown");
156
+
157
+ expect(client.interactions.getState(sprite).dragging).toBe(true);
158
+
159
+ client.pointer.update({ x: 0, y: 0 }, { x: 33, y: 47 });
160
+ client.interactions.handlePointerUp();
161
+
162
+ expect(client.interactions.getState(sprite).dragging).toBe(false);
163
+ expect(client.processAction).toHaveBeenCalledWith("crate:move", {
164
+ eventId: "crate-1",
165
+ position: {
166
+ x: 2,
167
+ y: 2,
168
+ worldX: 32,
169
+ worldY: 32,
170
+ width: 16,
171
+ height: 16,
172
+ },
173
+ });
174
+ });
175
+ });