@energy8platform/game-engine 0.12.0 → 0.14.0

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.
@@ -5,6 +5,7 @@ import { Container } from 'pixi.js';
5
5
  export type FlexDirection = 'row' | 'column';
6
6
  export type JustifyContent = 'start' | 'center' | 'end' | 'space-between' | 'space-around';
7
7
  export type AlignItems = 'start' | 'center' | 'end' | 'stretch';
8
+ export type AlignContent = 'start' | 'center' | 'end' | 'space-between' | 'stretch';
8
9
 
9
10
  export type AlignSelf = 'auto' | 'start' | 'center' | 'end' | 'stretch';
10
11
 
@@ -14,13 +15,21 @@ export interface FlexItemConfig {
14
15
  /** Flex shrink factor (0 = don't shrink, default: 1) */
15
16
  flexShrink?: number;
16
17
  /** Explicit width override for layout calculations */
17
- layoutWidth?: number;
18
+ layoutWidth?: number | string;
18
19
  /** Explicit height override for layout calculations */
19
- layoutHeight?: number;
20
+ layoutHeight?: number | string;
20
21
  /** Override parent's alignItems for this child */
21
22
  alignSelf?: AlignSelf;
22
23
  /** Exclude from flex layout (acts like position: absolute) */
23
24
  flexExclude?: boolean;
25
+ /** Absolute positioning for flexExclude children (distance from top edge) */
26
+ top?: number;
27
+ /** Absolute positioning for flexExclude children (distance from right edge) */
28
+ right?: number;
29
+ /** Absolute positioning for flexExclude children (distance from bottom edge) */
30
+ bottom?: number;
31
+ /** Absolute positioning for flexExclude children (distance from left edge) */
32
+ left?: number;
24
33
  }
25
34
 
26
35
  export interface FlexContainerConfig {
@@ -34,16 +43,23 @@ export interface FlexContainerConfig {
34
43
  gap?: number;
35
44
  /** Padding [top, right, bottom, left] or single number (default: 0) */
36
45
  padding?: number | [number, number, number, number];
46
+ /** Individual padding overrides (take priority over `padding`) */
47
+ paddingTop?: number;
48
+ paddingRight?: number;
49
+ paddingBottom?: number;
50
+ paddingLeft?: number;
37
51
  /** Enable wrapping to next line (default: false) */
38
52
  flexWrap?: boolean;
53
+ /** Distribution of lines along the cross axis when wrapping (default: 'start') */
54
+ alignContent?: AlignContent;
39
55
  /** Maximum width before wrapping (only with flexWrap) */
40
56
  maxWidth?: number;
41
57
  /** Maximum height before wrapping (only with flexWrap + column) */
42
58
  maxHeight?: number;
43
- /** Explicit container width (used for cross-axis alignment/stretch) */
44
- width?: number;
45
- /** Explicit container height (used for cross-axis alignment/stretch) */
46
- height?: number;
59
+ /** Explicit container width number in pixels, or string percentage (e.g. "50%") resolved against parent */
60
+ width?: number | string;
61
+ /** Explicit container height number in pixels, or string percentage (e.g. "50%") resolved against parent */
62
+ height?: number | string;
47
63
  }
48
64
 
49
65
  // ─── Helpers ─────────────────────────────────────────────
@@ -52,26 +68,56 @@ function normalizePadding(p: number | [number, number, number, number]): [number
52
68
  return typeof p === 'number' ? [p, p, p, p] : p;
53
69
  }
54
70
 
71
+ /** Resolve padding from config: individual props override the base `padding` value */
72
+ function resolvePadding(config: FlexContainerConfig): [number, number, number, number] {
73
+ const base = normalizePadding(config.padding ?? 0);
74
+ return [
75
+ config.paddingTop ?? base[0],
76
+ config.paddingRight ?? base[1],
77
+ config.paddingBottom ?? base[2],
78
+ config.paddingLeft ?? base[3],
79
+ ];
80
+ }
81
+
82
+ /** Resolve a dimension value — number passes through, "50%" resolves against reference */
83
+ function resolveDimension(value: number | string | undefined, reference: number): number | undefined {
84
+ if (value === undefined) return undefined;
85
+ if (typeof value === 'number') return value;
86
+ if (typeof value === 'string' && value.endsWith('%')) {
87
+ const pct = parseFloat(value);
88
+ if (!isNaN(pct) && reference > 0 && isFinite(reference)) return (pct / 100) * reference;
89
+ }
90
+ return undefined;
91
+ }
92
+
55
93
  /** Measure a child's size and bounds offset for layout purposes */
56
- function measureChild(child: Container & { _flexConfig?: FlexItemConfig }): { w: number; h: number; ox: number; oy: number } {
94
+ function measureChild(
95
+ child: Container & { _flexConfig?: FlexItemConfig },
96
+ parentContentW = 0,
97
+ parentContentH = 0,
98
+ ): { w: number; h: number; ox: number; oy: number } {
57
99
  const cfg = child._flexConfig;
58
- if (cfg?.layoutWidth !== undefined && cfg?.layoutHeight !== undefined) {
59
- return { w: cfg.layoutWidth, h: cfg.layoutHeight, ox: 0, oy: 0 };
100
+ const resolvedLW = resolveDimension(cfg?.layoutWidth, parentContentW);
101
+ const resolvedLH = resolveDimension(cfg?.layoutHeight, parentContentH);
102
+ if (resolvedLW !== undefined && resolvedLH !== undefined) {
103
+ return { w: resolvedLW, h: resolvedLH, ox: 0, oy: 0 };
60
104
  }
61
105
 
62
- // For FlexContainers, use their explicit size if set
106
+ // For FlexContainers, use their explicit or computed size
63
107
  if (child instanceof FlexContainer) {
64
108
  const fc = child;
65
- if (fc._explicitWidth > 0 && fc._explicitHeight > 0) {
66
- return { w: fc._explicitWidth, h: fc._explicitHeight, ox: 0, oy: 0 };
109
+ const w = fc._explicitWidth > 0 ? fc._explicitWidth : (fc._computedWidth > 0 ? fc._computedWidth : undefined);
110
+ const h = fc._explicitHeight > 0 ? fc._explicitHeight : (fc._computedHeight > 0 ? fc._computedHeight : undefined);
111
+ if (w !== undefined && h !== undefined) {
112
+ return { w, h, ox: 0, oy: 0 };
67
113
  }
68
114
  }
69
115
 
70
116
  // Use localBounds to get the true visual extent and origin offset.
71
117
  // This handles children with non-zero anchors (e.g. Button, Label with centered text).
72
118
  const bounds = child.getLocalBounds();
73
- const w = cfg?.layoutWidth ?? bounds.width;
74
- const h = cfg?.layoutHeight ?? bounds.height;
119
+ const w = resolvedLW ?? bounds.width;
120
+ const h = resolvedLH ?? bounds.height;
75
121
  return { w, h, ox: bounds.x, oy: bounds.y };
76
122
  }
77
123
 
@@ -87,6 +133,45 @@ interface LineItem {
87
133
  oy: number;
88
134
  }
89
135
 
136
+ /**
137
+ * Set a child's main or cross dimension.
138
+ * For FlexContainer children, calls resize() to trigger internal relayout
139
+ * instead of the PixiJS scale setter.
140
+ */
141
+ function setChildMainSize(child: Container, isRow: boolean, mainSize: number, item: LineItem): void {
142
+ if (child instanceof FlexContainer) {
143
+ const fc = child;
144
+ fc.resize(
145
+ isRow ? mainSize : fc._explicitWidth || fc._computedWidth,
146
+ isRow ? fc._explicitHeight || fc._computedHeight : mainSize,
147
+ );
148
+ } else {
149
+ if (isRow) {
150
+ child.width = mainSize;
151
+ } else {
152
+ child.height = mainSize;
153
+ }
154
+ }
155
+ if (isRow) item.w = mainSize;
156
+ else item.h = mainSize;
157
+ }
158
+
159
+ function setChildCrossSize(child: Container, isRow: boolean, crossSize: number): void {
160
+ if (child instanceof FlexContainer) {
161
+ const fc = child;
162
+ fc.resize(
163
+ isRow ? fc._explicitWidth || fc._computedWidth : crossSize,
164
+ isRow ? crossSize : fc._explicitHeight || fc._computedHeight,
165
+ );
166
+ } else {
167
+ if (isRow) {
168
+ child.height = crossSize;
169
+ } else {
170
+ child.width = crossSize;
171
+ }
172
+ }
173
+ }
174
+
90
175
  function layoutLine(
91
176
  items: LineItem[],
92
177
  isRow: boolean,
@@ -120,13 +205,7 @@ function layoutLine(
120
205
  const grow = item.child._flexConfig?.flexGrow ?? 0;
121
206
  if (grow > 0) {
122
207
  const flexSize = (grow / totalGrow) * availableForFlex;
123
- if (isRow) {
124
- item.w = flexSize;
125
- item.child.width = flexSize;
126
- } else {
127
- item.h = flexSize;
128
- item.child.height = flexSize;
129
- }
208
+ setChildMainSize(item.child, isRow, flexSize, item);
130
209
  }
131
210
  }
132
211
  }
@@ -149,13 +228,7 @@ function layoutLine(
149
228
  const itemMain = isRow ? item.w : item.h;
150
229
  const reduction = overflow * (itemMain / totalShrinkable);
151
230
  const newSize = Math.max(0, itemMain - reduction);
152
- if (isRow) {
153
- item.w = newSize;
154
- item.child.width = newSize;
155
- } else {
156
- item.h = newSize;
157
- item.child.height = newSize;
158
- }
231
+ setChildMainSize(item.child, isRow, newSize, item);
159
232
  }
160
233
  }
161
234
  }
@@ -217,11 +290,7 @@ function layoutLine(
217
290
  crossPos += crossSize - crossDim;
218
291
  break;
219
292
  case 'stretch':
220
- if (isRow) {
221
- item.child.height = crossSize;
222
- } else {
223
- item.child.width = crossSize;
224
- }
293
+ setChildCrossSize(item.child, isRow, crossSize);
225
294
  break;
226
295
  }
227
296
 
@@ -264,12 +333,18 @@ function layoutLine(
264
333
  export class FlexContainer extends Container {
265
334
  readonly __uiComponent = true as const;
266
335
 
267
- private _config: Required<Pick<FlexContainerConfig, 'direction' | 'justifyContent' | 'alignItems' | 'gap' | 'flexWrap'>>;
336
+ private _config: Required<Pick<FlexContainerConfig, 'direction' | 'justifyContent' | 'alignItems' | 'gap' | 'flexWrap' | 'alignContent'>>;
268
337
  private _padding: [number, number, number, number];
269
338
  private _maxWidth: number;
270
339
  private _maxHeight: number;
271
340
  /** @internal */ _explicitWidth: number;
272
341
  /** @internal */ _explicitHeight: number;
342
+ /** @internal */ _computedWidth = 0;
343
+ /** @internal */ _computedHeight = 0;
344
+ /** @internal */ _availableWidth = 0;
345
+ /** @internal */ _availableHeight = 0;
346
+ /** @internal */ _rawWidth: number | string;
347
+ /** @internal */ _rawHeight: number | string;
273
348
  private _layoutChildren: (Container & { _flexConfig?: FlexItemConfig })[] = [];
274
349
  private _layoutDirty = true;
275
350
 
@@ -282,13 +357,16 @@ export class FlexContainer extends Container {
282
357
  alignItems: config.alignItems ?? 'start',
283
358
  gap: config.gap ?? 0,
284
359
  flexWrap: config.flexWrap ?? false,
360
+ alignContent: config.alignContent ?? 'start',
285
361
  };
286
362
 
287
- this._padding = normalizePadding(config.padding ?? 0);
363
+ this._padding = resolvePadding(config);
288
364
  this._maxWidth = config.maxWidth ?? Infinity;
289
365
  this._maxHeight = config.maxHeight ?? Infinity;
290
- this._explicitWidth = config.width ?? 0;
291
- this._explicitHeight = config.height ?? 0;
366
+ this._rawWidth = config.width ?? 0;
367
+ this._rawHeight = config.height ?? 0;
368
+ this._explicitWidth = typeof this._rawWidth === 'number' ? this._rawWidth : 0;
369
+ this._explicitHeight = typeof this._rawHeight === 'number' ? this._rawHeight : 0;
292
370
  }
293
371
 
294
372
  // ─── Public API ──────────────────────────────────────
@@ -402,20 +480,45 @@ export class FlexContainer extends Container {
402
480
  */
403
481
  updateLayout(): void {
404
482
  this._layoutDirty = false;
405
- const { direction, justifyContent, alignItems, gap, flexWrap } = this._config;
483
+ const { direction, justifyContent, alignItems, gap, flexWrap, alignContent } = this._config;
406
484
  const [pt, pr, pb, pl] = this._padding;
407
485
  const isRow = direction === 'row';
408
486
 
487
+ // Resolve percentage width/height against parent's available space
488
+ if (typeof this._rawWidth === 'string') {
489
+ this._explicitWidth = resolveDimension(this._rawWidth, this._availableWidth) ?? 0;
490
+ }
491
+ if (typeof this._rawHeight === 'string') {
492
+ this._explicitHeight = resolveDimension(this._rawHeight, this._availableHeight) ?? 0;
493
+ }
494
+
409
495
  const contentW = this._explicitWidth > 0 ? this._explicitWidth - pl - pr : Infinity;
410
496
  const contentH = this._explicitHeight > 0 ? this._explicitHeight - pt - pb : Infinity;
411
497
  const mainLimit = isRow ? contentW : contentH;
412
498
  const crossLimit = isRow ? contentH : contentW;
413
499
 
500
+ // Pass content area to measureChild for percentage resolution
501
+ const pctRefW = contentW < Infinity ? contentW : 0;
502
+ const pctRefH = contentH < Infinity ? contentH : 0;
503
+
504
+ // Propagate available size to child FlexContainers and resolve their percentages
505
+ for (const child of this._layoutChildren) {
506
+ if (child instanceof FlexContainer) {
507
+ const fc = child;
508
+ fc._availableWidth = pctRefW;
509
+ fc._availableHeight = pctRefH;
510
+ // If child has percentage dimensions, trigger its layout to resolve them
511
+ if (typeof fc._rawWidth === 'string' || typeof fc._rawHeight === 'string') {
512
+ fc.updateLayout();
513
+ }
514
+ }
515
+ }
516
+
414
517
  // Measure children (skip flexExclude — they position themselves)
415
518
  const measured: LineItem[] = [];
416
519
  for (const child of this._layoutChildren) {
417
520
  if (child._flexConfig?.flexExclude) continue;
418
- const { w, h, ox, oy } = measureChild(child);
521
+ const { w, h, ox, oy } = measureChild(child, pctRefW, pctRefH);
419
522
  measured.push({ child, w, h, ox, oy });
420
523
  }
421
524
 
@@ -453,8 +556,54 @@ export class FlexContainer extends Container {
453
556
  return maxCross;
454
557
  });
455
558
 
559
+ // Compute natural main size (for auto-sizing when no explicit size given)
560
+ let naturalMainSize = 0;
561
+ if (mainLimit === Infinity) {
562
+ for (const line of lines) {
563
+ let lineMain = 0;
564
+ for (const item of line) {
565
+ lineMain += isRow ? item.w : item.h;
566
+ }
567
+ lineMain += gap * Math.max(0, line.length - 1);
568
+ naturalMainSize = Math.max(naturalMainSize, lineMain);
569
+ }
570
+ }
571
+
572
+ // Effective main size: explicit if set, otherwise natural content size
573
+ const effectiveMainSize = mainLimit < Infinity ? mainLimit : naturalMainSize;
574
+
575
+ // Compute alignContent offsets for multi-line layouts
576
+ const totalLinesCross = lineCrossSizes.reduce((s, v) => s + v, 0) + gap * Math.max(0, lines.length - 1);
577
+ let acOffset = 0;
578
+ let acExtraGap = 0;
579
+ if (lines.length > 1 && crossLimit < Infinity) {
580
+ const freeSpace = Math.max(0, crossLimit - totalLinesCross);
581
+ switch (alignContent) {
582
+ case 'center':
583
+ acOffset = freeSpace / 2;
584
+ break;
585
+ case 'end':
586
+ acOffset = freeSpace;
587
+ break;
588
+ case 'space-between':
589
+ if (lines.length > 1) {
590
+ acExtraGap = freeSpace / (lines.length - 1);
591
+ }
592
+ break;
593
+ case 'stretch':
594
+ if (lines.length > 0) {
595
+ const extra = freeSpace / lines.length;
596
+ for (let i = 0; i < lineCrossSizes.length; i++) {
597
+ lineCrossSizes[i] += extra;
598
+ }
599
+ }
600
+ break;
601
+ // 'start' — no adjustment
602
+ }
603
+ }
604
+
456
605
  // Layout each line
457
- let crossOffset = isRow ? pt : pl;
606
+ let crossOffset = (isRow ? pt : pl) + acOffset;
458
607
  for (let i = 0; i < lines.length; i++) {
459
608
  const line = lines[i];
460
609
  const lineCross = lineCrossSizes[i];
@@ -463,16 +612,13 @@ export class FlexContainer extends Container {
463
612
  // Offset items by padding
464
613
  const tempItems = line.map((item) => ({ ...item }));
465
614
 
466
- // For single-line layouts, use the full available cross space for alignment;
467
- // for multi-line (wrapping), each line gets its own measured cross size.
468
- const effectiveCross = lines.length === 1 && crossLimit < Infinity
469
- ? crossLimit
470
- : (crossLimit < Infinity ? Math.min(lineCross, crossLimit) : lineCross);
615
+ // Cross size for alignment: use container cross size for single-line, line cross for multi-line
616
+ const effectiveCross = lines.length === 1 && crossLimit < Infinity ? crossLimit : lineCross;
471
617
 
472
618
  layoutLine(
473
619
  tempItems,
474
620
  isRow,
475
- mainLimit < Infinity ? mainLimit : 0,
621
+ effectiveMainSize,
476
622
  mainLimit < Infinity ? justifyContent : 'start',
477
623
  alignItems,
478
624
  gap,
@@ -487,24 +633,42 @@ export class FlexContainer extends Container {
487
633
  origChild.child.y = item.child.y + (isRow ? 0 : mainStart);
488
634
  }
489
635
 
490
- crossOffset += lineCross + gap;
636
+ crossOffset += lineCross + gap + acExtraGap;
491
637
  }
492
- }
493
638
 
494
- /** Computed content size (after layout) */
495
- getContentSize(): { width: number; height: number } {
496
- if (this._layoutDirty) this.updateLayout();
639
+ // Compute and store actual dimensions for measureChild() and getContentSize()
640
+ let totalCrossNatural = 0;
641
+ for (let i = 0; i < lineCrossSizes.length; i++) {
642
+ totalCrossNatural += lineCrossSizes[i];
643
+ if (i < lineCrossSizes.length - 1) totalCrossNatural += gap;
644
+ }
645
+
646
+ if (isRow) {
647
+ this._computedWidth = this._explicitWidth > 0 ? this._explicitWidth : (pl + naturalMainSize + pr);
648
+ this._computedHeight = this._explicitHeight > 0 ? this._explicitHeight : (pt + totalCrossNatural + pb);
649
+ } else {
650
+ this._computedWidth = this._explicitWidth > 0 ? this._explicitWidth : (pl + totalCrossNatural + pr);
651
+ this._computedHeight = this._explicitHeight > 0 ? this._explicitHeight : (pt + naturalMainSize + pb);
652
+ }
497
653
 
498
- let maxX = 0;
499
- let maxY = 0;
654
+ // Position flexExclude children (absolute positioning)
500
655
  for (const child of this._layoutChildren) {
501
- const { w, h } = measureChild(child);
502
- maxX = Math.max(maxX, child.x + w);
503
- maxY = Math.max(maxY, child.y + h);
656
+ if (!child._flexConfig?.flexExclude) continue;
657
+ const cfg = child._flexConfig;
658
+ const { w, h } = measureChild(child, pctRefW, pctRefH);
659
+ const cw = this._computedWidth;
660
+ const ch = this._computedHeight;
661
+ if (cfg.left !== undefined) child.x = cfg.left;
662
+ else if (cfg.right !== undefined) child.x = cw - w - cfg.right;
663
+ if (cfg.top !== undefined) child.y = cfg.top;
664
+ else if (cfg.bottom !== undefined) child.y = ch - h - cfg.bottom;
504
665
  }
666
+ }
505
667
 
506
- const [, pr, pb] = this._padding;
507
- return { width: maxX + pr, height: maxY + pb };
668
+ /** Computed content size (after layout) */
669
+ getContentSize(): { width: number; height: number } {
670
+ if (this._layoutDirty) this.updateLayout();
671
+ return { width: this._computedWidth, height: this._computedHeight };
508
672
  }
509
673
 
510
674
  /** React reconciler update hook — applies changed config props */
@@ -513,11 +677,27 @@ export class FlexContainer extends Container {
513
677
  if ('justifyContent' in changed) this.setJustifyContent(changed.justifyContent);
514
678
  if ('alignItems' in changed) this.setAlignItems(changed.alignItems);
515
679
  if ('gap' in changed) this.setGap(changed.gap);
516
- if ('padding' in changed) this.setPadding(changed.padding);
680
+ if ('padding' in changed || 'paddingTop' in changed || 'paddingRight' in changed || 'paddingBottom' in changed || 'paddingLeft' in changed) {
681
+ this._padding = resolvePadding(changed as FlexContainerConfig);
682
+ this._layoutDirty = true;
683
+ }
517
684
  if ('flexWrap' in changed) { this._config.flexWrap = changed.flexWrap; this._layoutDirty = true; }
685
+ if ('alignContent' in changed) { this._config.alignContent = changed.alignContent; this._layoutDirty = true; }
518
686
  if ('width' in changed || 'height' in changed) {
519
- this.resize(changed.width ?? this._explicitWidth, changed.height ?? this._explicitHeight);
520
- return; // resize calls updateLayout
687
+ const w = changed.width ?? this._rawWidth;
688
+ const h = changed.height ?? this._rawHeight;
689
+ this._rawWidth = w;
690
+ this._rawHeight = h;
691
+ if (typeof w === 'number' && typeof h === 'number') {
692
+ this.resize(w, h);
693
+ } else {
694
+ // Percentage — will resolve in updateLayout
695
+ this._explicitWidth = typeof w === 'number' ? w : 0;
696
+ this._explicitHeight = typeof h === 'number' ? h : 0;
697
+ this._layoutDirty = true;
698
+ this.updateLayout();
699
+ }
700
+ return;
521
701
  }
522
702
  if (this._layoutDirty) this.updateLayout();
523
703
  }
package/src/ui/index.ts CHANGED
@@ -4,7 +4,7 @@ export type { ViewInput } from './view';
4
4
 
5
5
  // ─── Engine UI Components ─────────────────────────────────
6
6
  export { FlexContainer } from './FlexContainer';
7
- export type { FlexContainerConfig, FlexDirection, JustifyContent, AlignItems, AlignSelf, FlexItemConfig } from './FlexContainer';
7
+ export type { FlexContainerConfig, FlexDirection, JustifyContent, AlignItems, AlignSelf, AlignContent, FlexItemConfig } from './FlexContainer';
8
8
  export { Button } from './Button';
9
9
  export type { ButtonConfig, ButtonState } from './Button';
10
10
  export { ProgressBar } from './ProgressBar';