@rpgjs/client 5.0.0-alpha.2 → 5.0.0-alpha.20

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 (163) hide show
  1. package/dist/Game/AnimationManager.d.ts +8 -0
  2. package/dist/Game/Map.d.ts +7 -1
  3. package/dist/Gui/Gui.d.ts +128 -5
  4. package/dist/RpgClient.d.ts +217 -59
  5. package/dist/RpgClientEngine.d.ts +345 -6
  6. package/dist/Sound.d.ts +199 -0
  7. package/dist/components/animations/index.d.ts +4 -0
  8. package/dist/components/dynamics/parse-value.d.ts +1 -0
  9. package/dist/components/gui/index.d.ts +3 -3
  10. package/dist/components/index.d.ts +3 -1
  11. package/dist/components/prebuilt/index.d.ts +18 -0
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.js +9 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/index10.js +149 -4
  16. package/dist/index10.js.map +1 -1
  17. package/dist/index11.js +21 -7
  18. package/dist/index11.js.map +1 -1
  19. package/dist/index12.js +6 -4
  20. package/dist/index12.js.map +1 -1
  21. package/dist/index13.js +11 -14
  22. package/dist/index13.js.map +1 -1
  23. package/dist/index14.js +8 -40
  24. package/dist/index14.js.map +1 -1
  25. package/dist/index15.js +187 -180
  26. package/dist/index15.js.map +1 -1
  27. package/dist/index16.js +104 -7
  28. package/dist/index16.js.map +1 -1
  29. package/dist/index17.js +82 -372
  30. package/dist/index17.js.map +1 -1
  31. package/dist/index18.js +361 -26
  32. package/dist/index18.js.map +1 -1
  33. package/dist/index19.js +46 -20
  34. package/dist/index19.js.map +1 -1
  35. package/dist/index2.js +683 -32
  36. package/dist/index2.js.map +1 -1
  37. package/dist/index20.js +5 -2417
  38. package/dist/index20.js.map +1 -1
  39. package/dist/index21.js +383 -97
  40. package/dist/index21.js.map +1 -1
  41. package/dist/index22.js +41 -104
  42. package/dist/index22.js.map +1 -1
  43. package/dist/index23.js +21 -67
  44. package/dist/index23.js.map +1 -1
  45. package/dist/index24.js +2632 -20
  46. package/dist/index24.js.map +1 -1
  47. package/dist/index25.js +107 -34
  48. package/dist/index25.js.map +1 -1
  49. package/dist/index26.js +69 -3
  50. package/dist/index26.js.map +1 -1
  51. package/dist/index27.js +17 -318
  52. package/dist/index27.js.map +1 -1
  53. package/dist/index28.js +24 -22
  54. package/dist/index28.js.map +1 -1
  55. package/dist/index29.js +92 -8
  56. package/dist/index29.js.map +1 -1
  57. package/dist/index3.js +68 -8
  58. package/dist/index3.js.map +1 -1
  59. package/dist/index30.js +37 -7
  60. package/dist/index30.js.map +1 -1
  61. package/dist/index31.js +18 -168
  62. package/dist/index31.js.map +1 -1
  63. package/dist/index32.js +3 -499
  64. package/dist/index32.js.map +1 -1
  65. package/dist/index33.js +332 -9
  66. package/dist/index33.js.map +1 -1
  67. package/dist/index34.js +24 -4400
  68. package/dist/index34.js.map +1 -1
  69. package/dist/index35.js +6 -311
  70. package/dist/index35.js.map +1 -1
  71. package/dist/index36.js +8 -88
  72. package/dist/index36.js.map +1 -1
  73. package/dist/index37.js +182 -56
  74. package/dist/index37.js.map +1 -1
  75. package/dist/index38.js +500 -16
  76. package/dist/index38.js.map +1 -1
  77. package/dist/index39.js +10 -18
  78. package/dist/index39.js.map +1 -1
  79. package/dist/index4.js +23 -5
  80. package/dist/index4.js.map +1 -1
  81. package/dist/index40.js +7 -0
  82. package/dist/index40.js.map +1 -0
  83. package/dist/index41.js +3690 -0
  84. package/dist/index41.js.map +1 -0
  85. package/dist/index42.js +77 -0
  86. package/dist/index42.js.map +1 -0
  87. package/dist/index43.js +6 -0
  88. package/dist/index43.js.map +1 -0
  89. package/dist/index44.js +20 -0
  90. package/dist/index44.js.map +1 -0
  91. package/dist/index45.js +146 -0
  92. package/dist/index45.js.map +1 -0
  93. package/dist/index46.js +12 -0
  94. package/dist/index46.js.map +1 -0
  95. package/dist/index47.js +113 -0
  96. package/dist/index47.js.map +1 -0
  97. package/dist/index48.js +136 -0
  98. package/dist/index48.js.map +1 -0
  99. package/dist/index49.js +137 -0
  100. package/dist/index49.js.map +1 -0
  101. package/dist/index5.js +2 -1
  102. package/dist/index5.js.map +1 -1
  103. package/dist/index50.js +112 -0
  104. package/dist/index50.js.map +1 -0
  105. package/dist/index51.js +141 -0
  106. package/dist/index51.js.map +1 -0
  107. package/dist/index52.js +9 -0
  108. package/dist/index52.js.map +1 -0
  109. package/dist/index53.js +54 -0
  110. package/dist/index53.js.map +1 -0
  111. package/dist/index6.js +1 -1
  112. package/dist/index6.js.map +1 -1
  113. package/dist/index7.js +11 -3
  114. package/dist/index7.js.map +1 -1
  115. package/dist/index8.js +68 -7
  116. package/dist/index8.js.map +1 -1
  117. package/dist/index9.js +230 -15
  118. package/dist/index9.js.map +1 -1
  119. package/dist/presets/animation.d.ts +31 -0
  120. package/dist/presets/faceset.d.ts +30 -0
  121. package/dist/presets/index.d.ts +103 -0
  122. package/dist/presets/lpc.d.ts +89 -0
  123. package/dist/services/loadMap.d.ts +123 -2
  124. package/dist/services/mmorpg.d.ts +9 -4
  125. package/dist/services/standalone.d.ts +51 -2
  126. package/package.json +22 -18
  127. package/src/Game/{EffectManager.ts → AnimationManager.ts} +3 -2
  128. package/src/Game/Map.ts +20 -2
  129. package/src/Game/Object.ts +163 -9
  130. package/src/Gui/Gui.ts +300 -17
  131. package/src/RpgClient.ts +222 -58
  132. package/src/RpgClientEngine.ts +804 -36
  133. package/src/Sound.ts +253 -0
  134. package/src/components/{effects → animations}/animation.ce +3 -6
  135. package/src/components/{effects → animations}/index.ts +1 -1
  136. package/src/components/character.ce +165 -37
  137. package/src/components/dynamics/parse-value.ts +80 -0
  138. package/src/components/dynamics/text.ce +183 -0
  139. package/src/components/gui/box.ce +17 -0
  140. package/src/components/gui/dialogbox/index.ce +73 -35
  141. package/src/components/gui/dialogbox/selection.ce +16 -1
  142. package/src/components/gui/index.ts +3 -4
  143. package/src/components/index.ts +5 -1
  144. package/src/components/prebuilt/hp-bar.ce +255 -0
  145. package/src/components/prebuilt/index.ts +21 -0
  146. package/src/components/scenes/draw-map.ce +6 -23
  147. package/src/components/scenes/event-layer.ce +9 -3
  148. package/src/core/setup.ts +2 -0
  149. package/src/index.ts +5 -2
  150. package/src/module.ts +72 -6
  151. package/src/presets/animation.ts +46 -0
  152. package/src/presets/faceset.ts +60 -0
  153. package/src/presets/index.ts +7 -1
  154. package/src/presets/lpc.ts +108 -0
  155. package/src/services/loadMap.ts +132 -3
  156. package/src/services/mmorpg.ts +27 -5
  157. package/src/services/standalone.ts +68 -6
  158. package/tsconfig.json +1 -1
  159. package/vite.config.ts +1 -1
  160. package/dist/Game/EffectManager.d.ts +0 -5
  161. package/dist/components/effects/index.d.ts +0 -4
  162. package/src/components/scenes/element-map.ce +0 -23
  163. /package/src/components/{effects → animations}/hit.ce +0 -0
package/src/Sound.ts ADDED
@@ -0,0 +1,253 @@
1
+ import { Howler } from 'canvasengine';
2
+ import { RpgClientEngine } from './RpgClientEngine';
3
+ import { inject } from './core/inject';
4
+
5
+ /**
6
+ * Sound decorator options
7
+ *
8
+ * Defines the configuration for a sound that can be played in the game.
9
+ * The sound can be a single file or multiple files (for different formats).
10
+ *
11
+ * @interface SoundOptions
12
+ */
13
+ export interface SoundOptions {
14
+ /**
15
+ * Sound identifier. Used to retrieve the sound later with RpgSound.get()
16
+ *
17
+ * @type {string}
18
+ */
19
+ id?: string;
20
+
21
+ /**
22
+ * Single sound file path. Use require() to wrap the path.
23
+ *
24
+ * @type {string}
25
+ * @example
26
+ * sound: require('./assets/sound.ogg')
27
+ */
28
+ sound?: string;
29
+
30
+ /**
31
+ * Multiple sounds with different IDs. The key is the sound ID and the value is the file path.
32
+ * Use require() to wrap each path.
33
+ *
34
+ * @type {{ [id: string]: string }}
35
+ * @example
36
+ * sounds: {
37
+ * hero: require('./assets/hero.ogg'),
38
+ * monster: require('./assets/monster.ogg')
39
+ * }
40
+ */
41
+ sounds?: { [id: string]: string };
42
+
43
+ /**
44
+ * Whether the sound should loop when it finishes playing.
45
+ *
46
+ * @type {boolean}
47
+ * @default false
48
+ */
49
+ loop?: boolean;
50
+
51
+ /**
52
+ * Volume level (0.0 to 1.0).
53
+ *
54
+ * @type {number}
55
+ * @default 1.0
56
+ */
57
+ volume?: number;
58
+ }
59
+
60
+ /**
61
+ * Metadata stored on the class decorated with @Sound
62
+ *
63
+ * @interface SoundMetadata
64
+ */
65
+ interface SoundMetadata {
66
+ id?: string;
67
+ sound?: string;
68
+ sounds?: { [id: string]: string };
69
+ loop?: boolean;
70
+ volume?: number;
71
+ }
72
+
73
+ const SOUND_METADATA_KEY = Symbol('rpgjs:sound');
74
+
75
+ /**
76
+ * Sound decorator
77
+ *
78
+ * Decorates a class to define a sound configuration. The decorated class can be
79
+ * added to the RpgClient module configuration, and the sound will be automatically
80
+ * registered and available through RpgSound.get().
81
+ *
82
+ * ## Design
83
+ *
84
+ * The decorator stores metadata on the class that is later used by the module loader
85
+ * to register sounds with the engine. The sound is created using Howler.js for
86
+ * advanced audio features like looping, volume control, and cross-browser compatibility.
87
+ *
88
+ * @param options - Sound configuration options
89
+ *
90
+ * @example
91
+ * ```ts
92
+ * import { Sound } from '@rpgjs/client'
93
+ *
94
+ * @Sound({
95
+ * id: 'town-music',
96
+ * sound: require('./sound/town.ogg'),
97
+ * loop: true,
98
+ * volume: 0.5
99
+ * })
100
+ * export class TownMusic {}
101
+ *
102
+ * // Multiple sounds in one class
103
+ * @Sound({
104
+ * sounds: {
105
+ * hero: require('./assets/hero.ogg'),
106
+ * monster: require('./assets/monster.ogg')
107
+ * },
108
+ * loop: true
109
+ * })
110
+ * export class CharacterSounds {}
111
+ * ```
112
+ */
113
+ export function Sound(options: SoundOptions) {
114
+ return function <T extends { new (...args: any[]): {} }>(constructor: T) {
115
+ const metadata: SoundMetadata = {
116
+ id: options.id,
117
+ sound: options.sound,
118
+ sounds: options.sounds,
119
+ loop: options.loop,
120
+ volume: options.volume,
121
+ };
122
+
123
+ // Store metadata on the class
124
+ (constructor as any)[SOUND_METADATA_KEY] = metadata;
125
+
126
+ return constructor;
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Get sound metadata from a decorated class
132
+ *
133
+ * @param soundClass - The class decorated with @Sound
134
+ * @returns The sound metadata or undefined
135
+ */
136
+ export function getSoundMetadata(soundClass: any): SoundMetadata | undefined {
137
+ return (soundClass as any)[SOUND_METADATA_KEY];
138
+ }
139
+
140
+ /**
141
+ * RpgSound class
142
+ *
143
+ * Provides a unified API to manage sounds in the game. Uses Howler.js internally
144
+ * for advanced audio features. Sounds can be retrieved by ID and controlled
145
+ * using Howler.js methods.
146
+ *
147
+ * ## Design
148
+ *
149
+ * RpgSound acts as a facade over Howler.js, providing easy access to sounds
150
+ * registered in the engine. It supports both individual sound control and
151
+ * global sound management (volume, mute, etc.).
152
+ *
153
+ * @example
154
+ * ```ts
155
+ * import { RpgSound } from '@rpgjs/client'
156
+ *
157
+ * // Play a sound
158
+ * RpgSound.get('town-music').play()
159
+ *
160
+ * // Control volume
161
+ * RpgSound.get('town-music').volume(0.5)
162
+ *
163
+ * // Stop a sound
164
+ * RpgSound.get('town-music').stop()
165
+ *
166
+ * // Global volume control
167
+ * RpgSound.global.volume(0.2)
168
+ * ```
169
+ */
170
+ export class RpgSound {
171
+ private static engine: RpgClientEngine | null = null;
172
+
173
+ /**
174
+ * Initialize RpgSound with the engine instance
175
+ *
176
+ * This is called automatically by the engine during initialization.
177
+ *
178
+ * @param engine - The RpgClientEngine instance
179
+ */
180
+ static init(engine: RpgClientEngine): void {
181
+ RpgSound.engine = engine;
182
+ }
183
+
184
+ /**
185
+ * Get a sound by its ID
186
+ *
187
+ * Retrieves a Howler sound instance from the engine's sound cache.
188
+ * The sound must be registered beforehand (via @Sound decorator or manually).
189
+ *
190
+ * @param id - The sound identifier
191
+ * @returns The Howler sound instance, or undefined if not found
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * // Get and play a sound
196
+ * const sound = RpgSound.get('town-music');
197
+ * if (sound) {
198
+ * sound.play();
199
+ * }
200
+ *
201
+ * // Chain methods
202
+ * RpgSound.get('battle-theme')?.volume(0.8).play();
203
+ * ```
204
+ */
205
+ static get(id: string): any {
206
+ if (!RpgSound.engine) {
207
+ console.warn('RpgSound not initialized. Make sure the engine has started.');
208
+ return undefined;
209
+ }
210
+
211
+ const sound = RpgSound.engine.sounds.get(id);
212
+ if (!sound) {
213
+ console.warn(`Sound with id "${id}" not found`);
214
+ return undefined;
215
+ }
216
+
217
+ // If the sound is a Howler instance, return it directly
218
+ if (sound && typeof sound.play === 'function') {
219
+ return sound;
220
+ }
221
+
222
+ // If the sound has a src property, try to create a Howler instance
223
+ if (sound && sound.src) {
224
+ // This should have been handled during addSound, but just in case
225
+ return sound;
226
+ }
227
+
228
+ return sound;
229
+ }
230
+
231
+ /**
232
+ * Global Howler instance for managing all sounds
233
+ *
234
+ * Provides access to Howler.js global methods for controlling all sounds
235
+ * at once (volume, mute, etc.).
236
+ *
237
+ * @example
238
+ * ```ts
239
+ * // Set global volume to 20%
240
+ * RpgSound.global.volume(0.2)
241
+ *
242
+ * // Mute all sounds
243
+ * RpgSound.global.mute(true)
244
+ *
245
+ * // Unmute all sounds
246
+ * RpgSound.global.mute(false)
247
+ * ```
248
+ */
249
+ static get global(): typeof Howler {
250
+ return Howler;
251
+ }
252
+ }
253
+
@@ -1,19 +1,16 @@
1
1
  <Sprite sheet x y anchor={0.5} />
2
2
 
3
3
  <script>
4
- import { signal } from "canvasengine";
5
4
  import { RpgClientEngine } from "../../RpgClientEngine";
6
5
  import { inject } from "../../core/inject";
7
6
 
8
- const { x, y, name, onFinish } = defineProps();
7
+ const { x, y, animationName, graphic, onFinish } = defineProps();
9
8
 
10
9
  const client = inject(RpgClientEngine);
11
- const spritesheets = client.spritesheets;
12
10
 
13
11
  const sheet = {
14
- definition: spritesheets.get(name()),
15
- playing: 'default',
12
+ definition: client.getSpriteSheet(graphic()),
13
+ playing: animationName() ?? 'default',
16
14
  onFinish
17
15
  };
18
-
19
16
  </script>
@@ -1,7 +1,7 @@
1
1
  import Hit from "./hit.ce";
2
2
  import Animation from "./animation.ce";
3
3
 
4
- export const PrebuiltEffects = {
4
+ export const PrebuiltComponentAnimations = {
5
5
  Hit,
6
6
  Animation
7
7
  }
@@ -1,55 +1,71 @@
1
- <Container x y zIndex={y} viewportFollow={isMe} controls>
1
+ <Container x={smoothX} y={smoothY} zIndex={y} viewportFollow={isMe} controls onBeforeDestroy visible>
2
+ @for (component of componentsBehind) {
3
+ <Container>
4
+ <component object />
5
+ </Container>
6
+ }
2
7
  <Particle emit={@emitParticleTrigger} settings={@particleSettings} zIndex={1000} name={particleName} />
3
- @for (graphicId of graphics) {
4
- <Sprite sheet={@sheet(@graphicId)} direction tint />
5
- }
6
- <!-- <Ellipse
7
- x={shadow.@x}
8
- y={shadow.@y}
9
- width={shadow.@width}
10
- height={shadow.@height}
11
- color="black"
12
- blur={10}
13
- alpha={0.5}
14
- /> -->
8
+ <Container>
9
+ @for (graphicObj of graphicsSignals) {
10
+ <Sprite sheet={@sheet(@graphicObj)} direction tint hitbox />
11
+ }
12
+ </Container>
13
+ @for (component of componentsInFront) {
14
+ <Container>
15
+ <component object />
16
+ </Container>
17
+ }
15
18
  </Container>
16
19
 
17
20
  <script>
18
- import { signal, effect, mount, computed } from "canvasengine";
21
+ import { signal, effect, mount, computed, tick, animatedSignal } from "canvasengine";
22
+ import { lastValueFrom, combineLatest, pairwise, filter, map, startWith } from "rxjs";
19
23
  import { Particle } from "@canvasengine/presets";
20
- import { GameEngineToken } from "@rpgjs/common";
24
+ import { GameEngineToken, ModulesToken } from "@rpgjs/common";
21
25
  import { RpgClientEngine } from "../RpgClientEngine";
22
26
  import { inject } from "../core/inject";
23
27
  import { Direction } from "@rpgjs/common";
24
28
  import Hit from "./effects/hit.ce";
29
+ import PlayerComponents from "./player-components.ce";
25
30
 
26
- const { object, id, isMe } = defineProps();
31
+ const { object, id } = defineProps();
27
32
 
28
33
  const client = inject(RpgClientEngine);
34
+ const hooks = inject(ModulesToken);
29
35
 
30
36
  const spritesheets = client.spritesheets;
37
+ const playerId = client.playerId;
38
+ const componentsBehind = client.spriteComponentsBehind;
39
+ const componentsInFront = client.spriteComponentsInFront;
40
+ const isMe = computed(() => id() === playerId);
41
+
42
+ const {
43
+ x,
44
+ y,
45
+ tint,
46
+ direction,
47
+ animationName,
48
+ animationCurrentIndex,
49
+ emitParticleTrigger,
50
+ particleName,
51
+ graphics,
52
+ hitbox,
53
+ isConnected,
54
+ graphicsSignals
55
+ } = object;
31
56
 
32
- const x = object.x;
33
- const y = object.y;
34
- const tint = object.tint;
35
- const direction = object.direction;
36
- const animationName = object.animationName;
37
- const emitParticleTrigger = object.emitParticleTrigger;
38
57
  const particleSettings = client.particleSettings;
39
- const particleName = object.particleName;
40
- const graphics = object.graphics;
41
-
42
- const hitbox = object.hitbox;
43
- const widthShadow = 10;
44
- const shadow = computed(() => ({
45
- x: hitbox().w / 2,
46
- y: hitbox().h - (hitbox().h / 2),
47
- width: hitbox().w + widthShadow,
48
- height: hitbox().h,
49
- }))
58
+
50
59
  const canControls = () => isMe() && object.canMove()
51
60
  const keyboardControls = client.globalConfig.keyboardControls;
52
61
 
62
+ const visible = computed(() => {
63
+ if (object.type === 'event') {
64
+ return true
65
+ }
66
+ return isConnected()
67
+ });
68
+
53
69
  const controls = signal({
54
70
  down: {
55
71
  repeat: true,
@@ -84,7 +100,7 @@
84
100
  keyDown() {
85
101
  if (canControls()) {
86
102
  client.processAction({ action: 'action' })
87
- // particleName.set('hit')
103
+ // particleName.set('hit')
88
104
  // emitParticleTrigger.start()
89
105
  // object.flash('red')
90
106
  }
@@ -92,13 +108,125 @@
92
108
  },
93
109
  });
94
110
 
95
- const sheet = (graphicId) => {
111
+ const smoothX = animatedSignal(x(), {
112
+ duration: isMe() ? 0 : 0
113
+ });
114
+
115
+ const smoothY = animatedSignal(y(), {
116
+ duration: isMe() ? 0 : 0,
117
+ });
118
+
119
+ const realAnimationName = signal(animationName());
120
+
121
+ const xSubscription = x.observable.subscribe((value) => {
122
+ smoothX.set(value);
123
+ });
124
+
125
+ const ySubscription = y.observable.subscribe((value) => {
126
+ smoothY.set(value);
127
+ });
128
+
129
+ const sheet = (graphicObject) => {
96
130
  return {
97
- definition: spritesheets.get(graphicId),
98
- playing: animationName,
131
+ definition: graphicObject,
132
+ playing: realAnimationName,
99
133
  params: {
100
134
  direction
101
135
  },
136
+ onFinish() {
137
+ animationCurrentIndex.update(index => index + 1)
138
+ }
102
139
  };
103
140
  }
141
+
142
+ // Combine animation change detection with movement state from smoothX/smoothY
143
+ const movementAnimations = ['walk', 'stand'];
144
+ const epsilon = 0; // movement threshold to consider the easing still running
145
+
146
+ const stateX$ = smoothX.animatedState.observable;
147
+ const stateY$ = smoothY.animatedState.observable;
148
+ const animationName$ = animationName.observable;
149
+
150
+ const moving$ = combineLatest([stateX$, stateY$]).pipe(
151
+ map(([sx, sy]) => {
152
+ const xFinished = Math.abs(sx.value.current - sx.value.end) <= epsilon;
153
+ const yFinished = Math.abs(sy.value.current - sy.value.end) <= epsilon;
154
+ return !xFinished || !yFinished; // moving if X or Y is not finished
155
+ }),
156
+ startWith(false)
157
+ );
158
+
159
+ const animationChange$ = animationName$.pipe(
160
+ startWith(animationName()),
161
+ pairwise(),
162
+ filter(([prev, curr]) => prev !== curr)
163
+ );
164
+
165
+ const animationMovementSubscription = combineLatest([animationChange$, moving$]).subscribe(([[prev, curr], isMoving]) => {
166
+ if (curr == 'stand' && !isMoving) {
167
+ realAnimationName.set(curr);
168
+ }
169
+ else if (curr == 'walk' && isMoving) {
170
+ realAnimationName.set(curr);
171
+ }
172
+ else if (!movementAnimations.includes(curr)) {
173
+ realAnimationName.set(curr);
174
+ }
175
+ if (!isMoving && object.animationIsPlaying && object.animationIsPlaying()) {
176
+ if (movementAnimations.includes(curr)) {
177
+ if (typeof object.resetAnimationState === 'function') {
178
+ object.resetAnimationState();
179
+ }
180
+ }
181
+ }
182
+ });
183
+
184
+ /**
185
+ * Cleanup subscriptions and call hooks before sprite destruction.
186
+ *
187
+ * # Design
188
+ * - Prevent memory leaks by unsubscribing from all local subscriptions created in this component.
189
+ * - Execute destruction hooks to notify modules and scene map of sprite removal.
190
+ *
191
+ * @example
192
+ * await onBeforeDestroy();
193
+ */
194
+ const onBeforeDestroy = async () => {
195
+ animationMovementSubscription.unsubscribe();
196
+ xSubscription.unsubscribe();
197
+ ySubscription.unsubscribe();
198
+ await lastValueFrom(hooks.callHooks("client-sprite-onDestroy", object))
199
+ await lastValueFrom(hooks.callHooks("client-sceneMap-onRemoveSprite", client.sceneMap, object))
200
+ }
201
+
202
+ mount((element) => {
203
+ hooks.callHooks("client-sprite-onAdd", object).subscribe()
204
+ hooks.callHooks("client-sceneMap-onAddSprite", client.sceneMap, object).subscribe()
205
+ })
206
+
207
+ /**
208
+ * Calculate Y position for components above player
209
+ */
210
+ const getComponentsTopY = () => {
211
+ const layout = componentsTopData()?.layout || {};
212
+ const hitboxValue = typeof hitbox === 'function' ? hitbox() : hitbox;
213
+ const playerHeight = hitboxValue?.h || 32;
214
+ const marginBottom = layout.marginBottom ?? 0;
215
+ return -(playerHeight / 2) - marginBottom;
216
+ };
217
+
218
+ /**
219
+ * Calculate Y position for components below player
220
+ */
221
+ const getComponentsBottomY = () => {
222
+ const layout = componentsBottomData()?.layout || {};
223
+ const hitboxValue = typeof hitbox === 'function' ? hitbox() : hitbox;
224
+ const playerHeight = hitboxValue?.h || 32;
225
+ const marginTop = layout.marginTop ?? 0;
226
+ return (playerHeight / 2) + marginTop;
227
+ };
228
+
229
+ tick(() => {
230
+ hooks.callHooks("client-sprite-onUpdate").subscribe()
231
+ })
104
232
  </script>
@@ -0,0 +1,80 @@
1
+ import { computed } from "canvasengine";
2
+
3
+ interface MatchResult {
4
+ property: string;
5
+ fullMatch: string;
6
+ index: number;
7
+ }
8
+
9
+ export const parseDynamicValue = (value: any, object?: any) => {
10
+ if (typeof value !== 'string') {
11
+ return computed(() => String(value ?? ''));
12
+ }
13
+
14
+ // Find all dynamic references like {propertyName}
15
+ const pattern = /\{([^}]+)\}/g;
16
+ const matches: MatchResult[] = [];
17
+ let match;
18
+
19
+ while ((match = pattern.exec(value)) !== null) {
20
+ matches.push({
21
+ property: match[1],
22
+ fullMatch: match[0],
23
+ index: match.index!
24
+ });
25
+ }
26
+
27
+ // If no dynamic references found, return simple computed
28
+ if (matches.length === 0) {
29
+ return computed(() => value);
30
+ }
31
+
32
+ // Create computed that tracks all referenced signals
33
+ return computed(() => {
34
+ let result = value;
35
+
36
+ // Replace from end to start to preserve indices
37
+ for (let i = matches.length - 1; i >= 0; i--) {
38
+ const { property, fullMatch } = matches[i];
39
+
40
+ // Try to access the property from the object
41
+ // Support nested properties like {param.maxHp}
42
+ let propertyValue = '';
43
+ try {
44
+ const propertyPath = property.split('.');
45
+ let currentValue = object;
46
+
47
+ for (let j = 0; j < propertyPath.length; j++) {
48
+ const prop = propertyPath[j];
49
+
50
+ // Check if currentValue is a signal (function) and call it
51
+ if (typeof currentValue === 'function') {
52
+ currentValue = currentValue();
53
+ }
54
+
55
+ // Access the property
56
+ if (currentValue && typeof currentValue === 'object' && prop in currentValue) {
57
+ currentValue = currentValue[prop];
58
+ } else {
59
+ currentValue = undefined;
60
+ break;
61
+ }
62
+ }
63
+
64
+ // If the final value is a signal, call it
65
+ if (typeof currentValue === 'function') {
66
+ currentValue = currentValue();
67
+ }
68
+
69
+ propertyValue = currentValue != null ? String(currentValue) : '';
70
+ } catch (error) {
71
+ // If property doesn't exist or can't be accessed, use empty string
72
+ propertyValue = '';
73
+ }
74
+
75
+ result = result.replace(fullMatch, propertyValue);
76
+ }
77
+
78
+ return result;
79
+ });
80
+ };