@energy8platform/game-engine 0.2.0 → 0.3.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.
Files changed (59) hide show
  1. package/README.md +318 -49
  2. package/dist/animation.cjs.js +191 -1
  3. package/dist/animation.cjs.js.map +1 -1
  4. package/dist/animation.d.ts +117 -1
  5. package/dist/animation.esm.js +192 -3
  6. package/dist/animation.esm.js.map +1 -1
  7. package/dist/audio.cjs.js +66 -16
  8. package/dist/audio.cjs.js.map +1 -1
  9. package/dist/audio.d.ts +4 -0
  10. package/dist/audio.esm.js +66 -16
  11. package/dist/audio.esm.js.map +1 -1
  12. package/dist/core.cjs.js +310 -84
  13. package/dist/core.cjs.js.map +1 -1
  14. package/dist/core.d.ts +60 -1
  15. package/dist/core.esm.js +311 -85
  16. package/dist/core.esm.js.map +1 -1
  17. package/dist/debug.cjs.js +36 -68
  18. package/dist/debug.cjs.js.map +1 -1
  19. package/dist/debug.d.ts +4 -6
  20. package/dist/debug.esm.js +36 -68
  21. package/dist/debug.esm.js.map +1 -1
  22. package/dist/index.cjs.js +1250 -251
  23. package/dist/index.cjs.js.map +1 -1
  24. package/dist/index.d.ts +386 -41
  25. package/dist/index.esm.js +1250 -254
  26. package/dist/index.esm.js.map +1 -1
  27. package/dist/ui.cjs.js +757 -1
  28. package/dist/ui.cjs.js.map +1 -1
  29. package/dist/ui.d.ts +208 -2
  30. package/dist/ui.esm.js +756 -2
  31. package/dist/ui.esm.js.map +1 -1
  32. package/dist/vite.cjs.js +65 -68
  33. package/dist/vite.cjs.js.map +1 -1
  34. package/dist/vite.d.ts +17 -23
  35. package/dist/vite.esm.js +66 -68
  36. package/dist/vite.esm.js.map +1 -1
  37. package/package.json +4 -5
  38. package/src/animation/SpriteAnimation.ts +210 -0
  39. package/src/animation/Tween.ts +27 -1
  40. package/src/animation/index.ts +2 -0
  41. package/src/audio/AudioManager.ts +64 -15
  42. package/src/core/EventEmitter.ts +7 -1
  43. package/src/core/GameApplication.ts +25 -7
  44. package/src/core/SceneManager.ts +3 -1
  45. package/src/debug/DevBridge.ts +49 -80
  46. package/src/index.ts +6 -0
  47. package/src/input/InputManager.ts +26 -0
  48. package/src/loading/CSSPreloader.ts +7 -33
  49. package/src/loading/LoadingScene.ts +17 -41
  50. package/src/loading/index.ts +1 -0
  51. package/src/loading/logo.ts +95 -0
  52. package/src/types.ts +4 -0
  53. package/src/ui/BalanceDisplay.ts +14 -0
  54. package/src/ui/Button.ts +1 -1
  55. package/src/ui/Layout.ts +364 -0
  56. package/src/ui/ScrollContainer.ts +557 -0
  57. package/src/ui/index.ts +4 -0
  58. package/src/viewport/ViewportManager.ts +2 -0
  59. package/src/vite/index.ts +83 -83
package/dist/ui.cjs.js CHANGED
@@ -156,9 +156,20 @@ class Tween {
156
156
  }
157
157
  /**
158
158
  * Wait for a given duration (useful in timelines).
159
+ * Uses PixiJS Ticker for consistent timing with other tweens.
159
160
  */
160
161
  static delay(ms) {
161
- return new Promise((resolve) => setTimeout(resolve, ms));
162
+ return new Promise((resolve) => {
163
+ let elapsed = 0;
164
+ const onTick = (ticker) => {
165
+ elapsed += ticker.deltaMS;
166
+ if (elapsed >= ms) {
167
+ pixi_js.Ticker.shared.remove(onTick);
168
+ resolve();
169
+ }
170
+ };
171
+ pixi_js.Ticker.shared.add(onTick);
172
+ });
162
173
  }
163
174
  /**
164
175
  * Kill all tweens on a target.
@@ -185,6 +196,20 @@ class Tween {
185
196
  static get activeTweens() {
186
197
  return Tween._tweens.length;
187
198
  }
199
+ /**
200
+ * Reset the tween system — kill all tweens and remove the ticker.
201
+ * Useful for cleanup between game instances, tests, or hot-reload.
202
+ */
203
+ static reset() {
204
+ for (const tw of Tween._tweens) {
205
+ tw.resolve();
206
+ }
207
+ Tween._tweens.length = 0;
208
+ if (Tween._tickerAdded) {
209
+ pixi_js.Ticker.shared.remove(Tween.tick);
210
+ Tween._tickerAdded = false;
211
+ }
212
+ }
188
213
  // ─── Internal ──────────────────────────────────────────
189
214
  static ensureTicker() {
190
215
  if (Tween._tickerAdded)
@@ -696,6 +721,7 @@ class BalanceDisplay extends pixi_js.Container {
696
721
  _currentValue = 0;
697
722
  _displayedValue = 0;
698
723
  _animating = false;
724
+ _animationCancelled = false;
699
725
  constructor(config = {}) {
700
726
  super();
701
727
  this._config = {
@@ -757,11 +783,22 @@ class BalanceDisplay extends pixi_js.Container {
757
783
  this.updateDisplay();
758
784
  }
759
785
  async animateValue(from, to) {
786
+ // Cancel any ongoing animation
787
+ if (this._animating) {
788
+ this._animationCancelled = true;
789
+ }
760
790
  this._animating = true;
791
+ this._animationCancelled = false;
761
792
  const duration = this._config.animationDuration;
762
793
  const startTime = Date.now();
763
794
  return new Promise((resolve) => {
764
795
  const tick = () => {
796
+ // If cancelled by a newer animation, stop immediately
797
+ if (this._animationCancelled) {
798
+ this._animating = false;
799
+ resolve();
800
+ return;
801
+ }
765
802
  const elapsed = Date.now() - startTime;
766
803
  const t = Math.min(elapsed / duration, 1);
767
804
  const eased = Easing.easeOutCubic(t);
@@ -1070,12 +1107,731 @@ class Toast extends pixi_js.Container {
1070
1107
  }
1071
1108
  }
1072
1109
 
1110
+ /**
1111
+ * Responsive layout container that automatically arranges its children.
1112
+ *
1113
+ * Supports horizontal, vertical, grid, and wrap layout modes with
1114
+ * alignment, padding, gap, and viewport-anchor positioning.
1115
+ * Breakpoints allow different layouts for different screen sizes.
1116
+ *
1117
+ * @example
1118
+ * ```ts
1119
+ * const toolbar = new Layout({
1120
+ * direction: 'horizontal',
1121
+ * gap: 20,
1122
+ * alignment: 'center',
1123
+ * anchor: 'bottom-center',
1124
+ * padding: 16,
1125
+ * breakpoints: {
1126
+ * 768: { direction: 'vertical', gap: 10 },
1127
+ * },
1128
+ * });
1129
+ *
1130
+ * toolbar.addItem(spinButton);
1131
+ * toolbar.addItem(betLabel);
1132
+ * scene.container.addChild(toolbar);
1133
+ *
1134
+ * // On resize, update layout position relative to viewport
1135
+ * toolbar.updateViewport(width, height);
1136
+ * ```
1137
+ */
1138
+ class Layout extends pixi_js.Container {
1139
+ _config;
1140
+ _padding;
1141
+ _anchor;
1142
+ _maxWidth;
1143
+ _breakpoints;
1144
+ _content;
1145
+ _items = [];
1146
+ _viewportWidth = 0;
1147
+ _viewportHeight = 0;
1148
+ constructor(config = {}) {
1149
+ super();
1150
+ this._config = {
1151
+ direction: config.direction ?? 'vertical',
1152
+ gap: config.gap ?? 0,
1153
+ alignment: config.alignment ?? 'start',
1154
+ autoLayout: config.autoLayout ?? true,
1155
+ columns: config.columns ?? 2,
1156
+ };
1157
+ this._padding = Layout.normalizePadding(config.padding ?? 0);
1158
+ this._anchor = config.anchor ?? 'top-left';
1159
+ this._maxWidth = config.maxWidth ?? Infinity;
1160
+ // Sort breakpoints by width ascending for correct resolution
1161
+ this._breakpoints = config.breakpoints
1162
+ ? Object.entries(config.breakpoints)
1163
+ .map(([w, cfg]) => [Number(w), cfg])
1164
+ .sort((a, b) => a[0] - b[0])
1165
+ : [];
1166
+ this._content = new pixi_js.Container();
1167
+ this.addChild(this._content);
1168
+ }
1169
+ /** Add an item to the layout */
1170
+ addItem(child) {
1171
+ this._items.push(child);
1172
+ this._content.addChild(child);
1173
+ if (this._config.autoLayout)
1174
+ this.layout();
1175
+ return this;
1176
+ }
1177
+ /** Remove an item from the layout */
1178
+ removeItem(child) {
1179
+ const idx = this._items.indexOf(child);
1180
+ if (idx !== -1) {
1181
+ this._items.splice(idx, 1);
1182
+ this._content.removeChild(child);
1183
+ if (this._config.autoLayout)
1184
+ this.layout();
1185
+ }
1186
+ return this;
1187
+ }
1188
+ /** Remove all items */
1189
+ clearItems() {
1190
+ for (const item of this._items) {
1191
+ this._content.removeChild(item);
1192
+ }
1193
+ this._items.length = 0;
1194
+ if (this._config.autoLayout)
1195
+ this.layout();
1196
+ return this;
1197
+ }
1198
+ /** Get all layout items */
1199
+ get items() {
1200
+ return this._items;
1201
+ }
1202
+ /**
1203
+ * Update the viewport size and recalculate layout.
1204
+ * Should be called from `Scene.onResize()`.
1205
+ */
1206
+ updateViewport(width, height) {
1207
+ this._viewportWidth = width;
1208
+ this._viewportHeight = height;
1209
+ this.layout();
1210
+ }
1211
+ /**
1212
+ * Recalculate layout positions of all children.
1213
+ */
1214
+ layout() {
1215
+ if (this._items.length === 0)
1216
+ return;
1217
+ // Resolve effective config (apply breakpoint overrides)
1218
+ const effective = this.resolveConfig();
1219
+ const gap = effective.gap ?? this._config.gap;
1220
+ const direction = effective.direction ?? this._config.direction;
1221
+ const alignment = effective.alignment ?? this._config.alignment;
1222
+ const columns = effective.columns ?? this._config.columns;
1223
+ const padding = effective.padding !== undefined
1224
+ ? Layout.normalizePadding(effective.padding)
1225
+ : this._padding;
1226
+ const maxWidth = effective.maxWidth ?? this._maxWidth;
1227
+ const [pt, pr, pb, pl] = padding;
1228
+ switch (direction) {
1229
+ case 'horizontal':
1230
+ this.layoutLinear('x', 'y', gap, alignment, pl, pt);
1231
+ break;
1232
+ case 'vertical':
1233
+ this.layoutLinear('y', 'x', gap, alignment, pt, pl);
1234
+ break;
1235
+ case 'grid':
1236
+ this.layoutGrid(columns, gap, alignment, pl, pt);
1237
+ break;
1238
+ case 'wrap':
1239
+ this.layoutWrap(maxWidth - pl - pr, gap, alignment, pl, pt);
1240
+ break;
1241
+ }
1242
+ // Apply anchor positioning relative to viewport
1243
+ this.applyAnchor(effective.anchor ?? this._anchor);
1244
+ }
1245
+ // ─── Private layout helpers ────────────────────────────
1246
+ layoutLinear(mainAxis, crossAxis, gap, alignment, mainOffset, crossOffset) {
1247
+ let pos = mainOffset;
1248
+ const sizes = this._items.map(item => this.getItemSize(item));
1249
+ const maxCross = Math.max(...sizes.map(s => (crossAxis === 'x' ? s.width : s.height)));
1250
+ for (let i = 0; i < this._items.length; i++) {
1251
+ const item = this._items[i];
1252
+ const size = sizes[i];
1253
+ item[mainAxis] = pos;
1254
+ // Cross-axis alignment
1255
+ const itemCross = crossAxis === 'x' ? size.width : size.height;
1256
+ switch (alignment) {
1257
+ case 'start':
1258
+ item[crossAxis] = crossOffset;
1259
+ break;
1260
+ case 'center':
1261
+ item[crossAxis] = crossOffset + (maxCross - itemCross) / 2;
1262
+ break;
1263
+ case 'end':
1264
+ item[crossAxis] = crossOffset + maxCross - itemCross;
1265
+ break;
1266
+ case 'stretch':
1267
+ item[crossAxis] = crossOffset;
1268
+ // Note: stretch doesn't resize children — that's up to the item
1269
+ break;
1270
+ }
1271
+ const mainSize = mainAxis === 'x' ? size.width : size.height;
1272
+ pos += mainSize + gap;
1273
+ }
1274
+ }
1275
+ layoutGrid(columns, gap, alignment, offsetX, offsetY) {
1276
+ const sizes = this._items.map(item => this.getItemSize(item));
1277
+ const maxItemWidth = Math.max(...sizes.map(s => s.width));
1278
+ const maxItemHeight = Math.max(...sizes.map(s => s.height));
1279
+ const cellW = maxItemWidth + gap;
1280
+ const cellH = maxItemHeight + gap;
1281
+ for (let i = 0; i < this._items.length; i++) {
1282
+ const item = this._items[i];
1283
+ const col = i % columns;
1284
+ const row = Math.floor(i / columns);
1285
+ const size = sizes[i];
1286
+ // X alignment within cell
1287
+ switch (alignment) {
1288
+ case 'center':
1289
+ item.x = offsetX + col * cellW + (maxItemWidth - size.width) / 2;
1290
+ break;
1291
+ case 'end':
1292
+ item.x = offsetX + col * cellW + maxItemWidth - size.width;
1293
+ break;
1294
+ default:
1295
+ item.x = offsetX + col * cellW;
1296
+ }
1297
+ item.y = offsetY + row * cellH;
1298
+ }
1299
+ }
1300
+ layoutWrap(maxWidth, gap, alignment, offsetX, offsetY) {
1301
+ let x = offsetX;
1302
+ let y = offsetY;
1303
+ let rowHeight = 0;
1304
+ const sizes = this._items.map(item => this.getItemSize(item));
1305
+ for (let i = 0; i < this._items.length; i++) {
1306
+ const item = this._items[i];
1307
+ const size = sizes[i];
1308
+ // Check if item fits in current row
1309
+ if (x + size.width > maxWidth + offsetX && x > offsetX) {
1310
+ // Wrap to next row
1311
+ x = offsetX;
1312
+ y += rowHeight + gap;
1313
+ rowHeight = 0;
1314
+ }
1315
+ item.x = x;
1316
+ item.y = y;
1317
+ x += size.width + gap;
1318
+ rowHeight = Math.max(rowHeight, size.height);
1319
+ }
1320
+ }
1321
+ applyAnchor(anchor) {
1322
+ if (this._viewportWidth === 0 || this._viewportHeight === 0)
1323
+ return;
1324
+ const bounds = this._content.getBounds();
1325
+ const contentW = bounds.width;
1326
+ const contentH = bounds.height;
1327
+ const vw = this._viewportWidth;
1328
+ const vh = this._viewportHeight;
1329
+ let anchorX = 0;
1330
+ let anchorY = 0;
1331
+ // Horizontal
1332
+ if (anchor.includes('left')) {
1333
+ anchorX = 0;
1334
+ }
1335
+ else if (anchor.includes('right')) {
1336
+ anchorX = vw - contentW;
1337
+ }
1338
+ else {
1339
+ // center
1340
+ anchorX = (vw - contentW) / 2;
1341
+ }
1342
+ // Vertical
1343
+ if (anchor.startsWith('top')) {
1344
+ anchorY = 0;
1345
+ }
1346
+ else if (anchor.startsWith('bottom')) {
1347
+ anchorY = vh - contentH;
1348
+ }
1349
+ else {
1350
+ // center
1351
+ anchorY = (vh - contentH) / 2;
1352
+ }
1353
+ // Compensate for content's local bounds offset
1354
+ this.x = anchorX - bounds.x;
1355
+ this.y = anchorY - bounds.y;
1356
+ }
1357
+ resolveConfig() {
1358
+ if (this._breakpoints.length === 0 || this._viewportWidth === 0) {
1359
+ return {};
1360
+ }
1361
+ // Find the largest breakpoint that's ≤ current viewport width
1362
+ let resolved = {};
1363
+ for (const [maxWidth, overrides] of this._breakpoints) {
1364
+ if (this._viewportWidth <= maxWidth) {
1365
+ resolved = overrides;
1366
+ break;
1367
+ }
1368
+ }
1369
+ return resolved;
1370
+ }
1371
+ getItemSize(item) {
1372
+ const bounds = item.getBounds();
1373
+ return { width: bounds.width, height: bounds.height };
1374
+ }
1375
+ static normalizePadding(padding) {
1376
+ if (typeof padding === 'number') {
1377
+ return [padding, padding, padding, padding];
1378
+ }
1379
+ return padding;
1380
+ }
1381
+ }
1382
+
1383
+ /**
1384
+ * Scrollable container with touch/drag, mouse wheel, inertia, and optional scrollbar.
1385
+ *
1386
+ * Perfect for paytables, settings panels, bet history, and any scrollable content
1387
+ * that doesn't fit on screen.
1388
+ *
1389
+ * @example
1390
+ * ```ts
1391
+ * const scroll = new ScrollContainer({
1392
+ * width: 600,
1393
+ * height: 400,
1394
+ * direction: 'vertical',
1395
+ * showScrollbar: true,
1396
+ * elasticity: 0.3,
1397
+ * });
1398
+ *
1399
+ * // Add content taller than 400px
1400
+ * const list = new Container();
1401
+ * for (let i = 0; i < 50; i++) {
1402
+ * const row = createRow(i);
1403
+ * row.y = i * 40;
1404
+ * list.addChild(row);
1405
+ * }
1406
+ * scroll.setContent(list);
1407
+ *
1408
+ * scene.container.addChild(scroll);
1409
+ * ```
1410
+ */
1411
+ class ScrollContainer extends pixi_js.Container {
1412
+ _config;
1413
+ _viewport;
1414
+ _content = null;
1415
+ _mask;
1416
+ _bg;
1417
+ _scrollbarV = null;
1418
+ _scrollbarH = null;
1419
+ _scrollbarFadeTimeout = null;
1420
+ // Scroll state
1421
+ _scrollX = 0;
1422
+ _scrollY = 0;
1423
+ _velocityX = 0;
1424
+ _velocityY = 0;
1425
+ _isDragging = false;
1426
+ _dragStart = { x: 0, y: 0 };
1427
+ _scrollStart = { x: 0, y: 0 };
1428
+ _lastDragPos = { x: 0, y: 0 };
1429
+ _lastDragTime = 0;
1430
+ _isAnimating = false;
1431
+ _animationFrame = null;
1432
+ constructor(config) {
1433
+ super();
1434
+ this._config = {
1435
+ width: config.width,
1436
+ height: config.height,
1437
+ direction: config.direction ?? 'vertical',
1438
+ showScrollbar: config.showScrollbar ?? true,
1439
+ scrollbarWidth: config.scrollbarWidth ?? 6,
1440
+ scrollbarColor: config.scrollbarColor ?? 0xffffff,
1441
+ scrollbarAlpha: config.scrollbarAlpha ?? 0.4,
1442
+ elasticity: config.elasticity ?? 0.3,
1443
+ inertia: config.inertia ?? 0.92,
1444
+ snapSize: config.snapSize ?? 0,
1445
+ borderRadius: config.borderRadius ?? 0,
1446
+ };
1447
+ // Background
1448
+ this._bg = new pixi_js.Graphics();
1449
+ if (config.backgroundColor !== undefined) {
1450
+ this._bg.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
1451
+ .fill({ color: config.backgroundColor, alpha: config.backgroundAlpha ?? 1 });
1452
+ }
1453
+ this.addChild(this._bg);
1454
+ // Viewport (masked area)
1455
+ this._viewport = new pixi_js.Container();
1456
+ this.addChild(this._viewport);
1457
+ // Mask
1458
+ this._mask = new pixi_js.Graphics();
1459
+ this._mask.roundRect(0, 0, config.width, config.height, this._config.borderRadius)
1460
+ .fill(0xffffff);
1461
+ this.addChild(this._mask);
1462
+ this._viewport.mask = this._mask;
1463
+ // Scrollbars
1464
+ if (this._config.showScrollbar) {
1465
+ if (this._config.direction !== 'horizontal') {
1466
+ this._scrollbarV = new pixi_js.Graphics();
1467
+ this._scrollbarV.alpha = 0;
1468
+ this.addChild(this._scrollbarV);
1469
+ }
1470
+ if (this._config.direction !== 'vertical') {
1471
+ this._scrollbarH = new pixi_js.Graphics();
1472
+ this._scrollbarH.alpha = 0;
1473
+ this.addChild(this._scrollbarH);
1474
+ }
1475
+ }
1476
+ // Interaction
1477
+ this.eventMode = 'static';
1478
+ this.cursor = 'grab';
1479
+ this.hitArea = { contains: (x, y) => x >= 0 && x <= config.width && y >= 0 && y <= config.height };
1480
+ this.on('pointerdown', this.onPointerDown);
1481
+ this.on('pointermove', this.onPointerMove);
1482
+ this.on('pointerup', this.onPointerUp);
1483
+ this.on('pointerupoutside', this.onPointerUp);
1484
+ this.on('wheel', this.onWheel);
1485
+ }
1486
+ /** Set scrollable content. Replaces any existing content. */
1487
+ setContent(content) {
1488
+ if (this._content) {
1489
+ this._viewport.removeChild(this._content);
1490
+ }
1491
+ this._content = content;
1492
+ this._viewport.addChild(content);
1493
+ this._scrollX = 0;
1494
+ this._scrollY = 0;
1495
+ this.applyScroll();
1496
+ }
1497
+ /** Get the content container */
1498
+ get content() {
1499
+ return this._content;
1500
+ }
1501
+ /** Scroll to a specific position (in content coordinates) */
1502
+ scrollTo(x, y, animate = true) {
1503
+ if (!animate) {
1504
+ this._scrollX = x;
1505
+ this._scrollY = y;
1506
+ this.clampScroll();
1507
+ this.applyScroll();
1508
+ return;
1509
+ }
1510
+ this.animateScrollTo(x, y);
1511
+ }
1512
+ /** Scroll to make a specific item/child visible */
1513
+ scrollToItem(index) {
1514
+ if (this._config.snapSize > 0) {
1515
+ const pos = index * this._config.snapSize;
1516
+ if (this._config.direction === 'horizontal') {
1517
+ this.scrollTo(pos, this._scrollY);
1518
+ }
1519
+ else {
1520
+ this.scrollTo(this._scrollX, pos);
1521
+ }
1522
+ }
1523
+ }
1524
+ /** Current scroll position */
1525
+ get scrollPosition() {
1526
+ return { x: this._scrollX, y: this._scrollY };
1527
+ }
1528
+ /** Resize the scroll viewport */
1529
+ resize(width, height) {
1530
+ this._config.width = width;
1531
+ this._config.height = height;
1532
+ // Redraw mask and background
1533
+ this._mask.clear();
1534
+ this._mask.roundRect(0, 0, width, height, this._config.borderRadius).fill(0xffffff);
1535
+ this._bg.clear();
1536
+ this.hitArea = { contains: (x, y) => x >= 0 && x <= width && y >= 0 && y <= height };
1537
+ this.clampScroll();
1538
+ this.applyScroll();
1539
+ }
1540
+ /** Destroy and clean up */
1541
+ destroy(options) {
1542
+ this.stopAnimation();
1543
+ if (this._scrollbarFadeTimeout !== null) {
1544
+ clearTimeout(this._scrollbarFadeTimeout);
1545
+ }
1546
+ this.off('pointerdown', this.onPointerDown);
1547
+ this.off('pointermove', this.onPointerMove);
1548
+ this.off('pointerup', this.onPointerUp);
1549
+ this.off('pointerupoutside', this.onPointerUp);
1550
+ this.off('wheel', this.onWheel);
1551
+ super.destroy(options);
1552
+ }
1553
+ // ─── Scroll mechanics ─────────────────────────────────
1554
+ get contentWidth() {
1555
+ if (!this._content)
1556
+ return 0;
1557
+ const bounds = this._content.getBounds();
1558
+ return bounds.width;
1559
+ }
1560
+ get contentHeight() {
1561
+ if (!this._content)
1562
+ return 0;
1563
+ const bounds = this._content.getBounds();
1564
+ return bounds.height;
1565
+ }
1566
+ get maxScrollX() {
1567
+ return Math.max(0, this.contentWidth - this._config.width);
1568
+ }
1569
+ get maxScrollY() {
1570
+ return Math.max(0, this.contentHeight - this._config.height);
1571
+ }
1572
+ canScrollX() {
1573
+ return this._config.direction === 'horizontal' || this._config.direction === 'both';
1574
+ }
1575
+ canScrollY() {
1576
+ return this._config.direction === 'vertical' || this._config.direction === 'both';
1577
+ }
1578
+ clampScroll() {
1579
+ if (this.canScrollX()) {
1580
+ this._scrollX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
1581
+ }
1582
+ else {
1583
+ this._scrollX = 0;
1584
+ }
1585
+ if (this.canScrollY()) {
1586
+ this._scrollY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
1587
+ }
1588
+ else {
1589
+ this._scrollY = 0;
1590
+ }
1591
+ }
1592
+ applyScroll() {
1593
+ if (!this._content)
1594
+ return;
1595
+ this._content.x = -this._scrollX;
1596
+ this._content.y = -this._scrollY;
1597
+ this.updateScrollbars();
1598
+ }
1599
+ // ─── Input handlers ────────────────────────────────────
1600
+ onPointerDown = (e) => {
1601
+ this._isDragging = true;
1602
+ this._isAnimating = false;
1603
+ this.stopAnimation();
1604
+ this.cursor = 'grabbing';
1605
+ const local = e.getLocalPosition(this);
1606
+ this._dragStart = { x: local.x, y: local.y };
1607
+ this._scrollStart = { x: this._scrollX, y: this._scrollY };
1608
+ this._lastDragPos = { x: local.x, y: local.y };
1609
+ this._lastDragTime = Date.now();
1610
+ this._velocityX = 0;
1611
+ this._velocityY = 0;
1612
+ this.showScrollbars();
1613
+ };
1614
+ onPointerMove = (e) => {
1615
+ if (!this._isDragging)
1616
+ return;
1617
+ const local = e.getLocalPosition(this);
1618
+ const dx = local.x - this._dragStart.x;
1619
+ const dy = local.y - this._dragStart.y;
1620
+ const now = Date.now();
1621
+ const dt = Math.max(1, now - this._lastDragTime);
1622
+ // Calculate velocity for inertia
1623
+ this._velocityX = (local.x - this._lastDragPos.x) / dt * 16; // normalize to ~60fps
1624
+ this._velocityY = (local.y - this._lastDragPos.y) / dt * 16;
1625
+ this._lastDragPos = { x: local.x, y: local.y };
1626
+ this._lastDragTime = now;
1627
+ // Apply scroll with elasticity for overscroll
1628
+ let newX = this._scrollStart.x - dx;
1629
+ let newY = this._scrollStart.y - dy;
1630
+ const elasticity = this._config.elasticity;
1631
+ if (this.canScrollX()) {
1632
+ if (newX < 0)
1633
+ newX *= elasticity;
1634
+ else if (newX > this.maxScrollX)
1635
+ newX = this.maxScrollX + (newX - this.maxScrollX) * elasticity;
1636
+ this._scrollX = newX;
1637
+ }
1638
+ if (this.canScrollY()) {
1639
+ if (newY < 0)
1640
+ newY *= elasticity;
1641
+ else if (newY > this.maxScrollY)
1642
+ newY = this.maxScrollY + (newY - this.maxScrollY) * elasticity;
1643
+ this._scrollY = newY;
1644
+ }
1645
+ this.applyScroll();
1646
+ };
1647
+ onPointerUp = () => {
1648
+ if (!this._isDragging)
1649
+ return;
1650
+ this._isDragging = false;
1651
+ this.cursor = 'grab';
1652
+ // Start inertia
1653
+ if (Math.abs(this._velocityX) > 0.5 || Math.abs(this._velocityY) > 0.5) {
1654
+ this.startInertia();
1655
+ }
1656
+ else {
1657
+ this.snapAndBounce();
1658
+ }
1659
+ };
1660
+ onWheel = (e) => {
1661
+ e.preventDefault?.();
1662
+ const delta = e.deltaY ?? 0;
1663
+ const deltaX = e.deltaX ?? 0;
1664
+ if (this.canScrollY()) {
1665
+ this._scrollY += delta * 0.5;
1666
+ }
1667
+ if (this.canScrollX()) {
1668
+ this._scrollX += deltaX * 0.5;
1669
+ }
1670
+ this.clampScroll();
1671
+ this.applyScroll();
1672
+ this.showScrollbars();
1673
+ this.scheduleScrollbarFade();
1674
+ };
1675
+ // ─── Inertia & snap ───────────────────────────────────
1676
+ startInertia() {
1677
+ this._isAnimating = true;
1678
+ const tick = () => {
1679
+ if (!this._isAnimating)
1680
+ return;
1681
+ this._velocityX *= this._config.inertia;
1682
+ this._velocityY *= this._config.inertia;
1683
+ if (this.canScrollX())
1684
+ this._scrollX -= this._velocityX;
1685
+ if (this.canScrollY())
1686
+ this._scrollY -= this._velocityY;
1687
+ // Bounce back if overscrolled
1688
+ let bounced = false;
1689
+ if (this.canScrollX()) {
1690
+ if (this._scrollX < 0) {
1691
+ this._scrollX *= 0.8;
1692
+ bounced = true;
1693
+ }
1694
+ else if (this._scrollX > this.maxScrollX) {
1695
+ this._scrollX = this.maxScrollX + (this._scrollX - this.maxScrollX) * 0.8;
1696
+ bounced = true;
1697
+ }
1698
+ }
1699
+ if (this.canScrollY()) {
1700
+ if (this._scrollY < 0) {
1701
+ this._scrollY *= 0.8;
1702
+ bounced = true;
1703
+ }
1704
+ else if (this._scrollY > this.maxScrollY) {
1705
+ this._scrollY = this.maxScrollY + (this._scrollY - this.maxScrollY) * 0.8;
1706
+ bounced = true;
1707
+ }
1708
+ }
1709
+ this.applyScroll();
1710
+ const speed = Math.abs(this._velocityX) + Math.abs(this._velocityY);
1711
+ if (speed < 0.1 && !bounced) {
1712
+ this._isAnimating = false;
1713
+ this.snapAndBounce();
1714
+ return;
1715
+ }
1716
+ this._animationFrame = requestAnimationFrame(tick);
1717
+ };
1718
+ this._animationFrame = requestAnimationFrame(tick);
1719
+ }
1720
+ snapAndBounce() {
1721
+ // Clamp first
1722
+ let targetX = Math.max(0, Math.min(this._scrollX, this.maxScrollX));
1723
+ let targetY = Math.max(0, Math.min(this._scrollY, this.maxScrollY));
1724
+ // Snap
1725
+ if (this._config.snapSize > 0) {
1726
+ if (this.canScrollY()) {
1727
+ targetY = Math.round(targetY / this._config.snapSize) * this._config.snapSize;
1728
+ targetY = Math.max(0, Math.min(targetY, this.maxScrollY));
1729
+ }
1730
+ if (this.canScrollX()) {
1731
+ targetX = Math.round(targetX / this._config.snapSize) * this._config.snapSize;
1732
+ targetX = Math.max(0, Math.min(targetX, this.maxScrollX));
1733
+ }
1734
+ }
1735
+ if (Math.abs(targetX - this._scrollX) < 0.5 && Math.abs(targetY - this._scrollY) < 0.5) {
1736
+ this._scrollX = targetX;
1737
+ this._scrollY = targetY;
1738
+ this.applyScroll();
1739
+ this.scheduleScrollbarFade();
1740
+ return;
1741
+ }
1742
+ this.animateScrollTo(targetX, targetY);
1743
+ }
1744
+ animateScrollTo(targetX, targetY) {
1745
+ this._isAnimating = true;
1746
+ const startX = this._scrollX;
1747
+ const startY = this._scrollY;
1748
+ const startTime = Date.now();
1749
+ const duration = 300;
1750
+ const tick = () => {
1751
+ if (!this._isAnimating)
1752
+ return;
1753
+ const elapsed = Date.now() - startTime;
1754
+ const t = Math.min(elapsed / duration, 1);
1755
+ // easeOutCubic
1756
+ const eased = 1 - Math.pow(1 - t, 3);
1757
+ this._scrollX = startX + (targetX - startX) * eased;
1758
+ this._scrollY = startY + (targetY - startY) * eased;
1759
+ this.applyScroll();
1760
+ if (t < 1) {
1761
+ this._animationFrame = requestAnimationFrame(tick);
1762
+ }
1763
+ else {
1764
+ this._isAnimating = false;
1765
+ this.scheduleScrollbarFade();
1766
+ }
1767
+ };
1768
+ this._animationFrame = requestAnimationFrame(tick);
1769
+ }
1770
+ stopAnimation() {
1771
+ this._isAnimating = false;
1772
+ if (this._animationFrame !== null) {
1773
+ cancelAnimationFrame(this._animationFrame);
1774
+ this._animationFrame = null;
1775
+ }
1776
+ }
1777
+ // ─── Scrollbars ────────────────────────────────────────
1778
+ updateScrollbars() {
1779
+ const { width, height, scrollbarWidth, scrollbarColor, scrollbarAlpha } = this._config;
1780
+ if (this._scrollbarV && this.canScrollY() && this.contentHeight > height) {
1781
+ const ratio = height / this.contentHeight;
1782
+ const barH = Math.max(20, height * ratio);
1783
+ const barY = (this._scrollY / this.maxScrollY) * (height - barH);
1784
+ this._scrollbarV.clear();
1785
+ this._scrollbarV.roundRect(width - scrollbarWidth - 2, Math.max(0, barY), scrollbarWidth, barH, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
1786
+ }
1787
+ if (this._scrollbarH && this.canScrollX() && this.contentWidth > width) {
1788
+ const ratio = width / this.contentWidth;
1789
+ const barW = Math.max(20, width * ratio);
1790
+ const barX = (this._scrollX / this.maxScrollX) * (width - barW);
1791
+ this._scrollbarH.clear();
1792
+ this._scrollbarH.roundRect(Math.max(0, barX), height - scrollbarWidth - 2, barW, scrollbarWidth, scrollbarWidth / 2).fill({ color: scrollbarColor, alpha: scrollbarAlpha });
1793
+ }
1794
+ }
1795
+ showScrollbars() {
1796
+ if (this._scrollbarV)
1797
+ this._scrollbarV.alpha = 1;
1798
+ if (this._scrollbarH)
1799
+ this._scrollbarH.alpha = 1;
1800
+ }
1801
+ scheduleScrollbarFade() {
1802
+ if (this._scrollbarFadeTimeout !== null) {
1803
+ clearTimeout(this._scrollbarFadeTimeout);
1804
+ }
1805
+ this._scrollbarFadeTimeout = window.setTimeout(() => {
1806
+ this.fadeScrollbars();
1807
+ }, 1000);
1808
+ }
1809
+ fadeScrollbars() {
1810
+ const duration = 300;
1811
+ const startTime = Date.now();
1812
+ const startAlphaV = this._scrollbarV?.alpha ?? 0;
1813
+ const startAlphaH = this._scrollbarH?.alpha ?? 0;
1814
+ const tick = () => {
1815
+ const t = Math.min((Date.now() - startTime) / duration, 1);
1816
+ if (this._scrollbarV)
1817
+ this._scrollbarV.alpha = startAlphaV * (1 - t);
1818
+ if (this._scrollbarH)
1819
+ this._scrollbarH.alpha = startAlphaH * (1 - t);
1820
+ if (t < 1)
1821
+ requestAnimationFrame(tick);
1822
+ };
1823
+ requestAnimationFrame(tick);
1824
+ }
1825
+ }
1826
+
1073
1827
  exports.BalanceDisplay = BalanceDisplay;
1074
1828
  exports.Button = Button;
1075
1829
  exports.Label = Label;
1830
+ exports.Layout = Layout;
1076
1831
  exports.Modal = Modal;
1077
1832
  exports.Panel = Panel;
1078
1833
  exports.ProgressBar = ProgressBar;
1834
+ exports.ScrollContainer = ScrollContainer;
1079
1835
  exports.Toast = Toast;
1080
1836
  exports.WinDisplay = WinDisplay;
1081
1837
  //# sourceMappingURL=ui.cjs.js.map