@energy8platform/game-engine 0.3.0 → 0.5.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.
- package/README.md +197 -44
- package/dist/core.cjs.js +1 -0
- package/dist/core.cjs.js.map +1 -1
- package/dist/core.esm.js +1 -0
- package/dist/core.esm.js.map +1 -1
- package/dist/index.cjs.js +296 -787
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +56 -129
- package/dist/index.esm.js +297 -788
- package/dist/index.esm.js.map +1 -1
- package/dist/ui.cjs.js +613 -1104
- package/dist/ui.cjs.js.map +1 -1
- package/dist/ui.d.ts +55 -128
- package/dist/ui.esm.js +614 -1105
- package/dist/ui.esm.js.map +1 -1
- package/dist/vite.cjs.js +23 -3
- package/dist/vite.cjs.js.map +1 -1
- package/dist/vite.d.ts +1 -1
- package/dist/vite.esm.js +23 -3
- package/dist/vite.esm.js.map +1 -1
- package/package.json +17 -2
- package/src/core/GameApplication.ts +1 -0
- package/src/index.ts +11 -0
- package/src/ui/BalanceDisplay.ts +0 -3
- package/src/ui/Button.ts +71 -130
- package/src/ui/Layout.ts +109 -181
- package/src/ui/Modal.ts +6 -5
- package/src/ui/Panel.ts +52 -55
- package/src/ui/ProgressBar.ts +52 -57
- package/src/ui/ScrollContainer.ts +58 -489
- package/src/ui/Toast.ts +5 -9
- package/src/ui/index.ts +13 -0
- package/src/vite/index.ts +23 -3
package/src/ui/Button.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Graphics, Texture } from 'pixi.js';
|
|
2
|
+
import { FancyButton } from '@pixi/ui';
|
|
3
|
+
import type { ButtonOptions } from '@pixi/ui';
|
|
4
4
|
|
|
5
|
-
export type ButtonState = '
|
|
5
|
+
export type ButtonState = 'default' | 'hover' | 'pressed' | 'disabled';
|
|
6
6
|
|
|
7
7
|
export interface ButtonConfig {
|
|
8
8
|
/** Default texture/sprite for each state (optional — uses Graphics if not provided) */
|
|
@@ -21,179 +21,120 @@ export interface ButtonConfig {
|
|
|
21
21
|
animationDuration?: number;
|
|
22
22
|
/** Start disabled */
|
|
23
23
|
disabled?: boolean;
|
|
24
|
+
/** Button text */
|
|
25
|
+
text?: string;
|
|
26
|
+
/** Button text style */
|
|
27
|
+
textStyle?: Record<string, unknown>;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
const DEFAULT_COLORS: Record<ButtonState, number> = {
|
|
27
|
-
|
|
31
|
+
default: 0xffd700,
|
|
28
32
|
hover: 0xffe44d,
|
|
29
33
|
pressed: 0xccac00,
|
|
30
34
|
disabled: 0x666666,
|
|
31
35
|
};
|
|
32
36
|
|
|
37
|
+
function makeGraphicsView(
|
|
38
|
+
w: number, h: number, radius: number, color: number,
|
|
39
|
+
): Graphics {
|
|
40
|
+
const g = new Graphics();
|
|
41
|
+
g.roundRect(0, 0, w, h, radius).fill(color);
|
|
42
|
+
// Highlight overlay
|
|
43
|
+
g.roundRect(2, 2, w - 4, h * 0.45, radius).fill({ color: 0xffffff, alpha: 0.1 });
|
|
44
|
+
return g;
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
/**
|
|
34
|
-
* Interactive button component
|
|
48
|
+
* Interactive button component powered by `@pixi/ui` FancyButton.
|
|
35
49
|
*
|
|
36
|
-
* Supports both texture-based and Graphics-based rendering
|
|
50
|
+
* Supports both texture-based and Graphics-based rendering with
|
|
51
|
+
* per-state views, press animation, and text.
|
|
37
52
|
*
|
|
38
53
|
* @example
|
|
39
54
|
* ```ts
|
|
40
55
|
* const btn = new Button({
|
|
41
56
|
* width: 200, height: 60, borderRadius: 12,
|
|
42
|
-
* colors: {
|
|
57
|
+
* colors: { default: 0x22aa22, hover: 0x33cc33 },
|
|
58
|
+
* text: 'SPIN',
|
|
43
59
|
* });
|
|
44
60
|
*
|
|
45
|
-
* btn.
|
|
61
|
+
* btn.onPress.connect(() => console.log('Clicked!'));
|
|
46
62
|
* scene.container.addChild(btn);
|
|
47
63
|
* ```
|
|
48
64
|
*/
|
|
49
|
-
export class Button extends
|
|
50
|
-
private
|
|
51
|
-
private _bg: Graphics;
|
|
52
|
-
private _sprites: Partial<Record<ButtonState, Sprite>> = {};
|
|
53
|
-
private _config: Required<
|
|
65
|
+
export class Button extends FancyButton {
|
|
66
|
+
private _buttonConfig: Required<
|
|
54
67
|
Pick<ButtonConfig, 'width' | 'height' | 'borderRadius' | 'pressScale' | 'animationDuration'>
|
|
55
68
|
> & ButtonConfig;
|
|
56
69
|
|
|
57
|
-
/** Called when the button is tapped/clicked */
|
|
58
|
-
public onTap?: () => void;
|
|
59
|
-
|
|
60
|
-
/** Called when the button state changes */
|
|
61
|
-
public onStateChange?: (state: ButtonState) => void;
|
|
62
|
-
|
|
63
70
|
constructor(config: ButtonConfig = {}) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
pressScale: 0.95,
|
|
71
|
-
animationDuration: 100,
|
|
71
|
+
const resolvedConfig = {
|
|
72
|
+
width: config.width ?? 200,
|
|
73
|
+
height: config.height ?? 60,
|
|
74
|
+
borderRadius: config.borderRadius ?? 8,
|
|
75
|
+
pressScale: config.pressScale ?? 0.95,
|
|
76
|
+
animationDuration: config.animationDuration ?? 100,
|
|
72
77
|
...config,
|
|
73
78
|
};
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
const colorMap = { ...DEFAULT_COLORS, ...config.colors };
|
|
81
|
+
const { width, height, borderRadius } = resolvedConfig;
|
|
82
|
+
|
|
83
|
+
// Build FancyButton options
|
|
84
|
+
const options: ButtonOptions = {
|
|
85
|
+
anchor: 0.5,
|
|
86
|
+
animations: {
|
|
87
|
+
hover: {
|
|
88
|
+
props: { scale: { x: 1.03, y: 1.03 } },
|
|
89
|
+
duration: resolvedConfig.animationDuration,
|
|
90
|
+
},
|
|
91
|
+
pressed: {
|
|
92
|
+
props: { scale: { x: resolvedConfig.pressScale, y: resolvedConfig.pressScale } },
|
|
93
|
+
duration: resolvedConfig.animationDuration,
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
};
|
|
78
97
|
|
|
79
|
-
//
|
|
98
|
+
// Texture-based views
|
|
80
99
|
if (config.textures) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
100
|
+
if (config.textures.default) options.defaultView = config.textures.default as any;
|
|
101
|
+
if (config.textures.hover) options.hoverView = config.textures.hover as any;
|
|
102
|
+
if (config.textures.pressed) options.pressedView = config.textures.pressed as any;
|
|
103
|
+
if (config.textures.disabled) options.disabledView = config.textures.disabled as any;
|
|
104
|
+
} else {
|
|
105
|
+
// Graphics-based views
|
|
106
|
+
options.defaultView = makeGraphicsView(width, height, borderRadius, colorMap.default);
|
|
107
|
+
options.hoverView = makeGraphicsView(width, height, borderRadius, colorMap.hover);
|
|
108
|
+
options.pressedView = makeGraphicsView(width, height, borderRadius, colorMap.pressed);
|
|
109
|
+
options.disabledView = makeGraphicsView(width, height, borderRadius, colorMap.disabled);
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
// Set up hit area for Graphics-based
|
|
96
|
-
this.pivot.set(this._config.width / 2, this._config.height / 2);
|
|
112
|
+
// Text
|
|
113
|
+
if (config.text) {
|
|
114
|
+
options.text = config.text;
|
|
115
|
+
}
|
|
97
116
|
|
|
98
|
-
|
|
99
|
-
this.on('pointerover', this.onPointerOver);
|
|
100
|
-
this.on('pointerout', this.onPointerOut);
|
|
101
|
-
this.on('pointerdown', this.onPointerDown);
|
|
102
|
-
this.on('pointerup', this.onPointerUp);
|
|
103
|
-
this.on('pointertap', this.onPointerTap);
|
|
117
|
+
super(options);
|
|
104
118
|
|
|
105
|
-
|
|
106
|
-
this.setState('normal');
|
|
119
|
+
this._buttonConfig = resolvedConfig;
|
|
107
120
|
|
|
108
121
|
if (config.disabled) {
|
|
109
|
-
this.
|
|
122
|
+
this.enabled = false;
|
|
110
123
|
}
|
|
111
124
|
}
|
|
112
125
|
|
|
113
|
-
/** Current button state */
|
|
114
|
-
get state(): ButtonState {
|
|
115
|
-
return this._state;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
126
|
/** Enable the button */
|
|
119
127
|
enable(): void {
|
|
120
|
-
|
|
121
|
-
this.setState('normal');
|
|
122
|
-
this.eventMode = 'static';
|
|
123
|
-
this.cursor = 'pointer';
|
|
124
|
-
}
|
|
128
|
+
this.enabled = true;
|
|
125
129
|
}
|
|
126
130
|
|
|
127
131
|
/** Disable the button */
|
|
128
132
|
disable(): void {
|
|
129
|
-
this.
|
|
130
|
-
this.eventMode = 'none';
|
|
131
|
-
this.cursor = 'default';
|
|
133
|
+
this.enabled = false;
|
|
132
134
|
}
|
|
133
135
|
|
|
134
136
|
/** Whether the button is disabled */
|
|
135
137
|
get disabled(): boolean {
|
|
136
|
-
return this.
|
|
138
|
+
return !this.enabled;
|
|
137
139
|
}
|
|
138
|
-
|
|
139
|
-
private setState(state: ButtonState): void {
|
|
140
|
-
if (this._state === state) return;
|
|
141
|
-
this._state = state;
|
|
142
|
-
this.render();
|
|
143
|
-
this.onStateChange?.(state);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
private render(): void {
|
|
147
|
-
const { width, height, borderRadius, colors } = this._config;
|
|
148
|
-
const colorMap = { ...DEFAULT_COLORS, ...colors };
|
|
149
|
-
|
|
150
|
-
// Update Graphics
|
|
151
|
-
this._bg.clear();
|
|
152
|
-
this._bg.roundRect(0, 0, width, height, borderRadius).fill(colorMap[this._state]);
|
|
153
|
-
|
|
154
|
-
// Add highlight for normal/hover
|
|
155
|
-
if (this._state === 'normal' || this._state === 'hover') {
|
|
156
|
-
this._bg
|
|
157
|
-
.roundRect(2, 2, width - 4, height * 0.45, borderRadius)
|
|
158
|
-
.fill({ color: 0xffffff, alpha: 0.1 });
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Update sprite visibility
|
|
162
|
-
for (const [state, sprite] of Object.entries(this._sprites)) {
|
|
163
|
-
if (sprite) sprite.visible = state === this._state;
|
|
164
|
-
}
|
|
165
|
-
// Fall back to normal sprite if state sprite doesn't exist
|
|
166
|
-
if (!this._sprites[this._state] && this._sprites.normal) {
|
|
167
|
-
this._sprites.normal.visible = true;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private onPointerOver = (): void => {
|
|
172
|
-
if (this._state === 'disabled') return;
|
|
173
|
-
this.setState('hover');
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
private onPointerOut = (): void => {
|
|
177
|
-
if (this._state === 'disabled') return;
|
|
178
|
-
this.setState('normal');
|
|
179
|
-
Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration);
|
|
180
|
-
};
|
|
181
|
-
|
|
182
|
-
private onPointerDown = (): void => {
|
|
183
|
-
if (this._state === 'disabled') return;
|
|
184
|
-
this.setState('pressed');
|
|
185
|
-
const s = this._config.pressScale;
|
|
186
|
-
Tween.to(this.scale, { x: s, y: s }, this._config.animationDuration, Easing.easeOutQuad);
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
private onPointerUp = (): void => {
|
|
190
|
-
if (this._state === 'disabled') return;
|
|
191
|
-
this.setState('hover');
|
|
192
|
-
Tween.to(this.scale, { x: 1, y: 1 }, this._config.animationDuration, Easing.easeOutBack);
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
private onPointerTap = (): void => {
|
|
196
|
-
if (this._state === 'disabled') return;
|
|
197
|
-
this.onTap?.();
|
|
198
|
-
};
|
|
199
140
|
}
|
package/src/ui/Layout.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Container } from 'pixi.js';
|
|
2
|
+
import type { LayoutStyles } from '@pixi/layout';
|
|
2
3
|
|
|
3
4
|
// ─── Types ───────────────────────────────────────────────
|
|
4
5
|
|
|
@@ -38,8 +39,65 @@ export interface LayoutConfig {
|
|
|
38
39
|
breakpoints?: Record<number, Partial<LayoutConfig>>;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
// ─── Helpers ─────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const ALIGNMENT_MAP: Record<LayoutAlignment, LayoutStyles['alignItems']> = {
|
|
45
|
+
start: 'flex-start',
|
|
46
|
+
center: 'center',
|
|
47
|
+
end: 'flex-end',
|
|
48
|
+
stretch: 'stretch',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function normalizePadding(
|
|
52
|
+
padding: number | [number, number, number, number],
|
|
53
|
+
): [number, number, number, number] {
|
|
54
|
+
if (typeof padding === 'number') return [padding, padding, padding, padding];
|
|
55
|
+
return padding;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function directionToFlexStyles(
|
|
59
|
+
direction: LayoutDirection,
|
|
60
|
+
maxWidth: number,
|
|
61
|
+
): Partial<LayoutStyles> {
|
|
62
|
+
switch (direction) {
|
|
63
|
+
case 'horizontal':
|
|
64
|
+
return { flexDirection: 'row', flexWrap: 'nowrap' };
|
|
65
|
+
case 'vertical':
|
|
66
|
+
return { flexDirection: 'column', flexWrap: 'nowrap' };
|
|
67
|
+
case 'grid':
|
|
68
|
+
return { flexDirection: 'row', flexWrap: 'wrap' };
|
|
69
|
+
case 'wrap':
|
|
70
|
+
return {
|
|
71
|
+
flexDirection: 'row',
|
|
72
|
+
flexWrap: 'wrap',
|
|
73
|
+
...(maxWidth < Infinity ? { maxWidth } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildLayoutStyles(config: {
|
|
79
|
+
direction: LayoutDirection;
|
|
80
|
+
gap: number;
|
|
81
|
+
alignment: LayoutAlignment;
|
|
82
|
+
columns: number;
|
|
83
|
+
padding: [number, number, number, number];
|
|
84
|
+
maxWidth: number;
|
|
85
|
+
}): LayoutStyles {
|
|
86
|
+
const [pt, pr, pb, pl] = config.padding;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
...directionToFlexStyles(config.direction, config.maxWidth),
|
|
90
|
+
gap: config.gap,
|
|
91
|
+
alignItems: ALIGNMENT_MAP[config.alignment],
|
|
92
|
+
paddingTop: pt,
|
|
93
|
+
paddingRight: pr,
|
|
94
|
+
paddingBottom: pb,
|
|
95
|
+
paddingLeft: pl,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
41
99
|
/**
|
|
42
|
-
* Responsive layout container
|
|
100
|
+
* Responsive layout container powered by `@pixi/layout` (Yoga flexbox engine).
|
|
43
101
|
*
|
|
44
102
|
* Supports horizontal, vertical, grid, and wrap layout modes with
|
|
45
103
|
* alignment, padding, gap, and viewport-anchor positioning.
|
|
@@ -62,17 +120,15 @@ export interface LayoutConfig {
|
|
|
62
120
|
* toolbar.addItem(betLabel);
|
|
63
121
|
* scene.container.addChild(toolbar);
|
|
64
122
|
*
|
|
65
|
-
* // On resize, update layout position relative to viewport
|
|
66
123
|
* toolbar.updateViewport(width, height);
|
|
67
124
|
* ```
|
|
68
125
|
*/
|
|
69
126
|
export class Layout extends Container {
|
|
70
|
-
private
|
|
127
|
+
private _layoutConfig: Required<Pick<LayoutConfig, 'direction' | 'gap' | 'alignment' | 'autoLayout' | 'columns'>>;
|
|
71
128
|
private _padding: [number, number, number, number];
|
|
72
129
|
private _anchor: LayoutAnchor;
|
|
73
130
|
private _maxWidth: number;
|
|
74
131
|
private _breakpoints: [number, Partial<LayoutConfig>][];
|
|
75
|
-
private _content: Container;
|
|
76
132
|
private _items: Container[] = [];
|
|
77
133
|
private _viewportWidth = 0;
|
|
78
134
|
private _viewportHeight = 0;
|
|
@@ -80,7 +136,7 @@ export class Layout extends Container {
|
|
|
80
136
|
constructor(config: LayoutConfig = {}) {
|
|
81
137
|
super();
|
|
82
138
|
|
|
83
|
-
this.
|
|
139
|
+
this._layoutConfig = {
|
|
84
140
|
direction: config.direction ?? 'vertical',
|
|
85
141
|
gap: config.gap ?? 0,
|
|
86
142
|
alignment: config.alignment ?? 'start',
|
|
@@ -88,26 +144,28 @@ export class Layout extends Container {
|
|
|
88
144
|
columns: config.columns ?? 2,
|
|
89
145
|
};
|
|
90
146
|
|
|
91
|
-
this._padding =
|
|
147
|
+
this._padding = normalizePadding(config.padding ?? 0);
|
|
92
148
|
this._anchor = config.anchor ?? 'top-left';
|
|
93
149
|
this._maxWidth = config.maxWidth ?? Infinity;
|
|
94
150
|
|
|
95
|
-
// Sort breakpoints by width ascending for correct resolution
|
|
96
151
|
this._breakpoints = config.breakpoints
|
|
97
152
|
? Object.entries(config.breakpoints)
|
|
98
153
|
.map(([w, cfg]) => [Number(w), cfg] as [number, Partial<LayoutConfig>])
|
|
99
154
|
.sort((a, b) => a[0] - b[0])
|
|
100
155
|
: [];
|
|
101
156
|
|
|
102
|
-
this.
|
|
103
|
-
this.addChild(this._content);
|
|
157
|
+
this.applyLayoutStyles();
|
|
104
158
|
}
|
|
105
159
|
|
|
106
160
|
/** Add an item to the layout */
|
|
107
161
|
addItem(child: Container): this {
|
|
108
162
|
this._items.push(child);
|
|
109
|
-
this.
|
|
110
|
-
|
|
163
|
+
this.addChild(child);
|
|
164
|
+
|
|
165
|
+
if (this._layoutConfig.direction === 'grid') {
|
|
166
|
+
this.applyGridChildWidth(child);
|
|
167
|
+
}
|
|
168
|
+
|
|
111
169
|
return this;
|
|
112
170
|
}
|
|
113
171
|
|
|
@@ -116,8 +174,7 @@ export class Layout extends Container {
|
|
|
116
174
|
const idx = this._items.indexOf(child);
|
|
117
175
|
if (idx !== -1) {
|
|
118
176
|
this._items.splice(idx, 1);
|
|
119
|
-
this.
|
|
120
|
-
if (this._config.autoLayout) this.layout();
|
|
177
|
+
this.removeChild(child);
|
|
121
178
|
}
|
|
122
179
|
return this;
|
|
123
180
|
}
|
|
@@ -125,10 +182,9 @@ export class Layout extends Container {
|
|
|
125
182
|
/** Remove all items */
|
|
126
183
|
clearItems(): this {
|
|
127
184
|
for (const item of this._items) {
|
|
128
|
-
this.
|
|
185
|
+
this.removeChild(item);
|
|
129
186
|
}
|
|
130
187
|
this._items.length = 0;
|
|
131
|
-
if (this._config.autoLayout) this.layout();
|
|
132
188
|
return this;
|
|
133
189
|
}
|
|
134
190
|
|
|
@@ -144,192 +200,81 @@ export class Layout extends Container {
|
|
|
144
200
|
updateViewport(width: number, height: number): void {
|
|
145
201
|
this._viewportWidth = width;
|
|
146
202
|
this._viewportHeight = height;
|
|
147
|
-
this.
|
|
203
|
+
this.applyLayoutStyles();
|
|
204
|
+
this.applyAnchor();
|
|
148
205
|
}
|
|
149
206
|
|
|
150
|
-
|
|
151
|
-
* Recalculate layout positions of all children.
|
|
152
|
-
*/
|
|
153
|
-
layout(): void {
|
|
154
|
-
if (this._items.length === 0) return;
|
|
155
|
-
|
|
156
|
-
// Resolve effective config (apply breakpoint overrides)
|
|
207
|
+
private applyLayoutStyles(): void {
|
|
157
208
|
const effective = this.resolveConfig();
|
|
158
|
-
const
|
|
159
|
-
const
|
|
160
|
-
const alignment = effective.alignment ?? this.
|
|
161
|
-
const columns = effective.columns ?? this.
|
|
209
|
+
const direction = effective.direction ?? this._layoutConfig.direction;
|
|
210
|
+
const gap = effective.gap ?? this._layoutConfig.gap;
|
|
211
|
+
const alignment = effective.alignment ?? this._layoutConfig.alignment;
|
|
212
|
+
const columns = effective.columns ?? this._layoutConfig.columns;
|
|
162
213
|
const padding = effective.padding !== undefined
|
|
163
|
-
?
|
|
214
|
+
? normalizePadding(effective.padding)
|
|
164
215
|
: this._padding;
|
|
165
216
|
const maxWidth = effective.maxWidth ?? this._maxWidth;
|
|
166
217
|
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
switch (direction) {
|
|
170
|
-
case 'horizontal':
|
|
171
|
-
this.layoutLinear('x', 'y', gap, alignment, pl, pt);
|
|
172
|
-
break;
|
|
173
|
-
case 'vertical':
|
|
174
|
-
this.layoutLinear('y', 'x', gap, alignment, pt, pl);
|
|
175
|
-
break;
|
|
176
|
-
case 'grid':
|
|
177
|
-
this.layoutGrid(columns, gap, alignment, pl, pt);
|
|
178
|
-
break;
|
|
179
|
-
case 'wrap':
|
|
180
|
-
this.layoutWrap(maxWidth - pl - pr, gap, alignment, pl, pt);
|
|
181
|
-
break;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Apply anchor positioning relative to viewport
|
|
185
|
-
this.applyAnchor(effective.anchor ?? this._anchor);
|
|
186
|
-
}
|
|
218
|
+
const styles = buildLayoutStyles({ direction, gap, alignment, columns, padding, maxWidth });
|
|
219
|
+
this.layout = styles;
|
|
187
220
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
mainAxis: 'x' | 'y',
|
|
192
|
-
crossAxis: 'x' | 'y',
|
|
193
|
-
gap: number,
|
|
194
|
-
alignment: LayoutAlignment,
|
|
195
|
-
mainOffset: number,
|
|
196
|
-
crossOffset: number,
|
|
197
|
-
): void {
|
|
198
|
-
let pos = mainOffset;
|
|
199
|
-
const sizes = this._items.map(item => this.getItemSize(item));
|
|
200
|
-
const maxCross = Math.max(...sizes.map(s => (crossAxis === 'x' ? s.width : s.height)));
|
|
201
|
-
|
|
202
|
-
for (let i = 0; i < this._items.length; i++) {
|
|
203
|
-
const item = this._items[i];
|
|
204
|
-
const size = sizes[i];
|
|
205
|
-
|
|
206
|
-
item[mainAxis] = pos;
|
|
207
|
-
|
|
208
|
-
// Cross-axis alignment
|
|
209
|
-
const itemCross = crossAxis === 'x' ? size.width : size.height;
|
|
210
|
-
switch (alignment) {
|
|
211
|
-
case 'start':
|
|
212
|
-
item[crossAxis] = crossOffset;
|
|
213
|
-
break;
|
|
214
|
-
case 'center':
|
|
215
|
-
item[crossAxis] = crossOffset + (maxCross - itemCross) / 2;
|
|
216
|
-
break;
|
|
217
|
-
case 'end':
|
|
218
|
-
item[crossAxis] = crossOffset + maxCross - itemCross;
|
|
219
|
-
break;
|
|
220
|
-
case 'stretch':
|
|
221
|
-
item[crossAxis] = crossOffset;
|
|
222
|
-
// Note: stretch doesn't resize children — that's up to the item
|
|
223
|
-
break;
|
|
221
|
+
if (direction === 'grid') {
|
|
222
|
+
for (const item of this._items) {
|
|
223
|
+
this.applyGridChildWidth(item);
|
|
224
224
|
}
|
|
225
|
-
|
|
226
|
-
const mainSize = mainAxis === 'x' ? size.width : size.height;
|
|
227
|
-
pos += mainSize + gap;
|
|
228
225
|
}
|
|
229
226
|
}
|
|
230
227
|
|
|
231
|
-
private
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const row = Math.floor(i / columns);
|
|
248
|
-
const size = sizes[i];
|
|
249
|
-
|
|
250
|
-
// X alignment within cell
|
|
251
|
-
switch (alignment) {
|
|
252
|
-
case 'center':
|
|
253
|
-
item.x = offsetX + col * cellW + (maxItemWidth - size.width) / 2;
|
|
254
|
-
break;
|
|
255
|
-
case 'end':
|
|
256
|
-
item.x = offsetX + col * cellW + maxItemWidth - size.width;
|
|
257
|
-
break;
|
|
258
|
-
default:
|
|
259
|
-
item.x = offsetX + col * cellW;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
item.y = offsetY + row * cellH;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
private layoutWrap(
|
|
267
|
-
maxWidth: number,
|
|
268
|
-
gap: number,
|
|
269
|
-
alignment: LayoutAlignment,
|
|
270
|
-
offsetX: number,
|
|
271
|
-
offsetY: number,
|
|
272
|
-
): void {
|
|
273
|
-
let x = offsetX;
|
|
274
|
-
let y = offsetY;
|
|
275
|
-
let rowHeight = 0;
|
|
276
|
-
const sizes = this._items.map(item => this.getItemSize(item));
|
|
277
|
-
|
|
278
|
-
for (let i = 0; i < this._items.length; i++) {
|
|
279
|
-
const item = this._items[i];
|
|
280
|
-
const size = sizes[i];
|
|
281
|
-
|
|
282
|
-
// Check if item fits in current row
|
|
283
|
-
if (x + size.width > maxWidth + offsetX && x > offsetX) {
|
|
284
|
-
// Wrap to next row
|
|
285
|
-
x = offsetX;
|
|
286
|
-
y += rowHeight + gap;
|
|
287
|
-
rowHeight = 0;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
item.x = x;
|
|
291
|
-
item.y = y;
|
|
292
|
-
|
|
293
|
-
x += size.width + gap;
|
|
294
|
-
rowHeight = Math.max(rowHeight, size.height);
|
|
228
|
+
private applyGridChildWidth(child: Container): void {
|
|
229
|
+
const effective = this.resolveConfig();
|
|
230
|
+
const columns = effective.columns ?? this._layoutConfig.columns;
|
|
231
|
+
const gap = effective.gap ?? this._layoutConfig.gap;
|
|
232
|
+
|
|
233
|
+
// Account for gaps between columns: total gap space = gap * (columns - 1)
|
|
234
|
+
// Each column gets: (100% - total_gap) / columns
|
|
235
|
+
// We use flexBasis + flexGrow to let Yoga handle the math when gap > 0
|
|
236
|
+
const styles: Record<string, unknown> = gap > 0
|
|
237
|
+
? { flexBasis: 0, flexGrow: 1, flexShrink: 1, maxWidth: `${(100 / columns).toFixed(2)}%` }
|
|
238
|
+
: { width: `${(100 / columns).toFixed(2)}%` };
|
|
239
|
+
|
|
240
|
+
if (child._layout) {
|
|
241
|
+
child._layout.setStyle(styles);
|
|
242
|
+
} else {
|
|
243
|
+
child.layout = styles;
|
|
295
244
|
}
|
|
296
245
|
}
|
|
297
246
|
|
|
298
|
-
private applyAnchor(
|
|
247
|
+
private applyAnchor(): void {
|
|
248
|
+
const anchor = this.resolveConfig().anchor ?? this._anchor;
|
|
299
249
|
if (this._viewportWidth === 0 || this._viewportHeight === 0) return;
|
|
300
250
|
|
|
301
|
-
const bounds = this.
|
|
302
|
-
const contentW = bounds.width;
|
|
303
|
-
const contentH = bounds.height;
|
|
251
|
+
const bounds = this.getLocalBounds();
|
|
252
|
+
const contentW = bounds.width * this.scale.x;
|
|
253
|
+
const contentH = bounds.height * this.scale.y;
|
|
304
254
|
const vw = this._viewportWidth;
|
|
305
255
|
const vh = this._viewportHeight;
|
|
306
256
|
|
|
307
257
|
let anchorX = 0;
|
|
308
258
|
let anchorY = 0;
|
|
309
259
|
|
|
310
|
-
// Horizontal
|
|
311
260
|
if (anchor.includes('left')) {
|
|
312
261
|
anchorX = 0;
|
|
313
262
|
} else if (anchor.includes('right')) {
|
|
314
263
|
anchorX = vw - contentW;
|
|
315
264
|
} else {
|
|
316
|
-
// center
|
|
317
265
|
anchorX = (vw - contentW) / 2;
|
|
318
266
|
}
|
|
319
267
|
|
|
320
|
-
// Vertical
|
|
321
268
|
if (anchor.startsWith('top')) {
|
|
322
269
|
anchorY = 0;
|
|
323
270
|
} else if (anchor.startsWith('bottom')) {
|
|
324
271
|
anchorY = vh - contentH;
|
|
325
272
|
} else {
|
|
326
|
-
// center
|
|
327
273
|
anchorY = (vh - contentH) / 2;
|
|
328
274
|
}
|
|
329
275
|
|
|
330
|
-
|
|
331
|
-
this.
|
|
332
|
-
this.y = anchorY - bounds.y;
|
|
276
|
+
this.x = anchorX - bounds.x * this.scale.x;
|
|
277
|
+
this.y = anchorY - bounds.y * this.scale.y;
|
|
333
278
|
}
|
|
334
279
|
|
|
335
280
|
private resolveConfig(): Partial<LayoutConfig> {
|
|
@@ -337,28 +282,11 @@ export class Layout extends Container {
|
|
|
337
282
|
return {};
|
|
338
283
|
}
|
|
339
284
|
|
|
340
|
-
// Find the largest breakpoint that's ≤ current viewport width
|
|
341
|
-
let resolved: Partial<LayoutConfig> = {};
|
|
342
285
|
for (const [maxWidth, overrides] of this._breakpoints) {
|
|
343
286
|
if (this._viewportWidth <= maxWidth) {
|
|
344
|
-
|
|
345
|
-
break;
|
|
287
|
+
return overrides;
|
|
346
288
|
}
|
|
347
289
|
}
|
|
348
|
-
return
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
private getItemSize(item: Container): { width: number; height: number } {
|
|
352
|
-
const bounds = item.getBounds();
|
|
353
|
-
return { width: bounds.width, height: bounds.height };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
private static normalizePadding(
|
|
357
|
-
padding: number | [number, number, number, number],
|
|
358
|
-
): [number, number, number, number] {
|
|
359
|
-
if (typeof padding === 'number') {
|
|
360
|
-
return [padding, padding, padding, padding];
|
|
361
|
-
}
|
|
362
|
-
return padding;
|
|
290
|
+
return {};
|
|
363
291
|
}
|
|
364
292
|
}
|