@rpgjs/client 5.0.0-beta.7 → 5.0.0-beta.8

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 (73) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/Game/Map.js +2 -1
  3. package/dist/Game/Map.js.map +1 -1
  4. package/dist/Game/Object.d.ts +17 -9
  5. package/dist/Game/Object.js +1 -12
  6. package/dist/Game/Object.js.map +1 -1
  7. package/dist/Gui/Gui.d.ts +17 -4
  8. package/dist/Gui/Gui.js +63 -33
  9. package/dist/Gui/Gui.js.map +1 -1
  10. package/dist/Gui/Gui.spec.d.ts +1 -0
  11. package/dist/Resource.js +1 -1
  12. package/dist/Resource.js.map +1 -1
  13. package/dist/RpgClient.d.ts +35 -2
  14. package/dist/RpgClientEngine.d.ts +41 -5
  15. package/dist/RpgClientEngine.js +43 -3
  16. package/dist/RpgClientEngine.js.map +1 -1
  17. package/dist/components/character.ce.js +225 -2
  18. package/dist/components/character.ce.js.map +1 -1
  19. package/dist/components/dynamics/bar.ce.js +96 -0
  20. package/dist/components/dynamics/bar.ce.js.map +1 -0
  21. package/dist/components/dynamics/image.ce.js +23 -0
  22. package/dist/components/dynamics/image.ce.js.map +1 -0
  23. package/dist/components/dynamics/parse-value.d.ts +3 -0
  24. package/dist/components/dynamics/parse-value.js +51 -35
  25. package/dist/components/dynamics/parse-value.js.map +1 -1
  26. package/dist/components/dynamics/parse-value.spec.d.ts +1 -0
  27. package/dist/components/dynamics/shape-utils.d.ts +16 -0
  28. package/dist/components/dynamics/shape-utils.js +73 -0
  29. package/dist/components/dynamics/shape-utils.js.map +1 -0
  30. package/dist/components/dynamics/shape-utils.spec.d.ts +1 -0
  31. package/dist/components/dynamics/shape.ce.js +83 -0
  32. package/dist/components/dynamics/shape.ce.js.map +1 -0
  33. package/dist/components/dynamics/text.ce.js +28 -41
  34. package/dist/components/dynamics/text.ce.js.map +1 -1
  35. package/dist/components/player-components-utils.d.ts +67 -0
  36. package/dist/components/player-components-utils.js +162 -0
  37. package/dist/components/player-components-utils.js.map +1 -0
  38. package/dist/components/player-components-utils.spec.d.ts +1 -0
  39. package/dist/components/player-components.ce.js +188 -0
  40. package/dist/components/player-components.ce.js.map +1 -0
  41. package/dist/core/setup.js.map +1 -1
  42. package/dist/module.js +3 -0
  43. package/dist/module.js.map +1 -1
  44. package/dist/node_modules/.pnpm/@signe_reactive@2.10.0/node_modules/@signe/reactive/dist/index.js +197 -3
  45. package/dist/node_modules/.pnpm/@signe_reactive@2.10.0/node_modules/@signe/reactive/dist/index.js.map +1 -1
  46. package/dist/node_modules/.pnpm/@signe_room@2.10.0/node_modules/@signe/room/dist/index.js +1 -1
  47. package/dist/services/loadMap.d.ts +6 -0
  48. package/dist/services/loadMap.js.map +1 -1
  49. package/package.json +4 -4
  50. package/src/Game/Map.ts +12 -2
  51. package/src/Game/Object.ts +22 -35
  52. package/src/Gui/Gui.spec.ts +273 -0
  53. package/src/Gui/Gui.ts +105 -50
  54. package/src/Resource.ts +1 -2
  55. package/src/RpgClient.ts +36 -2
  56. package/src/RpgClientEngine.ts +64 -10
  57. package/src/components/character.ce +281 -1
  58. package/src/components/dynamics/bar.ce +87 -0
  59. package/src/components/dynamics/image.ce +20 -0
  60. package/src/components/dynamics/parse-value.spec.ts +41 -0
  61. package/src/components/dynamics/parse-value.ts +102 -37
  62. package/src/components/dynamics/shape-utils.spec.ts +46 -0
  63. package/src/components/dynamics/shape-utils.ts +61 -0
  64. package/src/components/dynamics/shape.ce +89 -0
  65. package/src/components/dynamics/text.ce +34 -149
  66. package/src/components/player-components-utils.spec.ts +109 -0
  67. package/src/components/player-components-utils.ts +205 -0
  68. package/src/components/player-components.ce +221 -0
  69. package/src/core/setup.ts +2 -2
  70. package/src/module.ts +5 -1
  71. package/src/services/loadMap.ts +2 -0
  72. package/dist/node_modules/.pnpm/@signe_reactive@2.9.2/node_modules/@signe/reactive/dist/index.js +0 -227
  73. package/dist/node_modules/.pnpm/@signe_reactive@2.9.2/node_modules/@signe/reactive/dist/index.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import Canvas from "./components/scenes/canvas.ce";
2
2
  import { inject } from './core/inject'
3
- import { signal, bootstrapCanvas, Howl, trigger } from "canvasengine";
3
+ import { signal, bootstrapCanvas, Howl, trigger, type Trigger } from "canvasengine";
4
4
  import { AbstractWebsocket, WebSocketToken } from "./services/AbstractSocket";
5
5
  import { LoadMapService, LoadMapToken } from "./services/loadMap";
6
6
  import { RpgSound } from "./Sound";
@@ -14,6 +14,10 @@ import { lastValueFrom, Observable, combineLatest, BehaviorSubject, filter, swit
14
14
  import { GlobalConfigToken } from "./module";
15
15
  import * as PIXI from "pixi.js";
16
16
  import { PrebuiltComponentAnimations } from "./components/animations";
17
+ import TextComponent from "./components/dynamics/text.ce";
18
+ import BarComponent from "./components/dynamics/bar.ce";
19
+ import ShapeComponent from "./components/dynamics/shape.ce";
20
+ import ImageComponent from "./components/dynamics/image.ce";
17
21
  import {
18
22
  PredictionController,
19
23
  type PredictionHistoryEntry,
@@ -32,6 +36,17 @@ interface MovementTrajectoryPoint {
32
36
  direction?: Direction;
33
37
  }
34
38
 
39
+ type ConfigurableTrigger<T> = Omit<Trigger<T>, "start"> & {
40
+ start(config?: T): Promise<void>;
41
+ };
42
+
43
+ type MapShakeOptions = {
44
+ intensity?: number;
45
+ duration?: number;
46
+ frequency?: number;
47
+ direction?: string;
48
+ };
49
+
35
50
  export class RpgClientEngine<T = any> {
36
51
  private guiService: RpgGui;
37
52
  private webSocket: AbstractWebsocket;
@@ -44,10 +59,10 @@ export class RpgClientEngine<T = any> {
44
59
  stopProcessingInput = false;
45
60
  width = signal("100%");
46
61
  height = signal("100%");
47
- spritesheets: Map<string, any> = new Map();
62
+ spritesheets: Map<string | number, any> = new Map();
48
63
  sounds: Map<string, any> = new Map();
49
64
  componentAnimations: any[] = [];
50
- private spritesheetResolver?: (id: string) => any | Promise<any>;
65
+ private spritesheetResolver?: (id: string | number) => any | Promise<any>;
51
66
  private soundResolver?: (id: string) => any | Promise<any>;
52
67
  particleSettings: {
53
68
  emitters: any[]
@@ -61,10 +76,11 @@ export class RpgClientEngine<T = any> {
61
76
  playerIdSignal = signal<string | null>(null);
62
77
  spriteComponentsBehind = signal<any[]>([]);
63
78
  spriteComponentsInFront = signal<any[]>([]);
79
+ spriteComponents: Map<string, any> = new Map();
64
80
  /** ID of the sprite that the camera should follow. null means follow the current player */
65
81
  cameraFollowTargetId = signal<string | null>(null);
66
82
  /** Trigger for map shake animation */
67
- mapShakeTrigger = trigger();
83
+ mapShakeTrigger: ConfigurableTrigger<MapShakeOptions> = trigger<MapShakeOptions>();
68
84
 
69
85
  controlsReady = signal(undefined);
70
86
  gamePause = signal(false);
@@ -123,6 +139,13 @@ export class RpgClientEngine<T = any> {
123
139
  component: PrebuiltComponentAnimations.Animation
124
140
  })
125
141
 
142
+ this.registerSpriteComponent("rpg:text", TextComponent);
143
+ this.registerSpriteComponent("rpg:hpBar", BarComponent);
144
+ this.registerSpriteComponent("rpg:spBar", BarComponent);
145
+ this.registerSpriteComponent("rpg:bar", BarComponent);
146
+ this.registerSpriteComponent("rpg:shape", ShapeComponent);
147
+ this.registerSpriteComponent("rpg:image", ImageComponent);
148
+
126
149
  this.predictionEnabled = (this.globalConfig as any)?.prediction?.enabled !== false;
127
150
  this.initializePredictionController();
128
151
  }
@@ -233,7 +256,7 @@ export class RpgClientEngine<T = any> {
233
256
 
234
257
  await this.webSocket.connection(() => {
235
258
  const saveClient = inject(SaveClientService);
236
- saveClient.initialize(this.webSocket);
259
+ saveClient.initialize();
237
260
  this.initListeners()
238
261
  this.guiService._initialize()
239
262
  this.startPingPong();
@@ -425,7 +448,7 @@ export class RpgClientEngine<T = any> {
425
448
 
426
449
  this.webSocket.on("shakeMap", (data) => {
427
450
  const { intensity, duration, frequency, direction } = data || {};
428
- (this.mapShakeTrigger as any).start({
451
+ this.mapShakeTrigger.start({
429
452
  intensity,
430
453
  duration,
431
454
  frequency,
@@ -574,7 +597,7 @@ export class RpgClientEngine<T = any> {
574
597
  })
575
598
  await this.webSocket.reconnect(() => {
576
599
  const saveClient = inject(SaveClientService);
577
- saveClient.initialize(this.webSocket);
600
+ saveClient.initialize();
578
601
  this.initListeners()
579
602
  this.guiService._initialize()
580
603
  })
@@ -635,7 +658,7 @@ export class RpgClientEngine<T = any> {
635
658
  * });
636
659
  * ```
637
660
  */
638
- setSpritesheetResolver(resolver: (id: string) => any | Promise<any>): void {
661
+ setSpritesheetResolver(resolver: (id: string | number) => any | Promise<any>): void {
639
662
  this.spritesheetResolver = resolver;
640
663
  }
641
664
 
@@ -646,7 +669,7 @@ export class RpgClientEngine<T = any> {
646
669
  * If not found and a resolver is set, it calls the resolver to create the spritesheet.
647
670
  * The resolved spritesheet is automatically cached for future use.
648
671
  *
649
- * @param id - The spritesheet ID to retrieve
672
+ * @param id - The spritesheet ID or legacy tile ID to retrieve
650
673
  * @returns The spritesheet if found or created, or undefined if not found and no resolver
651
674
  * @returns Promise<any> if the resolver is asynchronous
652
675
  *
@@ -659,7 +682,7 @@ export class RpgClientEngine<T = any> {
659
682
  * const spritesheet = await engine.getSpriteSheet('dynamic-sprite');
660
683
  * ```
661
684
  */
662
- getSpriteSheet(id: string): any | Promise<any> {
685
+ getSpriteSheet(id: string | number): any | Promise<any> {
663
686
  // Check cache first
664
687
  if (this.spritesheets.has(id)) {
665
688
  return this.spritesheets.get(id);
@@ -1093,6 +1116,37 @@ export class RpgClientEngine<T = any> {
1093
1116
  return component
1094
1117
  }
1095
1118
 
1119
+ /**
1120
+ * Register a reusable sprite component that can be addressed by the server.
1121
+ *
1122
+ * Server-side component definitions only carry the component id and
1123
+ * serializable props. The client registry maps that id to the CanvasEngine
1124
+ * component that performs the actual rendering.
1125
+ *
1126
+ * @param id - Stable component id used by server component definitions
1127
+ * @param component - CanvasEngine component to render for this id
1128
+ * @returns The registered component
1129
+ *
1130
+ * @example
1131
+ * ```ts
1132
+ * engine.registerSpriteComponent('guildBadge', GuildBadgeComponent);
1133
+ * ```
1134
+ */
1135
+ registerSpriteComponent(id: string, component: any) {
1136
+ this.spriteComponents.set(id, component);
1137
+ return component;
1138
+ }
1139
+
1140
+ /**
1141
+ * Get a reusable sprite component by id.
1142
+ *
1143
+ * @param id - Component id registered on the client
1144
+ * @returns The CanvasEngine component, or undefined when missing
1145
+ */
1146
+ getSpriteComponent(id: string) {
1147
+ return this.spriteComponents.get(id);
1148
+ }
1149
+
1096
1150
  /**
1097
1151
  * Add a component animation to the engine
1098
1152
  *
@@ -4,6 +4,8 @@
4
4
  <compConfig.component object ...compConfig.props />
5
5
  </Container>
6
6
  }
7
+ <PlayerComponents object position="bottom" graphicBounds />
8
+ <PlayerComponents object position="left" graphicBounds />
7
9
  <Particle emit={emitParticleTrigger} settings={particleSettings} zIndex={1000} name={particleName} />
8
10
  <Container>
9
11
  @for (graphicObj of graphicsSignals) {
@@ -17,6 +19,9 @@
17
19
  />
18
20
  }
19
21
  </Container>
22
+ <PlayerComponents object position="center" graphicBounds />
23
+ <PlayerComponents object position="right" graphicBounds />
24
+ <PlayerComponents object position="top" graphicBounds />
20
25
  @for (compConfig of normalizedComponentsInFront) {
21
26
  <Container dependencies={compConfig.dependencies}>
22
27
  <compConfig.component object ...compConfig.props />
@@ -37,13 +42,14 @@
37
42
 
38
43
  <script>
39
44
  import { signal, effect, mount, computed, tick, animatedSignal, on } from "canvasengine";
45
+ import { Assets } from "pixi.js";
40
46
 
41
47
  import { lastValueFrom, combineLatest, pairwise, filter, map, startWith } from "rxjs";
42
48
  import { Particle } from "@canvasengine/presets";
43
49
  import { GameEngineToken, ModulesToken } from "@rpgjs/common";
44
50
  import { RpgClientEngine } from "../RpgClientEngine";
45
51
  import { inject } from "../core/inject";
46
- import { Direction } from "@rpgjs/common";
52
+ import { Direction, Animation } from "@rpgjs/common";
47
53
  import Hit from "./effects/hit.ce";
48
54
  import PlayerComponents from "./player-components.ce";
49
55
  import { RpgGui } from "../Gui/Gui";
@@ -390,6 +396,280 @@
390
396
  return undefined;
391
397
  }
392
398
 
399
+ const imageDimensions = signal({});
400
+ const loadingImageDimensions = new Set();
401
+
402
+ const toPositiveNumber = (value) => {
403
+ const number = typeof value === 'number' ? value : parseFloat(value);
404
+ return Number.isFinite(number) && number > 0 ? number : undefined;
405
+ };
406
+
407
+ const toFiniteNumber = (value, fallback = 0) => {
408
+ const number = typeof value === 'number' ? value : parseFloat(value);
409
+ return Number.isFinite(number) ? number : fallback;
410
+ };
411
+
412
+ const clampRatio = (value) => Math.min(1, Math.max(0, value));
413
+
414
+ const normalizePair = (value, fallback = [1, 1]) => {
415
+ if (Array.isArray(value)) {
416
+ const x = toFiniteNumber(value[0], fallback[0]);
417
+ const y = toFiniteNumber(value[1] ?? value[0], x);
418
+ return [x, y];
419
+ }
420
+ if (typeof value === 'number') {
421
+ return [value, value];
422
+ }
423
+ if (value && typeof value === 'object') {
424
+ const x = toFiniteNumber(value.x, fallback[0]);
425
+ const y = toFiniteNumber(value.y ?? value.x, x);
426
+ return [x, y];
427
+ }
428
+ return fallback;
429
+ };
430
+
431
+ const normalizeAnchor = (value) => {
432
+ if (!Array.isArray(value)) return undefined;
433
+ const [x, y] = normalizePair(value, [0, 0]);
434
+ return [clampRatio(x), clampRatio(y)];
435
+ };
436
+
437
+ const resolveImageSource = (image) => {
438
+ if (typeof image === 'string') return image;
439
+ if (typeof image?.default === 'string') return image.default;
440
+ return undefined;
441
+ };
442
+
443
+ const parentTextureOptions = (graphicObject) => {
444
+ const props = [
445
+ 'width',
446
+ 'height',
447
+ 'framesHeight',
448
+ 'framesWidth',
449
+ 'rectWidth',
450
+ 'rectHeight',
451
+ 'offset',
452
+ 'image',
453
+ 'sound',
454
+ 'spriteRealSize',
455
+ 'scale',
456
+ 'anchor',
457
+ 'pivot',
458
+ 'x',
459
+ 'y',
460
+ 'opacity'
461
+ ];
462
+
463
+ return props.reduce((options, prop) => {
464
+ if (graphicObject?.[prop] !== undefined) {
465
+ options[prop] = graphicObject[prop];
466
+ }
467
+ return options;
468
+ }, {});
469
+ };
470
+
471
+ const resolveTextureOptions = (graphicObject) => {
472
+ const textures = graphicObject?.textures ?? {};
473
+ const texture =
474
+ textures[realAnimationName()] ??
475
+ textures[Animation.Stand] ??
476
+ Object.values(textures)[0] ??
477
+ {};
478
+
479
+ return {
480
+ ...parentTextureOptions(graphicObject),
481
+ ...texture
482
+ };
483
+ };
484
+
485
+ const resolveFirstAnimationFrame = (textureOptions) => {
486
+ const animations = textureOptions?.animations;
487
+ if (!animations) return {};
488
+
489
+ try {
490
+ const frames = typeof animations === 'function'
491
+ ? animations({ direction: direction() })
492
+ : animations;
493
+ if (!Array.isArray(frames)) return {};
494
+ const firstGroup = frames[0];
495
+ return Array.isArray(firstGroup) ? firstGroup[0] ?? {} : firstGroup ?? {};
496
+ }
497
+ catch {
498
+ return {};
499
+ }
500
+ };
501
+
502
+ const optionValue = (prop, frame, textureOptions, graphicObject) => {
503
+ return frame?.[prop] ?? textureOptions?.[prop] ?? graphicObject?.[prop];
504
+ };
505
+
506
+ const resolveFrameSize = (textureOptions, dimensions) => {
507
+ const framesWidth = toPositiveNumber(textureOptions?.framesWidth) ?? 1;
508
+ const framesHeight = toPositiveNumber(textureOptions?.framesHeight) ?? 1;
509
+ const imageSource = resolveImageSource(textureOptions?.image);
510
+ const loadedSize = imageSource ? dimensions[imageSource] : undefined;
511
+ const fullWidth = toPositiveNumber(textureOptions?.width) ?? loadedSize?.width;
512
+ const fullHeight = toPositiveNumber(textureOptions?.height) ?? loadedSize?.height;
513
+ const width = toPositiveNumber(textureOptions?.rectWidth) ??
514
+ toPositiveNumber(textureOptions?.spriteWidth) ??
515
+ (fullWidth ? fullWidth / framesWidth : undefined);
516
+ const height = toPositiveNumber(textureOptions?.rectHeight) ??
517
+ toPositiveNumber(textureOptions?.spriteHeight) ??
518
+ (fullHeight ? fullHeight / framesHeight : undefined);
519
+
520
+ return {
521
+ width,
522
+ height
523
+ };
524
+ };
525
+
526
+ const resolveHitboxAnchor = (spriteWidth, spriteHeight, realSize, box) => {
527
+ if (!spriteWidth || !spriteHeight || !box) {
528
+ return [0, 0];
529
+ }
530
+
531
+ const heightOfSprite = typeof realSize === 'number' ? realSize : realSize?.height;
532
+ const resolvedHeight = toPositiveNumber(heightOfSprite) ?? spriteHeight;
533
+ const gap = Math.max(0, (spriteHeight - resolvedHeight) / 2);
534
+ const hitboxTopLeftX = clampRatio((spriteWidth - box.w) / 2 / spriteWidth);
535
+ const hitboxTopLeftY = clampRatio((spriteHeight - box.h - gap) / spriteHeight);
536
+ const hitboxCenterX = clampRatio(hitboxTopLeftX + box.w / 2 / spriteWidth);
537
+ const hitboxCenterY = clampRatio(hitboxTopLeftY + box.h / 2 / spriteHeight);
538
+ const footY = clampRatio((spriteHeight - gap) / spriteHeight);
539
+
540
+ switch (box.anchorMode ?? 'top-left') {
541
+ case 'center':
542
+ return [hitboxCenterX, hitboxCenterY];
543
+ case 'foot':
544
+ return [hitboxCenterX, footY];
545
+ case 'top-left':
546
+ default:
547
+ return [hitboxTopLeftX, hitboxTopLeftY];
548
+ }
549
+ };
550
+
551
+ const loadImageDimensions = (image) => {
552
+ const source = resolveImageSource(image);
553
+ if (!source || imageDimensions()[source] || loadingImageDimensions.has(source)) {
554
+ return;
555
+ }
556
+
557
+ loadingImageDimensions.add(source);
558
+ Assets.load(source)
559
+ .then((texture) => {
560
+ const width = toPositiveNumber(texture?.width);
561
+ const height = toPositiveNumber(texture?.height);
562
+ if (!width || !height) return;
563
+
564
+ imageDimensions.update((dimensions) => ({
565
+ ...dimensions,
566
+ [source]: { width, height }
567
+ }));
568
+ })
569
+ .catch(() => {})
570
+ .finally(() => {
571
+ loadingImageDimensions.delete(source);
572
+ });
573
+ };
574
+
575
+ effect(() => {
576
+ const sources = new Set();
577
+
578
+ graphicsSignals().forEach((graphicObject) => {
579
+ const baseImage = resolveImageSource(graphicObject?.image);
580
+ if (baseImage) sources.add(baseImage);
581
+
582
+ Object.values(graphicObject?.textures ?? {}).forEach((textureOptions) => {
583
+ const image = resolveImageSource(textureOptions?.image ?? graphicObject?.image);
584
+ if (image) sources.add(image);
585
+ });
586
+ });
587
+
588
+ sources.forEach((source) => loadImageDimensions(source));
589
+ });
590
+
591
+ const hitboxBounds = computed(() => {
592
+ const box = hitbox();
593
+ const width = box?.w ?? 0;
594
+ const height = box?.h ?? 0;
595
+
596
+ return {
597
+ left: 0,
598
+ top: 0,
599
+ right: width,
600
+ bottom: height,
601
+ width,
602
+ height,
603
+ centerX: width / 2,
604
+ centerY: height / 2
605
+ };
606
+ });
607
+
608
+ const graphicBounds = computed(() => {
609
+ const box = hitbox();
610
+ const fallback = hitboxBounds();
611
+ const dimensions = imageDimensions();
612
+ const graphics = graphicsSignals();
613
+ let bounds = null;
614
+
615
+ graphics.forEach((graphicObject) => {
616
+ const textureOptions = resolveTextureOptions(graphicObject);
617
+ const frame = resolveFirstAnimationFrame(textureOptions);
618
+ const size = resolveFrameSize(textureOptions, dimensions);
619
+ const spriteWidth = size.width ?? box?.w;
620
+ const spriteHeight = size.height ?? box?.h;
621
+
622
+ if (!spriteWidth || !spriteHeight) {
623
+ return;
624
+ }
625
+
626
+ const explicitAnchor = normalizeAnchor(optionValue('anchor', frame, textureOptions, graphicObject));
627
+ const anchor = explicitAnchor ?? resolveHitboxAnchor(
628
+ spriteWidth,
629
+ spriteHeight,
630
+ optionValue('spriteRealSize', frame, textureOptions, graphicObject),
631
+ box
632
+ );
633
+ const scale = normalizePair(optionValue('scale', frame, textureOptions, graphicObject) ?? graphicScale(graphicObject));
634
+ const x = toFiniteNumber(optionValue('x', frame, textureOptions, graphicObject), 0);
635
+ const y = toFiniteNumber(optionValue('y', frame, textureOptions, graphicObject), 0);
636
+ const leftEdge = -anchor[0] * spriteWidth * scale[0];
637
+ const rightEdge = (1 - anchor[0]) * spriteWidth * scale[0];
638
+ const topEdge = -anchor[1] * spriteHeight * scale[1];
639
+ const bottomEdge = (1 - anchor[1]) * spriteHeight * scale[1];
640
+ const graphic = {
641
+ left: x + Math.min(leftEdge, rightEdge),
642
+ top: y + Math.min(topEdge, bottomEdge),
643
+ right: x + Math.max(leftEdge, rightEdge),
644
+ bottom: y + Math.max(topEdge, bottomEdge)
645
+ };
646
+
647
+ bounds = bounds
648
+ ? {
649
+ left: Math.min(bounds.left, graphic.left),
650
+ top: Math.min(bounds.top, graphic.top),
651
+ right: Math.max(bounds.right, graphic.right),
652
+ bottom: Math.max(bounds.bottom, graphic.bottom)
653
+ }
654
+ : graphic;
655
+ });
656
+
657
+ if (!bounds) {
658
+ return fallback;
659
+ }
660
+
661
+ const width = bounds.right - bounds.left;
662
+ const height = bounds.bottom - bounds.top;
663
+
664
+ return {
665
+ ...bounds,
666
+ width,
667
+ height,
668
+ centerX: bounds.left + width / 2,
669
+ centerY: bounds.top + height / 2
670
+ };
671
+ });
672
+
393
673
  // Combine animation change detection with movement state from smoothX/smoothY
394
674
  const movementAnimations = ['walk', 'stand'];
395
675
  const epsilon = 0; // movement threshold to consider the easing still running
@@ -0,0 +1,87 @@
1
+ <Container width={width} height={containerHeight} minWidth={width} minHeight={containerHeight}>
2
+ <Graphics width={width} height={containerHeight} draw={drawBar} />
3
+ @if (hasLabel) {
4
+ <Text text={labelText} x={labelPosition.x} y={labelPosition.y} size={labelSize} color={labelColor} />
5
+ }
6
+ </Container>
7
+
8
+ <script>
9
+ import { computed } from "canvasengine";
10
+ import { resolveDynamicValue } from "./parse-value";
11
+
12
+ const { object, current, max, style, text } = defineProps();
13
+
14
+ const read = (prop, fallback) => prop ? prop() : fallback;
15
+
16
+ const toNumber = (value, fallback = 0) => {
17
+ const resolved = resolveDynamicValue(value, object);
18
+ const num = typeof resolved === 'number' ? resolved : parseFloat(resolved);
19
+ return Number.isFinite(num) ? num : fallback;
20
+ };
21
+
22
+ const toColor = (value, fallback) => {
23
+ const resolved = resolveDynamicValue(value, object);
24
+ if (typeof resolved === 'number') return resolved;
25
+ if (typeof resolved === 'string' && resolved.startsWith('#')) {
26
+ return parseInt(resolved.slice(1), 16);
27
+ }
28
+ return resolved ?? fallback;
29
+ };
30
+
31
+ const config = computed(() => read(style, {}) ?? {});
32
+ const width = computed(() => toNumber(config().width, 50));
33
+ const height = computed(() => toNumber(config().height, 8));
34
+ const borderRadius = computed(() => toNumber(config().borderRadius, 3));
35
+ const borderWidth = computed(() => toNumber(config().borderWidth, 1));
36
+ const backgroundColor = computed(() => toColor(config().bgColor, 0x16213e));
37
+ const fillColor = computed(() => toColor(config().fillColor, 0x4ade80));
38
+ const borderColor = computed(() => toColor(config().borderColor, 0x4a5568));
39
+ const opacity = computed(() => Math.max(0, Math.min(1, toNumber(config().opacity, 1))));
40
+
41
+ const currentValue = computed(() => toNumber(read(current, 0), 0));
42
+ const maxValue = computed(() => Math.max(0, toNumber(read(max, 0), 0)));
43
+ const percent = computed(() => {
44
+ const max = maxValue();
45
+ if (max <= 0) return 0;
46
+ return Math.max(0, Math.min(1, currentValue() / max));
47
+ });
48
+
49
+ const fillWidth = computed(() => Math.max(0, width() * percent()));
50
+ const labelTemplate = computed(() => read(text, null));
51
+ const labelText = computed(() => {
52
+ const template = labelTemplate();
53
+ if (template == null || template === '') return '';
54
+
55
+ const value = String(template)
56
+ .replace(/\{\$current\}/g, String(currentValue()))
57
+ .replace(/\{\$max\}/g, String(maxValue()))
58
+ .replace(/\{\$percent\}/g, String(Math.round(percent() * 100)));
59
+
60
+ return String(resolveDynamicValue(value, object) ?? '');
61
+ });
62
+ const labelSize = computed(() => toNumber(config().fontSize, 10));
63
+ const hasLabel = computed(() => labelText().length > 0);
64
+ const labelOffset = computed(() => hasLabel() ? labelSize() + 2 : 0);
65
+ const containerHeight = computed(() => labelOffset() + height());
66
+ const labelColor = computed(() => toColor(config().textColor, 0xffffff));
67
+ const labelPosition = computed(() => ({
68
+ x: 0,
69
+ y: 0
70
+ }));
71
+
72
+ const drawBar = (g) => {
73
+ g.roundRect(0, labelOffset(), width(), height(), borderRadius());
74
+ g.fill({ color: backgroundColor(), alpha: opacity() });
75
+
76
+ const currentWidth = fillWidth();
77
+ if (currentWidth > 0) {
78
+ g.roundRect(0, labelOffset(), currentWidth, height(), borderRadius());
79
+ g.fill({ color: fillColor(), alpha: opacity() });
80
+ }
81
+
82
+ const strokeWidth = borderWidth();
83
+ if (strokeWidth <= 0) return;
84
+ g.roundRect(0, labelOffset(), width(), height(), borderRadius());
85
+ g.stroke({ color: borderColor(), width: strokeWidth, alpha: opacity() });
86
+ };
87
+ </script>
@@ -0,0 +1,20 @@
1
+ <Sprite sheet={sheet} />
2
+
3
+ <script>
4
+ import { computed } from "canvasengine";
5
+ import { RpgClientEngine } from "../../RpgClientEngine";
6
+ import { inject } from "../../core/inject";
7
+ import { resolveDynamicValue } from "./parse-value";
8
+
9
+ const { object, value } = defineProps();
10
+ const client = inject(RpgClientEngine);
11
+
12
+ const sheet = computed(() => {
13
+ const id = resolveDynamicValue(value?.(), object);
14
+ if (!id) return null;
15
+ return {
16
+ definition: client.getSpriteSheet(id),
17
+ playing: 'default'
18
+ };
19
+ });
20
+ </script>
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { signal } from "canvasengine";
3
+ import { resolveDynamicProps, resolveDynamicValue } from "./parse-value";
4
+
5
+ describe("dynamic component values", () => {
6
+ test("resolves player properties and keeps bar placeholders for the bar renderer", () => {
7
+ const object = {
8
+ name: signal("Alex"),
9
+ hpSignal: signal(100),
10
+ _param: signal({ maxHp: 120 })
11
+ };
12
+
13
+ expect(resolveDynamicValue("HP: {hp}/{param.maxHp} {name} {$current}", object)).toBe("HP: 100/120 Alex {$current}");
14
+ });
15
+
16
+ test("keeps resolved props reactive", () => {
17
+ const object = {
18
+ name: signal("Alex"),
19
+ hpSignal: signal(100),
20
+ _param: signal({ maxHp: 120 })
21
+ };
22
+ const props: any = resolveDynamicProps({
23
+ value: "HP: {hp} {name}",
24
+ text: "{$current}/{$max} {name}",
25
+ style: {
26
+ width: "{hp}"
27
+ }
28
+ }, object);
29
+
30
+ expect(props.value()).toBe("HP: 100 Alex");
31
+ expect(props.text()).toBe("{$current}/{$max} Alex");
32
+ expect(props.style()).toEqual({ width: "100" });
33
+
34
+ object.hpSignal.set(10);
35
+ object.name.set("Sam");
36
+
37
+ expect(props.value()).toBe("HP: 10 Sam");
38
+ expect(props.text()).toBe("{$current}/{$max} Sam");
39
+ expect(props.style()).toEqual({ width: "10" });
40
+ });
41
+ });