@energy8platform/game-engine 0.11.0 → 0.13.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.
@@ -6,13 +6,21 @@ 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
8
 
9
+ export type AlignSelf = 'auto' | 'start' | 'center' | 'end' | 'stretch';
10
+
9
11
  export interface FlexItemConfig {
10
12
  /** Flex grow factor (0 = fixed size) */
11
13
  flexGrow?: number;
14
+ /** Flex shrink factor (0 = don't shrink, default: 1) */
15
+ flexShrink?: number;
12
16
  /** Explicit width override for layout calculations */
13
17
  layoutWidth?: number;
14
18
  /** Explicit height override for layout calculations */
15
19
  layoutHeight?: number;
20
+ /** Override parent's alignItems for this child */
21
+ alignSelf?: AlignSelf;
22
+ /** Exclude from flex layout (acts like position: absolute) */
23
+ flexExclude?: boolean;
16
24
  }
17
25
 
18
26
  export interface FlexContainerConfig {
@@ -123,6 +131,37 @@ function layoutLine(
123
131
  }
124
132
  }
125
133
 
134
+ // Shrink: if content overflows and mainSize is finite, shrink eligible items
135
+ if (totalGrow === 0 && mainSize > 0) {
136
+ const overflow = totalFixed + totalGap - mainSize;
137
+ if (overflow > 0) {
138
+ let totalShrinkable = 0;
139
+ for (const item of items) {
140
+ const shrink = item.child._flexConfig?.flexShrink ?? 1;
141
+ if (shrink > 0) {
142
+ totalShrinkable += isRow ? item.w : item.h;
143
+ }
144
+ }
145
+ if (totalShrinkable > 0) {
146
+ for (const item of items) {
147
+ const shrink = item.child._flexConfig?.flexShrink ?? 1;
148
+ if (shrink > 0) {
149
+ const itemMain = isRow ? item.w : item.h;
150
+ const reduction = overflow * (itemMain / totalShrinkable);
151
+ 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
+ }
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+
126
165
  // Calculate total main size after flex
127
166
  let totalMain = totalGap;
128
167
  for (const item of items) {
@@ -163,9 +202,12 @@ function layoutLine(
163
202
  const mainDim = isRow ? item.w : item.h;
164
203
  const crossDim = isRow ? item.h : item.w;
165
204
 
166
- // Cross-axis alignment
205
+ // Cross-axis alignment (alignSelf overrides align)
206
+ const effectiveAlign = (item.child._flexConfig?.alignSelf && item.child._flexConfig.alignSelf !== 'auto')
207
+ ? item.child._flexConfig.alignSelf
208
+ : align;
167
209
  let crossPos = crossOffset;
168
- switch (align) {
210
+ switch (effectiveAlign) {
169
211
  case 'start':
170
212
  break;
171
213
  case 'center':
@@ -369,11 +411,13 @@ export class FlexContainer extends Container {
369
411
  const mainLimit = isRow ? contentW : contentH;
370
412
  const crossLimit = isRow ? contentH : contentW;
371
413
 
372
- // Measure children
373
- const measured: LineItem[] = this._layoutChildren.map((child) => {
414
+ // Measure children (skip flexExclude — they position themselves)
415
+ const measured: LineItem[] = [];
416
+ for (const child of this._layoutChildren) {
417
+ if (child._flexConfig?.flexExclude) continue;
374
418
  const { w, h, ox, oy } = measureChild(child);
375
- return { child, w, h, ox, oy };
376
- });
419
+ measured.push({ child, w, h, ox, oy });
420
+ }
377
421
 
378
422
  // Split into lines (if wrapping)
379
423
  const lines: LineItem[][] = [];
@@ -419,6 +463,12 @@ export class FlexContainer extends Container {
419
463
  // Offset items by padding
420
464
  const tempItems = line.map((item) => ({ ...item }));
421
465
 
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);
471
+
422
472
  layoutLine(
423
473
  tempItems,
424
474
  isRow,
@@ -427,7 +477,7 @@ export class FlexContainer extends Container {
427
477
  alignItems,
428
478
  gap,
429
479
  crossOffset,
430
- crossLimit < Infinity ? Math.min(lineCross, crossLimit) : lineCross,
480
+ effectiveCross,
431
481
  );
432
482
 
433
483
  // Apply main-axis padding offset
@@ -0,0 +1,241 @@
1
+ import { Container, Graphics, FederatedPointerEvent } from 'pixi.js';
2
+ import { resolveView } from './view';
3
+ import type { ViewInput } from './view';
4
+
5
+ export interface SliderConfig {
6
+ /** Minimum value (default: 0) */
7
+ min?: number;
8
+ /** Maximum value (default: 1) */
9
+ max?: number;
10
+ /** Step increment (0 = continuous, default: 0) */
11
+ step?: number;
12
+ /** Initial value (default: min) */
13
+ value?: number;
14
+ /** Track width in pixels (default: 200) */
15
+ width?: number;
16
+ /** Track height in pixels (default: 8) */
17
+ height?: number;
18
+ /** Corner radius for Graphics-based track/fill (default: 4) */
19
+ borderRadius?: number;
20
+ /** Track background color (ignored when trackView provided) */
21
+ trackColor?: number;
22
+ /** Fill bar color (ignored when fillView provided) */
23
+ fillColor?: number;
24
+ /** Handle radius (for Graphics-based handle, default: 12) */
25
+ handleRadius?: number;
26
+ /** Handle color (for Graphics-based handle, ignored when handleView provided) */
27
+ handleColor?: number;
28
+
29
+ /** Custom track background view */
30
+ trackView?: ViewInput;
31
+ /** Custom fill bar view */
32
+ fillView?: ViewInput;
33
+ /** Custom handle view */
34
+ handleView?: ViewInput;
35
+
36
+ /** Called when value changes during drag */
37
+ onUpdate?: (value: number) => void;
38
+ /** Called when drag ends */
39
+ onChange?: (value: number) => void;
40
+ }
41
+
42
+ /**
43
+ * Draggable slider with customizable track, fill, and handle views.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * const volume = new Slider({
48
+ * min: 0, max: 1, value: 0.5,
49
+ * width: 200, height: 8,
50
+ * fillColor: 0xffd700,
51
+ * onUpdate: (v) => console.log('Volume:', v),
52
+ * });
53
+ * ```
54
+ */
55
+ export class Slider extends Container {
56
+ readonly __uiComponent = true as const;
57
+
58
+ private _track: Container;
59
+ private _fill: Container;
60
+ private _fillMask: Graphics;
61
+ private _handle: Container;
62
+ private _config: Required<Pick<SliderConfig, 'min' | 'max' | 'step' | 'width' | 'height' | 'borderRadius' | 'trackColor' | 'fillColor' | 'handleRadius' | 'handleColor'>>;
63
+ private _value: number;
64
+ private _dragging = false;
65
+
66
+ onUpdate: ((value: number) => void) | null = null;
67
+ onChange: ((value: number) => void) | null = null;
68
+
69
+ constructor(config: SliderConfig = {}) {
70
+ super();
71
+
72
+ this._config = {
73
+ min: config.min ?? 0,
74
+ max: config.max ?? 1,
75
+ step: config.step ?? 0,
76
+ width: config.width ?? 200,
77
+ height: config.height ?? 8,
78
+ borderRadius: config.borderRadius ?? 4,
79
+ trackColor: config.trackColor ?? 0x333333,
80
+ fillColor: config.fillColor ?? 0xffd700,
81
+ handleRadius: config.handleRadius ?? 12,
82
+ handleColor: config.handleColor ?? 0xffffff,
83
+ };
84
+
85
+ this._value = config.value ?? this._config.min;
86
+ this.onUpdate = config.onUpdate ?? null;
87
+ this.onChange = config.onChange ?? null;
88
+
89
+ const { width, height, borderRadius, trackColor, fillColor, handleRadius, handleColor } = this._config;
90
+
91
+ // Track
92
+ const customTrack = resolveView(config.trackView);
93
+ if (customTrack) {
94
+ customTrack.width = width;
95
+ customTrack.height = height;
96
+ this._track = customTrack;
97
+ } else {
98
+ const g = new Graphics();
99
+ g.roundRect(0, 0, width, height, borderRadius).fill(trackColor);
100
+ this._track = g;
101
+ }
102
+ this.addChild(this._track);
103
+
104
+ // Fill
105
+ const customFill = resolveView(config.fillView);
106
+ if (customFill) {
107
+ customFill.width = width;
108
+ customFill.height = height;
109
+ this._fill = customFill;
110
+ } else {
111
+ const g = new Graphics();
112
+ g.roundRect(0, 0, width, height, borderRadius).fill(fillColor);
113
+ this._fill = g;
114
+ }
115
+ this.addChild(this._fill);
116
+
117
+ // Fill mask
118
+ this._fillMask = new Graphics();
119
+ this.addChild(this._fillMask);
120
+ this._fill.mask = this._fillMask;
121
+
122
+ // Handle
123
+ const customHandle = resolveView(config.handleView);
124
+ if (customHandle) {
125
+ this._handle = customHandle;
126
+ } else {
127
+ const g = new Graphics();
128
+ g.circle(0, 0, handleRadius).fill(handleColor);
129
+ this._handle = g;
130
+ }
131
+ this._handle.y = height / 2;
132
+ this.addChild(this._handle);
133
+
134
+ // Interaction
135
+ this.eventMode = 'static';
136
+ this.cursor = 'pointer';
137
+
138
+ // Hit area covers track + handle overflow
139
+ const hitPad = Math.max(handleRadius - height / 2, 0);
140
+ this.hitArea = { contains: (x: number, y: number) => x >= -hitPad && x <= width + hitPad && y >= -hitPad && y <= height + hitPad };
141
+
142
+ this.on('pointerdown', this._onPointerDown, this);
143
+ this.on('globalpointermove', this._onPointerMove, this);
144
+ this.on('pointerup', this._onPointerUp, this);
145
+ this.on('pointerupoutside', this._onPointerUp, this);
146
+
147
+ this._updateVisuals();
148
+ }
149
+
150
+ /** Current value */
151
+ get value(): number {
152
+ return this._value;
153
+ }
154
+
155
+ set value(v: number) {
156
+ const clamped = this._applyStep(Math.max(this._config.min, Math.min(this._config.max, v)));
157
+ if (clamped === this._value) return;
158
+ this._value = clamped;
159
+ this._updateVisuals();
160
+ }
161
+
162
+ get min(): number { return this._config.min; }
163
+ get max(): number { return this._config.max; }
164
+
165
+ /** React reconciler update hook */
166
+ updateConfig(changed: Record<string, any>): void {
167
+ if ('value' in changed) this.value = changed.value;
168
+ if ('min' in changed) { this._config.min = changed.min; this._updateVisuals(); }
169
+ if ('max' in changed) { this._config.max = changed.max; this._updateVisuals(); }
170
+ if ('step' in changed) this._config.step = changed.step;
171
+ if ('onUpdate' in changed) this.onUpdate = changed.onUpdate;
172
+ if ('onChange' in changed) this.onChange = changed.onChange;
173
+ }
174
+
175
+ private _fraction(): number {
176
+ const { min, max } = this._config;
177
+ return max === min ? 0 : (this._value - min) / (max - min);
178
+ }
179
+
180
+ private _applyStep(v: number): number {
181
+ const { step, min } = this._config;
182
+ if (step <= 0) return v;
183
+ return min + Math.round((v - min) / step) * step;
184
+ }
185
+
186
+ private _updateVisuals(): void {
187
+ const frac = this._fraction();
188
+ const w = this._config.width;
189
+ const h = this._config.height;
190
+
191
+ // Update fill mask
192
+ this._fillMask.clear();
193
+ this._fillMask.rect(0, 0, w * frac, h).fill(0xffffff);
194
+
195
+ // Update handle position
196
+ this._handle.x = w * frac;
197
+ }
198
+
199
+ private _valueFromPointer(e: FederatedPointerEvent): number {
200
+ const local = this.toLocal(e.global);
201
+ const frac = Math.max(0, Math.min(1, local.x / this._config.width));
202
+ const { min, max } = this._config;
203
+ return this._applyStep(min + frac * (max - min));
204
+ }
205
+
206
+ private _onPointerDown(e: FederatedPointerEvent): void {
207
+ this._dragging = true;
208
+ const newValue = this._valueFromPointer(e);
209
+ if (newValue !== this._value) {
210
+ this._value = newValue;
211
+ this._updateVisuals();
212
+ this.onUpdate?.(this._value);
213
+ }
214
+ }
215
+
216
+ private _onPointerMove(e: FederatedPointerEvent): void {
217
+ if (!this._dragging) return;
218
+ const newValue = this._valueFromPointer(e);
219
+ if (newValue !== this._value) {
220
+ this._value = newValue;
221
+ this._updateVisuals();
222
+ this.onUpdate?.(this._value);
223
+ }
224
+ }
225
+
226
+ private _onPointerUp(_e: FederatedPointerEvent): void {
227
+ if (!this._dragging) return;
228
+ this._dragging = false;
229
+ this.onChange?.(this._value);
230
+ }
231
+
232
+ override destroy(options?: boolean | { children?: boolean; texture?: boolean; textureSource?: boolean }): void {
233
+ this.off('pointerdown', this._onPointerDown, this);
234
+ this.off('globalpointermove', this._onPointerMove, this);
235
+ this.off('pointerup', this._onPointerUp, this);
236
+ this.off('pointerupoutside', this._onPointerUp, this);
237
+ this.onUpdate = null;
238
+ this.onChange = null;
239
+ super.destroy(options);
240
+ }
241
+ }
@@ -0,0 +1,201 @@
1
+ import { Container, Graphics } from 'pixi.js';
2
+ import { Tween } from '../animation/Tween';
3
+ import { resolveView } from './view';
4
+ import type { ViewInput } from './view';
5
+
6
+ export interface ToggleConfig {
7
+ /** Initial state (default: false) */
8
+ value?: boolean;
9
+ /** Custom view for the ON state */
10
+ onView?: ViewInput;
11
+ /** Custom view for the OFF state */
12
+ offView?: ViewInput;
13
+ /** Width (for Graphics-based toggle, default: 52) */
14
+ width?: number;
15
+ /** Height (for Graphics-based toggle, default: 28) */
16
+ height?: number;
17
+ /** Track color when ON (ignored when custom views provided) */
18
+ onColor?: number;
19
+ /** Track color when OFF (ignored when custom views provided) */
20
+ offColor?: number;
21
+ /** Handle color (for Graphics-based toggle) */
22
+ handleColor?: number;
23
+ /** Handle radius (for Graphics-based toggle, default: auto) */
24
+ handleRadius?: number;
25
+ /** Animation duration in ms (default: 200) */
26
+ animationDuration?: number;
27
+
28
+ /** Called when value changes */
29
+ onChange?: (value: boolean) => void;
30
+ }
31
+
32
+ /**
33
+ * Toggle switch with two states.
34
+ *
35
+ * Supports custom ON/OFF views or auto-generated Graphics-based toggle.
36
+ * Click to toggle, or use `forceSwitch(value)` programmatically.
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * const mute = new Toggle({
41
+ * value: false,
42
+ * onColor: 0x22cc22,
43
+ * onChange: (on) => audioManager.mute(!on),
44
+ * });
45
+ * ```
46
+ */
47
+ export class Toggle extends Container {
48
+ readonly __uiComponent = true as const;
49
+
50
+ private _value: boolean;
51
+ private _onView: Container | null = null;
52
+ private _offView: Container | null = null;
53
+ private _handle: Container | null = null;
54
+ private _trackGfx: Graphics | null = null;
55
+ private _config: Required<Pick<ToggleConfig, 'width' | 'height' | 'onColor' | 'offColor' | 'handleColor' | 'handleRadius' | 'animationDuration'>>;
56
+ private _useCustomViews: boolean;
57
+
58
+ onChange: ((value: boolean) => void) | null = null;
59
+
60
+ constructor(config: ToggleConfig = {}) {
61
+ super();
62
+
63
+ this._config = {
64
+ width: config.width ?? 52,
65
+ height: config.height ?? 28,
66
+ onColor: config.onColor ?? 0x22cc22,
67
+ offColor: config.offColor ?? 0x666666,
68
+ handleColor: config.handleColor ?? 0xffffff,
69
+ handleRadius: config.handleRadius ?? 0, // 0 = auto
70
+ animationDuration: config.animationDuration ?? 200,
71
+ };
72
+
73
+ this._value = config.value ?? false;
74
+ this.onChange = config.onChange ?? null;
75
+
76
+ const customOn = resolveView(config.onView);
77
+ const customOff = resolveView(config.offView);
78
+ this._useCustomViews = !!(customOn || customOff);
79
+
80
+ if (this._useCustomViews) {
81
+ // Custom view mode: show/hide ON and OFF views
82
+ if (customOn) {
83
+ this._onView = customOn;
84
+ this._onView.visible = this._value;
85
+ this.addChild(this._onView);
86
+ }
87
+ if (customOff) {
88
+ this._offView = customOff;
89
+ this._offView.visible = !this._value;
90
+ this.addChild(this._offView);
91
+ }
92
+ } else {
93
+ // Graphics mode: track + sliding handle
94
+ const { width, height, handleColor } = this._config;
95
+ const handleRadius = this._config.handleRadius || (height / 2 - 3);
96
+ this._config.handleRadius = handleRadius;
97
+
98
+ this._trackGfx = new Graphics();
99
+ this.addChild(this._trackGfx);
100
+ this._drawTrack();
101
+
102
+ const handle = new Graphics();
103
+ handle.circle(0, 0, handleRadius).fill(handleColor);
104
+ handle.y = height / 2;
105
+ handle.x = this._value ? width - handleRadius - 3 : handleRadius + 3;
106
+ this._handle = handle;
107
+ this.addChild(handle);
108
+ }
109
+
110
+ // Interaction
111
+ this.eventMode = 'static';
112
+ this.cursor = 'pointer';
113
+ this.on('pointertap', this._onTap, this);
114
+ }
115
+
116
+ /** Current toggle state */
117
+ get value(): boolean {
118
+ return this._value;
119
+ }
120
+
121
+ set value(v: boolean) {
122
+ if (v === this._value) return;
123
+ this.forceSwitch(v);
124
+ }
125
+
126
+ /** Programmatically switch to a specific state with animation */
127
+ forceSwitch(value: boolean): void {
128
+ this._value = value;
129
+ this._animateToState();
130
+ }
131
+
132
+ /** React reconciler update hook */
133
+ updateConfig(changed: Record<string, any>): void {
134
+ if ('value' in changed) this.value = changed.value;
135
+ if ('onChange' in changed) this.onChange = changed.onChange;
136
+ if ('animationDuration' in changed) this._config.animationDuration = changed.animationDuration;
137
+ }
138
+
139
+ private _onTap(): void {
140
+ this._value = !this._value;
141
+ this._animateToState();
142
+ this.onChange?.(this._value);
143
+ }
144
+
145
+ private _animateToState(): void {
146
+ const duration = this._config.animationDuration;
147
+
148
+ if (this._useCustomViews) {
149
+ // Custom views: crossfade
150
+ if (this._onView) {
151
+ Tween.killTweensOf(this._onView);
152
+ if (this._value) {
153
+ this._onView.visible = true;
154
+ Tween.to(this._onView, { alpha: 1 }, duration);
155
+ } else {
156
+ Tween.to(this._onView, { alpha: 0 }, duration).then(() => {
157
+ if (this._onView) this._onView.visible = false;
158
+ });
159
+ }
160
+ }
161
+ if (this._offView) {
162
+ Tween.killTweensOf(this._offView);
163
+ if (!this._value) {
164
+ this._offView.visible = true;
165
+ Tween.to(this._offView, { alpha: 1 }, duration);
166
+ } else {
167
+ Tween.to(this._offView, { alpha: 0 }, duration).then(() => {
168
+ if (this._offView) this._offView.visible = false;
169
+ });
170
+ }
171
+ }
172
+ } else {
173
+ // Graphics mode: slide handle + recolor track
174
+ this._drawTrack();
175
+ if (this._handle) {
176
+ const { width } = this._config;
177
+ const handleRadius = this._config.handleRadius;
178
+ const targetX = this._value ? width - handleRadius - 3 : handleRadius + 3;
179
+ Tween.killTweensOf(this._handle);
180
+ Tween.to(this._handle, { x: targetX }, duration);
181
+ }
182
+ }
183
+ }
184
+
185
+ private _drawTrack(): void {
186
+ if (!this._trackGfx) return;
187
+ const { width, height, onColor, offColor } = this._config;
188
+ const radius = height / 2;
189
+ this._trackGfx.clear();
190
+ this._trackGfx.roundRect(0, 0, width, height, radius).fill(this._value ? onColor : offColor);
191
+ }
192
+
193
+ override destroy(options?: boolean | { children?: boolean; texture?: boolean; textureSource?: boolean }): void {
194
+ this.off('pointertap', this._onTap, this);
195
+ if (this._handle) Tween.killTweensOf(this._handle);
196
+ if (this._onView) Tween.killTweensOf(this._onView);
197
+ if (this._offView) Tween.killTweensOf(this._offView);
198
+ this.onChange = null;
199
+ super.destroy(options);
200
+ }
201
+ }
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, FlexItemConfig } from './FlexContainer';
7
+ export type { FlexContainerConfig, FlexDirection, JustifyContent, AlignItems, AlignSelf, FlexItemConfig } from './FlexContainer';
8
8
  export { Button } from './Button';
9
9
  export type { ButtonConfig, ButtonState } from './Button';
10
10
  export { ProgressBar } from './ProgressBar';
@@ -25,3 +25,7 @@ export { Layout } from './Layout';
25
25
  export type { LayoutConfig, LayoutDirection, LayoutAlignment, LayoutAnchor } from './Layout';
26
26
  export { ScrollContainer } from './ScrollContainer';
27
27
  export type { ScrollContainerConfig, ScrollDirection } from './ScrollContainer';
28
+ export { Slider } from './Slider';
29
+ export type { SliderConfig } from './Slider';
30
+ export { Toggle } from './Toggle';
31
+ export type { ToggleConfig } from './Toggle';