littlejsengine 1.19.3 → 1.21.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 (54) hide show
  1. package/FAQ.md +841 -820
  2. package/README.md +29 -25
  3. package/dist/littlejs.d.ts +2857 -955
  4. package/dist/littlejs.esm.js +33428 -20995
  5. package/dist/littlejs.esm.min.js +4 -3
  6. package/dist/littlejs.js +33386 -20996
  7. package/dist/littlejs.min.js +2 -2
  8. package/dist/littlejs.release.js +27841 -20422
  9. package/package.json +10 -5
  10. package/plugins/audioEffects.js +28 -24
  11. package/plugins/box2d.js +2516 -2073
  12. package/plugins/drawUtilities.js +206 -31
  13. package/plugins/gltf.js +786 -0
  14. package/plugins/jsconfig.json +19 -0
  15. package/plugins/lightSystem.js +719 -332
  16. package/plugins/math3d.js +148 -27
  17. package/plugins/medalSystem.js +326 -280
  18. package/plugins/newgrounds.js +400 -199
  19. package/plugins/pathFinder.js +208 -175
  20. package/plugins/pluginExport.js +25 -0
  21. package/plugins/postProcess.js +96 -24
  22. package/plugins/render3d.js +2067 -1632
  23. package/plugins/render3dDebug.js +110 -0
  24. package/plugins/render3dEditor.js +1812 -0
  25. package/plugins/render3dExtras.js +1948 -0
  26. package/plugins/render3dLevel.js +189 -0
  27. package/plugins/render3dVoxels.js +514 -0
  28. package/plugins/sceneSystem.js +58 -0
  29. package/plugins/textureSheet.js +496 -458
  30. package/plugins/threejs.js +183 -163
  31. package/plugins/tweakables.js +411 -0
  32. package/plugins/tweenSystem.js +265 -145
  33. package/plugins/uiSystem.js +2024 -1687
  34. package/plugins/zzfxm.js +159 -159
  35. package/src/engine.js +378 -97
  36. package/src/engineAudio.js +231 -127
  37. package/src/engineBuild.mjs +292 -256
  38. package/src/engineDebug.js +967 -743
  39. package/src/engineDraw.js +391 -198
  40. package/src/engineEditor.js +2452 -0
  41. package/src/engineExport.js +18 -0
  42. package/src/engineInput.js +1466 -1293
  43. package/src/engineLogo.js +19 -0
  44. package/src/engineMath.js +1274 -1252
  45. package/src/engineObject.js +197 -82
  46. package/src/engineParticles.js +583 -532
  47. package/src/engineRelease.js +79 -45
  48. package/src/engineSettings.js +115 -29
  49. package/src/engineTileLayer.js +995 -741
  50. package/src/engineUtilities.js +19 -13
  51. package/src/engineWebGL.js +239 -82
  52. package/src/jsconfig.json +17 -13
  53. package/plugins/box2d.wasm.js +0 -630
  54. package/plugins/box2d.wasm.wasm +0 -0
@@ -1,1688 +1,2025 @@
1
- /**
2
- * LittleJS User Interface Plugin
3
- * - call new UISystemPlugin() to setup the UI system
4
- * - Gamepad and keyboard navigation support
5
- * - Nested Menus
6
- * - Text
7
- * - Buttons
8
- * - Checkboxes
9
- * - Images
10
- * - Sliders
11
- * - Video
12
- * @namespace UISystem
13
- */
14
-
15
- 'use strict';
16
-
17
- ///////////////////////////////////////////////////////////////////////////////
18
-
19
- /** Global UI system plugin object
20
- * @type {UISystemPlugin}
21
- * @memberof UISystem */
22
- let uiSystem;
23
-
24
- /** Enable UI system debug drawing
25
- * 0=off, 1=normal, 2=show invisible
26
- * @type {number}
27
- * @default
28
- * @memberof UISystem */
29
- let uiDebug = 0;
30
-
31
- /** Enable UI system debug drawing
32
- * 0=off, 1=normal, 2=show invisible
33
- * @param {number|boolean} debugMode
34
- * @memberof UISystem */
35
- function uiSetDebug(debugMode)
36
- { uiDebug = typeof debugMode === 'boolean' ? (debugMode ? 1 : 0) : debugMode; }
37
-
38
- ///////////////////////////////////////////////////////////////////////////////
39
- /**
40
- * UI System Global Object
41
- * @memberof UISystem
42
- */
43
- class UISystemPlugin
44
- {
45
- /** Create the global UI system object
46
- * @param {CanvasRenderingContext2D} [context]
47
- * @example
48
- * // create the ui plugin object
49
- * new UISystemPlugin;
50
- */
51
- constructor(context=mainContext)
52
- {
53
- ASSERT(!uiSystem, 'UI system already initialized');
54
- uiSystem = this;
55
-
56
- // default settings
57
- /** @property {boolean} - Activate when mouse is pressed down instead of clicked */
58
- this.activateOnPress = false;
59
- /** @property {Color} - Default fill color for UI elements */
60
- this.defaultColor = WHITE;
61
- /** @property {Color} - Default outline color for UI elements */
62
- this.defaultLineColor = BLACK;
63
- /** @property {Color} - Default text color for UI elements */
64
- this.defaultTextColor = BLACK;
65
- /** @property {Color} - Default button color for UI elements */
66
- this.defaultButtonColor = hsl(0,0,.7);
67
- /** @property {Color} - Default hover color for UI elements */
68
- this.defaultHoverColor = hsl(0,0,.9);
69
- /** @property {Color} - Default color for disabled UI elements */
70
- this.defaultDisabledColor = hsl(0,0,.3);
71
- /** @property {Color} - Uses a gradient fill combined with color */
72
- this.defaultGradientColor = undefined;
73
- /** @property {number} - Default line width for UI elements */
74
- this.defaultLineWidth = 4;
75
- /** @property {number} - Default rounded rect corner radius for UI elements */
76
- this.defaultCornerRadius = 0;
77
- /** @property {number} - Default scale to use for fitting text to object */
78
- this.defaultTextFitScale = .8;
79
- /** @property {string} - Default font for UI elements */
80
- this.defaultFont = fontDefault;
81
- /** @property {Sound} - Default sound when interactive UI element is pressed */
82
- this.defaultSoundPress = undefined;
83
- /** @property {Sound} - Default sound when interactive UI element is released */
84
- this.defaultSoundRelease = undefined;
85
- /** @property {Sound} - Default sound when interactive UI element is clicked */
86
- this.defaultSoundClick = undefined;
87
- /** @property {Color} - Color for shadow */
88
- this.defaultShadowColor = CLEAR_BLACK;
89
- /** @property {number} - Size of shadow blur */
90
- this.defaultShadowBlur = 5;
91
- /** @property {Vector2} - Offset of shadow blur */
92
- this.defaultShadowOffset = vec2(5);
93
- /** @property {number} - If set ui coords will be renormalized to this canvas height */
94
- this.nativeHeight = 0;
95
-
96
- // navigation properties
97
- /** @property {UIObject} - Object currently selected by navigation (gamepad or keyboard) */
98
- this.navigationObject = undefined;
99
- /** @property {Timer} - Cool down timer for navigation inputs */
100
- this.navigationTimer = new Timer(undefined, true);
101
- /** @property {number} - Time between navigation inputs in seconds */
102
- this.navigationDelay = .2;
103
- /** @property {boolean} - should the navigation be horizontal, vertical, or both? */
104
- this.navigationDirection = 1;
105
- /** @property {boolean} - True if user last used navigation instead of mouse */
106
- this.navigationMode = false;
107
-
108
- // system state
109
- /** @property {Array<UIObject>} - List of all UI elements */
110
- this.uiObjects = [];
111
- /** @property {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} - Context to render UI elements to */
112
- this.uiContext = context;
113
- /** @property {UIObject} - Object user is currently interacting with */
114
- this.activeObject = undefined;
115
- /** @property {UIObject} - Top most object user is over */
116
- this.hoverObject = undefined;
117
- /** @property {UIObject} - Hover object at start of update */
118
- this.lastHoverObject = undefined;
119
- /** @property {UIObject} - Current confirm menu being shown */
120
- this.confirmDialog = undefined;
121
- /** @private */
122
- this._keyInputObject = undefined;
123
- /** @private */
124
- this._onKeyDown = (e) => this._keyInputObject?.onKeyDown(e);
125
-
126
- engineAddPlugin(uiUpdate, uiRender);
127
-
128
- // set object position based on anchor target (parent box, or canvas for roots),
129
- // self-pivot, and localPos offset
130
- function updateTransforms(o)
131
- {
132
- let targetPos, targetSize;
133
- if (o.parent)
134
- {
135
- targetPos = o.parent.nativePos;
136
- targetSize = o.parent.size;
137
- }
138
- else
139
- {
140
- // anchor to canvas in native coords (handles nativeHeight if set)
141
- targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
142
- targetSize = uiSystem.nativeHeight
143
- ? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y,
144
- uiSystem.nativeHeight)
145
- : mainCanvasSize;
146
- }
147
-
148
- const a = o.anchor;
149
- o.nativePos = targetPos
150
- .add(targetSize.multiply(a).scale(.5)) // anchor point on target
151
- .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
152
- .add(o.localPos); // user offset
153
- }
154
-
155
- // setup recursive update and render
156
- // update in reverse order to detect mouse enter/leave
157
- function uiUpdate()
158
- {
159
- if (uiSystem.activeObject && !uiSystem.activeObject.visible)
160
- uiSystem.activeObject = undefined;
161
-
162
- // reset hover object at start of update
163
- uiSystem.lastHoverObject = uiSystem.hoverObject;
164
- uiSystem.hoverObject = undefined;
165
-
166
- if (mouseWasPressed(0))
167
- {
168
- // exit navigation mode on mouse press
169
- uiSystem.navigationMode = false;
170
- uiSystem.navigationObject = undefined;
171
- }
172
- if (uiSystem.keyInputObject)
173
- {
174
- // handle text input
175
- uiSystem.activeObject = uiSystem.keyInputObject;
176
- uiSystem.hoverObject = uiSystem.keyInputObject;
177
- uiSystem.navigationMode = false;
178
- uiSystem.navigationObject = undefined;
179
- }
180
-
181
- // navigation with gamepad/keyboard
182
- const navigableObjects = uiSystem.getNavigableObjects();
183
- if (!navigableObjects.length)
184
- uiSystem.navigationObject = undefined;
185
- else if (!uiSystem.keyInputObject)
186
- {
187
- // unselect object if it is no longer navigable
188
- if (!navigableObjects.includes(uiSystem.navigationObject))
189
- uiSystem.navigationObject = undefined;
190
-
191
- if (!isTouchDevice)
192
- if (uiSystem.navigationMode && !uiSystem.navigationObject)
193
- {
194
- // select first auto focus object
195
- uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
196
- }
197
-
198
- // navigate with dpad or left stick
199
- if (!uiSystem.navigationTimer.active())
200
- {
201
- // navigate through list with gamepad or keyboard
202
- const direction = sign(uiSystem.getNavigationDirection());
203
- if (direction)
204
- {
205
- let newNavigationObject;
206
- if (!uiSystem.navigationObject)
207
- {
208
- // use auto select object
209
- newNavigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
210
-
211
- if (!newNavigationObject)
212
- {
213
- // try first or last object
214
- const newIndex = direction > 0 ? 0 : navigableObjects.length-1;
215
- newNavigationObject = navigableObjects[newIndex];
216
- }
217
- }
218
- else
219
- {
220
- const currentIndex = navigableObjects.indexOf(uiSystem.navigationObject);
221
- const newIndex = mod(currentIndex + direction, navigableObjects.length);
222
- newNavigationObject = navigableObjects[newIndex];
223
- }
224
-
225
- if (uiSystem.navigationObject !== newNavigationObject)
226
- {
227
- uiSystem.navigationMode = true;
228
- uiSystem.hoverObject = undefined;
229
- uiSystem.navigationObject = newNavigationObject;
230
- uiSystem.navigationTimer.set(uiSystem.navigationDelay);
231
- newNavigationObject.soundPress &&
232
- newNavigationObject.soundPress.play();
233
- }
234
- }
235
- }
236
-
237
- // activate the navigation object when pressed
238
- if (uiSystem.navigationObject)
239
- if (uiSystem.getNavigationWasPressed())
240
- uiSystem.navigationObject.navigatePressed();
241
- }
242
-
243
- // update in reverse order so topmost objects get priority
244
- for (let i = uiSystem.uiObjects.length; i--;)
245
- {
246
- const o = uiSystem.uiObjects[i];
247
- o.parent || updateObject(o);
248
- }
249
-
250
- // remove destroyed objects
251
- uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed);
252
-
253
- function updateObject(o)
254
- {
255
- if (o.destroyed || !o.visible) return;
256
-
257
- // update in reverse order to detect mouse enter/leave
258
- updateTransforms(o);
259
- for (let i=o.children.length; i--;)
260
- {
261
- // a child may destroy siblings mid-update (e.g. dialog close)
262
- const child = o.children[i];
263
- child && updateObject(child);
264
- }
265
- if (!o.destroyed)
266
- o.update();
267
- }
268
- }
269
- function uiRender()
270
- {
271
- const context = uiSystem.uiContext;
272
- context.save();
273
- if (uiSystem.nativeHeight)
274
- {
275
- // convert to native height
276
- const s = mainCanvasSize.y / uiSystem.nativeHeight;
277
- context.translate(-s*mainCanvasSize.x/2,0);
278
- context.scale(s,s);
279
- context.translate(mainCanvasSize.x/2/s,0);
280
- }
281
-
282
- function renderObject(o)
283
- {
284
- if (!o.visible) return;
285
-
286
- // render object and children
287
- updateTransforms(o);
288
- o.render();
289
- for (const c of o.children)
290
- renderObject(c);
291
- }
292
- uiSystem.uiObjects.forEach(o=> o.parent || renderObject(o));
293
-
294
- if (uiDebug > 0)
295
- {
296
- // debug render all objects
297
- function renderDebug(o, visible=true)
298
- {
299
- visible &&= !!o.visible;
300
- updateTransforms(o);
301
- o.renderDebug(visible);
302
- for (const c of o.children)
303
- renderDebug(c, visible);
304
- }
305
- uiSystem.uiObjects.forEach(o=> o.parent || renderDebug(o));
306
- }
307
- context.restore();
308
- }
309
- }
310
-
311
- /** Draw a rectangle to the UI context
312
- * @param {Vector2} pos
313
- * @param {Vector2} size
314
- * @param {Color} [color]
315
- * @param {number} [lineWidth]
316
- * @param {Color} [lineColor]
317
- * @param {number} [cornerRadius]
318
- * @param {Color} [gradientColor]
319
- * @param {Color} [shadowColor]
320
- * @param {number} [shadowBlur]
321
- * @param {Color} [shadowOffset] */
322
- drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
323
- {
324
- ASSERT(isVector2(pos), 'pos must be a vec2');
325
- ASSERT(isVector2(size), 'size must be a vec2');
326
- ASSERT(isColor(color), 'color must be a color');
327
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
328
- ASSERT(isColor(lineColor), 'lineColor must be a color');
329
- ASSERT(isNumber(cornerRadius), 'cornerRadius must be a number');
330
-
331
- const context = uiSystem.uiContext;
332
- if (gradientColor)
333
- {
334
- const g = context.createLinearGradient(
335
- pos.x, pos.y-size.y/2, pos.x, pos.y+size.y/2);
336
- const c = color.toString();
337
- g.addColorStop(0, c);
338
- g.addColorStop(.5, gradientColor.toString());
339
- g.addColorStop(1, c);
340
- context.fillStyle = g;
341
- }
342
- else
343
- context.fillStyle = color.toString();
344
- if (shadowBlur || shadowOffset.x || shadowOffset.y)
345
- if (shadowColor.a > 0)
346
- {
347
- // setup shadow
348
- context.shadowColor = shadowColor.toString();
349
- context.shadowBlur = shadowBlur;
350
- context.shadowOffsetX = shadowOffset.x;
351
- context.shadowOffsetY = shadowOffset.y;
352
- }
353
- context.beginPath();
354
- if (cornerRadius && context['roundRect'])
355
- context['roundRect'](pos.x-size.x/2, pos.y-size.y/2, size.x, size.y, cornerRadius);
356
- else
357
- context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
358
- context.fill();
359
- context.shadowColor = '#0000';
360
- if (lineWidth && lineColor.a > 0)
361
- {
362
- context.strokeStyle = lineColor.toString();
363
- context.lineWidth = lineWidth;
364
- context.stroke();
365
- }
366
- }
367
-
368
- /** Draw a line to the UI context
369
- * @param {Vector2} posA
370
- * @param {Vector2} posB
371
- * @param {number} [lineWidth=uiSystem.defaultLineWidth]
372
- * @param {Color} [lineColor=uiSystem.defaultLineColor] */
373
- drawLine(posA, posB, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor)
374
- {
375
- ASSERT(isVector2(posA), 'posA must be a vec2');
376
- ASSERT(isVector2(posB), 'posB must be a vec2');
377
- ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
378
- ASSERT(isColor(lineColor), 'lineColor must be a color');
379
-
380
- const context = uiSystem.uiContext;
381
- context.strokeStyle = lineColor.toString();
382
- context.lineWidth = lineWidth;
383
- context.beginPath();
384
- context.lineTo(posA.x, posA.y);
385
- context.lineTo(posB.x, posB.y);
386
- context.stroke();
387
- }
388
-
389
- /** Draw a tile to the UI context
390
- * @param {Vector2} pos
391
- * @param {Vector2} size
392
- * @param {TileInfo} tileInfo
393
- * @param {Color} [color=uiSystem.defaultColor]
394
- * @param {number} [angle]
395
- * @param {boolean} [mirror]
396
- * @param {Color} [shadowColor]
397
- * @param {number} [shadowBlur]
398
- * @param {Color} [shadowOffset] */
399
- drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
400
- {
401
- const context = uiSystem.uiContext;
402
- if (shadowBlur || shadowOffset.x || shadowOffset.y)
403
- if (shadowColor.a > 0)
404
- {
405
- // setup shadow
406
- context.shadowColor = shadowColor.toString();
407
- context.shadowBlur = shadowBlur;
408
- context.shadowOffsetX = shadowOffset.x;
409
- context.shadowOffsetY = shadowOffset.y;
410
- }
411
- drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, context);
412
- context.shadowColor = '#0000';
413
- }
414
-
415
- /** Draw text to the UI context
416
- * @param {string} text
417
- * @param {Vector2} pos
418
- * @param {Vector2} size
419
- * @param {Color} [color=uiSystem.defaultColor]
420
- * @param {number} [lineWidth=uiSystem.defaultLineWidth]
421
- * @param {Color} [lineColor=uiSystem.defaultLineColor]
422
- * @param {string} [align]
423
- * @param {string} [font=uiSystem.defaultFont]
424
- * @param {string} [fontStyle]
425
- * @param {boolean} [applyMaxWidth=true]
426
- * @param {Vector2} [textShadow]
427
- * @param {Color} [shadowColor]
428
- * @param {number} [shadowBlur]
429
- * @param {Color} [shadowOffset] */
430
- drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
431
- {
432
- const context = uiSystem.uiContext;
433
- if (shadowColor.a > 0)
434
- {
435
- if (textShadow)
436
- drawTextScreen(text, pos.add(textShadow), size.y, shadowColor, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
437
- if (shadowBlur || shadowOffset.x || shadowOffset.y)
438
- {
439
- // setup shadow
440
- context.shadowColor = shadowColor.toString();
441
- context.shadowBlur = shadowBlur;
442
- context.shadowOffsetX = shadowOffset.x;
443
- context.shadowOffsetY = shadowOffset.y;
444
- }
445
- }
446
- drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
447
- context.shadowColor = '#0000';
448
- }
449
-
450
- /**
451
- * @callback DragAndDropCallback - Callback for drag and drop events
452
- * @param {DragEvent} event - The drag event
453
- * @memberof UISystem
454
- */
455
-
456
- /** Setup drag and drop event handlers
457
- * Automatically prevents defaults and calls the given functions
458
- * @param {DragAndDropCallback} [onDrop] - when a file is dropped
459
- * @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window
460
- * @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window
461
- * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
462
- setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
463
- {
464
- // remove any prior listeners so repeated setup calls don't stack
465
- if (this._dragListeners)
466
- for (const [type, listener] of this._dragListeners)
467
- document.removeEventListener(type, listener);
468
- this._dragListeners = [];
469
- const setCallback = (callback, listenerType)=>
470
- {
471
- const listener = (e)=> { e.preventDefault(); callback && callback(e); };
472
- document.addEventListener(listenerType, listener);
473
- this._dragListeners.push([listenerType, listener]);
474
- };
475
- setCallback(onDrop, 'drop');
476
- setCallback(onDragEnter, 'dragenter');
477
- setCallback(onDragLeave, 'dragleave');
478
- setCallback(onDragOver, 'dragover');
479
- }
480
-
481
- /** Convert a screen space position to native UI position
482
- * @param {Vector2} pos
483
- * @return {Vector2} */
484
- screenToNative(pos)
485
- {
486
- if (!uiSystem.nativeHeight)
487
- return pos;
488
-
489
- const s = mainCanvasSize.y / uiSystem.nativeHeight;
490
- const sInv = 1/s;
491
- const p = pos.copy();
492
- p.x += s*mainCanvasSize.x/2;
493
- p.x *= sInv;
494
- p.y *= sInv;
495
- p.x -= sInv*mainCanvasSize.x/2;
496
- return p;
497
- }
498
-
499
- /** Object to send keyboard input to (typically a UITextInput).
500
- * The document keydown listener is only attached while this is set,
501
- * so games that never use text input pay no event-handling cost.
502
- * @type {UIObject} */
503
- get keyInputObject() { return this._keyInputObject; }
504
- set keyInputObject(obj)
505
- {
506
- const had = !!this._keyInputObject;
507
- this._keyInputObject = obj;
508
- if (!had && obj)
509
- document.addEventListener('keydown', this._onKeyDown);
510
- else if (had && !obj)
511
- document.removeEventListener('keydown', this._onKeyDown);
512
- }
513
-
514
- /** Destroy and remove all objects
515
- * @memberof UISystem */
516
- destroyObjects()
517
- {
518
- for (const o of this.uiObjects)
519
- o.parent || o.destroy();
520
- this.uiObjects = this.uiObjects.filter(o=>!o.destroyed);
521
- this.activeObject = undefined;
522
- this.hoverObject = undefined;
523
- this.lastHoverObject = undefined;
524
- this.keyInputObject = undefined;
525
- }
526
-
527
- /** Get all navigable UI objects sorted by navigationIndex
528
- * @return {Array<UIObject>} */
529
- getNavigableObjects()
530
- {
531
- function getNavigableRecursive(o)
532
- {
533
- if (!o.visible || o.disabled)
534
- return; // skip children if parent is invisible or disabled
535
-
536
- if (o.isInteractive() && o.navigationIndex !== undefined)
537
- objects.push(o);
538
- for (let i=o.children.length; i--;)
539
- getNavigableRecursive(o.children[i]);
540
- }
541
-
542
- // get all the valid navigable objects recursively
543
- let objects = [];
544
- for (let i = uiSystem.uiObjects.length; i--;)
545
- {
546
- const o = uiSystem.uiObjects[i];
547
- if (uiSystem.confirmDialog && o !== uiSystem.confirmDialog)
548
- continue;
549
- o.parent || getNavigableRecursive(o);
550
- }
551
-
552
- // sort by navigationIndex (lower numbers first)
553
- objects.sort((a, b)=> a.navigationIndex - b.navigationIndex);
554
- return objects;
555
- }
556
-
557
- /** Get navigation direction from gamepad or keyboard
558
- * @return {number} */
559
- getNavigationDirection()
560
- {
561
- const vertical = uiSystem.navigationDirection === 1;
562
- const both = uiSystem.navigationDirection === 2;
563
- if (isUsingGamepad)
564
- {
565
- const stick = gamepadStick(0, gamepadPrimary);
566
- const dpad = gamepadDpad(gamepadPrimary);
567
- if (both)
568
- return -(stick.y || dpad.y) || (stick.x || dpad.x);
569
- return vertical ? -(stick.y || dpad.y) : (stick.x || dpad.x);
570
- }
571
- const up = 'ArrowUp', down = 'ArrowDown', left = 'ArrowLeft', right = 'ArrowRight';
572
- if (both)
573
- {
574
- return keyIsDown(up) || keyIsDown(left) ? -1 :
575
- keyIsDown(down) || keyIsDown(right) ? 1 : 0;
576
- }
577
- const back = vertical ? up : left;
578
- const forward = vertical ? down : right;
579
- return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
580
- }
581
-
582
- /** Get other axis navigation direction from gamepad or keyboard
583
- * @return {number} */
584
- getNavigationOtherDirection()
585
- {
586
- if (uiSystem.navigationDirection === 2)
587
- return 0; // other direction disabled
588
-
589
- const vertical = uiSystem.navigationDirection === 1;
590
- if (isUsingGamepad)
591
- {
592
- const stick = gamepadStick(0, gamepadPrimary);
593
- const dpad = gamepadDpad(gamepadPrimary);
594
- return !vertical ? (stick.y || dpad.y) : (stick.x || dpad.x);
595
- }
596
- const back = !vertical ? 'ArrowUp' : 'ArrowLeft';
597
- const forward = !vertical ? 'ArrowDown' : 'ArrowRight';
598
- return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
599
- }
600
-
601
- /** Get if navigation button was pressed from gamepad or keyboard
602
- * @return {boolean} */
603
- getNavigationWasPressed()
604
- {
605
- return isUsingGamepad ? gamepadWasPressed(0, gamepadPrimary) :
606
- keyWasPressed('Space') || keyWasPressed('Enter');
607
- }
608
-
609
- /** Show a confirmation dialog with Yes/No buttons
610
- * Centers the dialog on the screen with darkened background
611
- * @param {string} [text] - The message to display
612
- * @param {Function} [yesCallback] - Called when Yes is clicked
613
- * @param {Function} [noCallback] - Called when No is clicked
614
- * @param {Vector2} [size] - Size of the confirmation dialog
615
- * @param {string} [exitKey] - Key that can exit the menu
616
- * @return {UIObject} The confirmation menu object
617
- */
618
- showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
619
- {
620
- ASSERT(!uiSystem.confirmDialog);
621
-
622
- const savedNavigationDirection = uiSystem.navigationDirection;
623
-
624
- // allow both axes for navigation
625
- uiSystem.navigationDirection = 2;
626
-
627
- // confirm menu
628
- const confirmMenu = new UIObject(vec2(), size);
629
- uiSystem.confirmDialog = confirmMenu;
630
- confirmMenu.onRender = ()=>
631
- {
632
- const backgroundColor = hsl(0,0,0,.7);
633
- uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
634
- }
635
- confirmMenu.onUpdate = ()=>
636
- {
637
- if (keyWasPressed(exitKey))
638
- closeMenu();
639
- }
640
- confirmMenu.isMouseOverlapping = ()=> true; // always hover
641
-
642
- // title text
643
- const gap = 50;
644
- const textTitle = new UIText(vec2(0,-50), vec2(size.x-gap,70), text);
645
- confirmMenu.addChild(textTitle);
646
-
647
- // yes button
648
- const buttonYes = new UIButton(vec2(-80,50), vec2(120,70), 'Yes');
649
- buttonYes.textHeight = 40;
650
- buttonYes.navigationIndex = 1;
651
- buttonYes.hoverColor = hsl(0,1,.5);
652
- buttonYes.onClick = ()=> { closeMenu(); yesCallback && yesCallback(); };
653
- confirmMenu.addChild(buttonYes);
654
-
655
- // no button
656
- const buttonNo = new UIButton(vec2(80,50), vec2(120,70), 'No');
657
- buttonNo.textHeight = 40;
658
- buttonNo.navigationIndex = 2;
659
- buttonNo.navigationAutoSelect = true;
660
- buttonNo.onClick = ()=> { closeMenu(); noCallback && noCallback(); };
661
- confirmMenu.addChild(buttonNo);
662
-
663
- // close menu and return to normal navigation
664
- function closeMenu()
665
- {
666
- ASSERT(uiSystem.confirmDialog === confirmMenu);
667
- confirmMenu.destroy();
668
- uiSystem.confirmDialog = undefined;
669
- uiSystem.navigationDirection = savedNavigationDirection;
670
- inputClear();
671
- }
672
- return confirmMenu;
673
- }
674
- }
675
-
676
- ///////////////////////////////////////////////////////////////////////////////
677
- /**
678
- * UI Object - Base level object for all UI elements
679
- * @memberof UISystem */
680
- class UIObject
681
- {
682
- /** Create a UIObject
683
- * @param {Vector2} [pos=vec2()]
684
- * @param {Vector2} [size=vec2(1)]
685
- */
686
- constructor(pos=vec2(), size=vec2())
687
- {
688
- ASSERT(isVector2(pos), 'ui object pos must be a vec2');
689
- ASSERT(isVector2(size), 'ui object size must be a vec2');
690
-
691
- /** @property {Vector2} - Position you set: an offset from this object's
692
- * anchor point (the parent box, or the canvas for roots). This is the
693
- * input that controls placement — set this, not nativePos. */
694
- this.localPos = pos.copy();
695
- /** @property {Vector2} - Resolved position in native UI space, recomputed
696
- * every frame from localPos + anchor (and nativeHeight, if set). This is a
697
- * derived output used for drawing and hit-testing; assigning to it has no
698
- * effect since it is overwritten each frame. Set localPos instead. */
699
- this.nativePos = pos.copy();
700
- /** @property {Vector2} - Screen space size of the object */
701
- this.size = size.copy();
702
- /** @property {Color} - Color of the object */
703
- this.color = uiSystem.defaultColor.copy();
704
- /** @property {Color} - Color of the object when active, uses hoverColor if undefined */
705
- this.activeColor = undefined;
706
- /** @property {string} - Text for this ui object */
707
- this.text = undefined;
708
- /** @property {Color} - Color when disabled */
709
- this.disabledColor = uiSystem.defaultDisabledColor.copy();
710
- /** @property {boolean} - Is this object disabled? */
711
- this.disabled = false;
712
- /** @property {Color} - Color for text */
713
- this.textColor = uiSystem.defaultTextColor.copy();
714
- /** @property {Color} - Color used when hovering over the object */
715
- this.hoverColor = uiSystem.defaultHoverColor.copy();
716
- /** @property {Color} - Color for line drawing */
717
- this.lineColor = uiSystem.defaultLineColor.copy();
718
- /** @property {Color} - Uses a gradient fill combined with color */
719
- this.gradientColor = uiSystem.defaultGradientColor ? uiSystem.defaultGradientColor.copy() : undefined;
720
- /** @property {number} - Width for line drawing */
721
- this.lineWidth = uiSystem.defaultLineWidth;
722
- /** @property {number} - Corner radius for rounded rects */
723
- this.cornerRadius = uiSystem.defaultCornerRadius;
724
- /** @property {string} - Font for this object */
725
- this.font = uiSystem.defaultFont;
726
- /** @property {string} - Font style for this object or undefined */
727
- this.fontStyle = undefined;
728
- /** @property {number} - Override for text width */
729
- this.textWidth = undefined;
730
- /** @property {number} - Override for text height */
731
- this.textHeight = undefined;
732
- /** @property {number} - Scale text to fit in the object */
733
- this.textFitScale = uiSystem.defaultTextFitScale;
734
- /** @property {Vector2} - How much to offset the text shadow or undefined */
735
- this.textShadow = undefined;
736
- /** @property {number} - Color for text line drawing */
737
- this.textLineColor = uiSystem.defaultLineColor.copy();
738
- /** @property {number} - Width for text line drawing */
739
- this.textLineWidth = 0;
740
- /** @property {boolean} - Should this object be drawn */
741
- this.visible = true;
742
- /** @property {Array<UIObject>} - A list of this object's children */
743
- this.children = [];
744
- /** @property {UIObject} - This object's parent, position is in parent space */
745
- this.parent = undefined;
746
- /** @property {number} - Added size to make small buttons easier to touch on mobile devices */
747
- this.extraTouchSize = 0;
748
- /** @property {Sound} - Sound when interactive element is pressed */
749
- this.soundPress = uiSystem.defaultSoundPress;
750
- /** @property {Sound} - Sound when interactive element is released */
751
- this.soundRelease = uiSystem.defaultSoundRelease;
752
- /** @property {Sound} - Sound when interactive element is clicked */
753
- this.soundClick = uiSystem.defaultSoundClick;
754
- /** @property {boolean} - Is this element interactive */
755
- this.interactive = false;
756
- /** @property {boolean} - Activate when dragged over with mouse held down */
757
- this.dragActivate = false;
758
- /** @property {boolean} - True if this can be a hover object */
759
- this.canBeHover = true;
760
- /** @property {Color} - Color for shadow, undefined if no shadow */
761
- this.shadowColor = uiSystem.defaultShadowColor?.copy();
762
- /** @property {number} - Size of shadow blur */
763
- this.shadowBlur = uiSystem.defaultShadowBlur;
764
- /** @property {Vector2} - Offset of shadow blur */
765
- this.shadowOffset = uiSystem.defaultShadowOffset?.copy();
766
- /** @property {number} - Optional navigation order index, lower values are selected first */
767
- this.navigationIndex = undefined;
768
- /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
769
- this.navigationAutoSelect = false;
770
- /** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored.
771
- * Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right.
772
- * Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */
773
- this.anchor = vec2();
774
-
775
- uiSystem.uiObjects.push(this);
776
- }
777
-
778
- /** Add a child UIObject to this object, returns child for chaining
779
- * @param {UIObject} child
780
- * @return {UIObject} The child object added */
781
- addChild(child)
782
- {
783
- ASSERT(!child.parent && !this.children.includes(child));
784
- this.children.push(child);
785
- child.parent = this;
786
- return child;
787
- }
788
-
789
- /** Remove a child UIObject from this object
790
- * @param {UIObject} child */
791
- removeChild(child)
792
- {
793
- ASSERT(child.parent === this && this.children.includes(child));
794
- this.children.splice(this.children.indexOf(child), 1);
795
- child.parent = undefined;
796
- }
797
-
798
- /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
799
- destroy()
800
- {
801
- if (this.destroyed)
802
- return;
803
-
804
- // clear ui-system references that point at this object so events
805
- // don't keep firing against a destroyed target (especially the
806
- // keydown listener attached for keyInputObject)
807
- if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
808
- if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
809
- if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
810
- if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
811
- if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
812
-
813
- // disconnect from parent and destroy children
814
- this.destroyed = 1;
815
- this.parent?.removeChild(this);
816
- for (const child of this.children)
817
- {
818
- child.parent = undefined;
819
- child.destroy();
820
- }
821
- // clear references so destroyed children can be GC'd
822
- this.children.length = 0;
823
- }
824
-
825
- /** Check if the mouse is overlapping this ui object
826
- * @return {boolean} - True if overlapping */
827
- isMouseOverlapping()
828
- {
829
- if (!mouseInWindow) return false;
830
-
831
- const size = !isTouchDevice ? this.size :
832
- this.size.add(vec2(this.extraTouchSize || 0));
833
- const pos = uiSystem.screenToNative(mousePosScreen);
834
- return isOverlapping(this.nativePos, size, pos);
835
- }
836
-
837
- /** Update the object, called automatically by plugin once each frame */
838
- update()
839
- {
840
- // call the custom update callback
841
- this.onUpdate();
842
-
843
- // unset active if disabled
844
- if (this.disabled)
845
- {
846
- if (this === uiSystem.activeObject)
847
- uiSystem.activeObject = undefined;
848
- if (this === uiSystem.keyInputObject)
849
- uiSystem.keyInputObject = undefined;
850
- }
851
-
852
- if (uiSystem.keyInputObject)
853
- return;
854
-
855
- const wasHover = uiSystem.lastHoverObject === this;
856
- const isActive = this.isActiveObject();
857
- const mouseDown = mouseIsDown(0);
858
- const mousePress = this.dragActivate ? mouseDown : mouseWasPressed(0);
859
- if (this.canBeHover)
860
- if (!uiSystem.navigationMode) // no mouse hover in navigation mode
861
- if (mousePress || isActive || (!mouseDown && !isTouchDevice))
862
- if (!uiSystem.hoverObject && this.isMouseOverlapping())
863
- uiSystem.hoverObject = this;
864
- if (this.isHoverObject())
865
- {
866
- if (!this.disabled)
867
- {
868
- if (mousePress)
869
- {
870
- if (this.interactive)
871
- {
872
- if (!this.dragActivate || (!wasHover || mouseWasPressed(0)))
873
- this.onPress();
874
- this.soundPress && this.soundPress.play();
875
- if (uiSystem.activeObject && !isActive)
876
- uiSystem.activeObject.onRelease();
877
- uiSystem.activeObject = this;
878
-
879
- if (uiSystem.activateOnPress)
880
- this.click(!this.soundPress);
881
- }
882
- }
883
- if (!uiSystem.activateOnPress)
884
- if (!mouseDown && this.isActiveObject() && this.interactive)
885
- this.click();
886
- }
887
-
888
- // clear mouse was pressed state even when disabled
889
- mousePress && inputClearKey(0,0,0,1,0);
890
- }
891
- if (isActive)
892
- if (!mouseDown || (this.dragActivate && !this.isHoverObject()))
893
- {
894
- this.onRelease();
895
- this.soundRelease && this.soundRelease.play();
896
- uiSystem.activeObject = undefined;
897
- }
898
-
899
- // call enter/leave events
900
- if (this.isHoverObject() !== wasHover)
901
- this.isHoverObject() ? this.onEnter() : this.onLeave();
902
- }
903
-
904
- /** Render the object, called automatically by plugin once each frame */
905
- render()
906
- {
907
- // call the custom render callback
908
- this.onRender();
909
-
910
- if (!this.size.x || !this.size.y) return;
911
-
912
- const isNavigationObject = this.isNavigationObject();
913
- const lineColor = isNavigationObject ? this.color :
914
- this.interactive && this.isActiveObject() && !this.disabled ?
915
- this.color : this.lineColor;
916
- const color = isNavigationObject ? this.hoverColor :
917
- this.disabled ? this.disabledColor :
918
- this.interactive ?
919
- this.isActiveObject() ? this.activeColor || this.hoverColor :
920
- this.isHoverObject() ? this.hoverColor :
921
- this.color : this.color;
922
- const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
923
-
924
- uiSystem.drawRect(this.nativePos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
925
- }
926
-
927
- /** Get the size for text with overrides and scale
928
- * @return {Vector2} */
929
- getTextSize()
930
- {
931
- return vec2(
932
- this.textWidth || this.textFitScale * this.size.x,
933
- this.textHeight || this.textFitScale * this.size.y);
934
- }
935
-
936
- /** Called when the navigation button is pressed on this object */
937
- navigatePressed() { this.click(); }
938
-
939
- /** @return {boolean} - Is the mouse hovering over this element */
940
- isHoverObject() { return uiSystem.hoverObject === this; }
941
-
942
- /** @return {boolean} - Is the mouse held onto this element */
943
- isActiveObject() { return uiSystem.activeObject === this; }
944
-
945
- /** @return {boolean} - Is the gamepad or keyboard navigation object */
946
- isNavigationObject() { return uiSystem.navigationObject === this; }
947
-
948
- /** @return {boolean} - Is this object in keyboard input mode */
949
- isKeyInputObject() { return uiSystem.keyInputObject === this; }
950
-
951
- /** @return {boolean} - Can it be interacted with */
952
- isInteractive() { return this.interactive && this.visible && !this.disabled;}
953
-
954
- /** Returns string containing info about this object for debugging
955
- * @return {string} */
956
- toString()
957
- {
958
- let text = 'type = ' + this.constructor.name;
959
- if (this.text)
960
- text += '\ntext = ' + this.text;
961
- if (this.nativePos.x || this.nativePos.y)
962
- text += '\nnativePos = ' + this.nativePos;
963
- if (this.localPos.x || this.localPos.y)
964
- text += '\nlocalPos = ' + this.localPos;
965
- if (this.size.x || this.size.y)
966
- text += '\nsize = ' + this.size;
967
- if (this.color)
968
- text += '\ncolor = ' + this.color;
969
- return text;
970
- }
971
-
972
- /** Called if uiDebug is enabled
973
- * @param {boolean} visible */
974
- renderDebug(visible=true)
975
- {
976
- // apply color based on state
977
- const color =
978
- !visible ? GREEN :
979
- this.isHoverObject() ? YELLOW :
980
- this.disabled ? PURPLE :
981
- this.interactive ? RED : BLUE;
982
- uiSystem.drawRect(this.nativePos, this.size, CLEAR_BLACK, 4, color);
983
- }
984
-
985
- /** Internal function called when object is clicked
986
- * @param {boolean} [playSound] */
987
- click(playSound=true)
988
- {
989
- this.onClick();
990
- if (playSound && this.soundClick)
991
- this.soundClick.play();
992
- }
993
-
994
- /** Called each frame before object updates */
995
- onUpdate() {}
996
-
997
- /** Called each frame before object renders */
998
- onRender() {}
999
-
1000
- /** Called when the mouse enters the object */
1001
- onEnter() {}
1002
-
1003
- /** Called when the mouse leaves the object */
1004
- onLeave() {}
1005
-
1006
- /** Called when the mouse is pressed while over the object */
1007
- onPress() {}
1008
-
1009
- /** Called when the mouse is released while over the object */
1010
- onRelease() {}
1011
-
1012
- /** Called when user clicks on this object */
1013
- onClick() {}
1014
-
1015
- /** Called when the state of this object changes */
1016
- onChange() {}
1017
- };
1018
-
1019
- ///////////////////////////////////////////////////////////////////////////////
1020
- /**
1021
- * UIText - A UI object that displays text
1022
- * @extends UIObject
1023
- * @memberof UISystem
1024
- */
1025
- class UIText extends UIObject
1026
- {
1027
- /** Create a UIText object
1028
- * @param {Vector2} [pos]
1029
- * @param {Vector2} [size]
1030
- * @param {string} [text]
1031
- * @param {string} [align]
1032
- * @param {string} [font=uiSystem.defaultFont]
1033
- */
1034
- constructor(pos, size, text='', align='center', font=uiSystem.defaultFont)
1035
- {
1036
- super(pos, size);
1037
-
1038
- ASSERT(isStringLike(text), 'ui text must be a string');
1039
- ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
1040
- ASSERT(isStringLike(font), 'ui text font must be a string');
1041
-
1042
- // set properties
1043
- this.text = text;
1044
- this.align = align;
1045
- this.font = font;
1046
-
1047
- // text can not be a hover object by default
1048
- this.canBeHover = false;
1049
-
1050
- // no background by default
1051
- this.color = CLEAR_BLACK;
1052
- this.shadowColor = CLEAR_BLACK;
1053
- this.gradientColor = undefined;
1054
- this.lineWidth = 0;
1055
-
1056
- // use max fit scale by default
1057
- this.textFitScale = 1;
1058
- }
1059
- render()
1060
- {
1061
- super.render();
1062
-
1063
- // render the text
1064
- const textSize = this.getTextSize();
1065
- uiSystem.drawText(this.text, this.nativePos, textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor, this.shadowBlur, this.shadowOffset);
1066
- }
1067
- }
1068
-
1069
- ///////////////////////////////////////////////////////////////////////////////
1070
- /**
1071
- * UITextInput - An editable text input field
1072
- * - A simple text entry field that supports basic editing
1073
- * - Suitable for short text input like names or numbers
1074
- * @extends UIObject
1075
- * @memberof UISystem
1076
- */
1077
- class UITextInput extends UIObject
1078
- {
1079
- /** Create a UITextInput object
1080
- * @param {Vector2} [pos]
1081
- * @param {Vector2} [size]
1082
- * @param {string} [text]
1083
- */
1084
- constructor(pos, size, text='')
1085
- {
1086
- super(pos, size);
1087
-
1088
- ASSERT(isStringLike(text), 'ui text must be a string');
1089
-
1090
- /** @property {number} - Max length of input (0 = no limit) */
1091
- this.maxLength = 0;
1092
-
1093
- // set properties
1094
- this.text = text;
1095
- this.interactive = true;
1096
- this.canBeHover = true;
1097
- }
1098
-
1099
- click()
1100
- {
1101
- // start editing the text
1102
- uiSystem.keyInputObject = this;
1103
- this.onClick();
1104
- }
1105
-
1106
- /** Stop editing the text */
1107
- stopEditing()
1108
- {
1109
- if (!this.isKeyInputObject())
1110
- return;
1111
-
1112
- if (this.soundRelease)
1113
- this.soundRelease.play();
1114
- uiSystem.activeObject = undefined;
1115
- uiSystem.keyInputObject = undefined;
1116
- this.onChange();
1117
- }
1118
-
1119
- /** Key down event handler if this object is being edited
1120
- * @param {KeyboardEvent} [e] */
1121
- onKeyDown(e)
1122
- {
1123
- const code = e.code, key = e.key
1124
- if (code === 'Backspace')
1125
- this.text = this.text.slice(0, -1);
1126
- else if (code === 'Enter' || code === 'Escape')
1127
- this.stopEditing();
1128
- else if (key.length === 1) // printable characters
1129
- {
1130
- if (!this.maxLength || this.text.length < this.maxLength)
1131
- this.text += key;
1132
- }
1133
- }
1134
-
1135
- update()
1136
- {
1137
- super.update();
1138
-
1139
- if (!this.isKeyInputObject())
1140
- return;
1141
-
1142
- // click off object to stop editing
1143
- if (mouseWasPressed(0) && !this.isMouseOverlapping() ||
1144
- gamepadWasPressed(0, gamepadPrimary))
1145
- {
1146
- this.stopEditing();
1147
- inputClearKey(0,0);
1148
- }
1149
- }
1150
-
1151
- render()
1152
- {
1153
- super.render();
1154
-
1155
- // draw the text scaled to fit
1156
- const textSize = this.getTextSize();
1157
- let text = this.text;
1158
- if (this.isKeyInputObject()) // add a cursor to end of text
1159
- text += timeReal%1 < .5 ? '█' : '░';
1160
- uiSystem.drawText(text, this.nativePos, textSize,
1161
- this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
1162
- }
1163
- }
1164
-
1165
- ///////////////////////////////////////////////////////////////////////////////
1166
- /**
1167
- * UITile - A UI object that displays a tile image
1168
- * @extends UIObject
1169
- * @memberof UISystem
1170
- */
1171
- class UITile extends UIObject
1172
- {
1173
- /** Create a UITile object
1174
- * @param {Vector2} [pos]
1175
- * @param {Vector2} [size]
1176
- * @param {TileInfo} [tileInfo]
1177
- * @param {Color} [color=WHITE]
1178
- * @param {number} [angle]
1179
- * @param {boolean} [mirror]
1180
- */
1181
- constructor(pos, size, tileInfo, color=WHITE, angle=0, mirror=false)
1182
- {
1183
- super(pos, size);
1184
-
1185
- ASSERT(tileInfo instanceof TileInfo, 'ui tile tileInfo must be a TileInfo');
1186
- ASSERT(isColor(color), 'ui tile color must be a color');
1187
- ASSERT(isNumber(angle), 'ui tile angle must be a number');
1188
-
1189
- /** @property {TileInfo} - Tile image to use */
1190
- this.tileInfo = tileInfo;
1191
- /** @property {number} - Angle to rotate in radians */
1192
- this.angle = angle;
1193
- /** @property {boolean} - Should it be mirrored? */
1194
- this.mirror = mirror;
1195
- // set properties
1196
- this.color = color.copy();
1197
-
1198
- // no shadow by default
1199
- this.shadowColor = CLEAR_BLACK;
1200
- }
1201
- render()
1202
- {
1203
- uiSystem.drawTile(this.nativePos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
1204
- }
1205
- }
1206
-
1207
- ///////////////////////////////////////////////////////////////////////////////
1208
- /**
1209
- * UIButton - A UI object that acts as a button
1210
- * @extends UIObject
1211
- * @memberof UISystem
1212
- */
1213
- class UIButton extends UIObject
1214
- {
1215
- /** Create a UIButton object
1216
- * @param {Vector2} [pos]
1217
- * @param {Vector2} [size]
1218
- * @param {string} [text]
1219
- * @param {Color} [color=uiSystem.defaultButtonColor]
1220
- */
1221
- constructor(pos, size, text='', color=uiSystem.defaultButtonColor)
1222
- {
1223
- super(pos, size);
1224
-
1225
- ASSERT(isStringLike(text), 'ui button must be a string');
1226
- ASSERT(isColor(color), 'ui button color must be a color');
1227
-
1228
- /** @property {Vector2} - Text offset for the button */
1229
- this.textOffset = vec2();
1230
-
1231
- // set properties
1232
- this.text = text;
1233
- this.color = color.copy();
1234
- this.interactive = true;
1235
- }
1236
- render()
1237
- {
1238
- super.render();
1239
-
1240
- // draw the text scaled to fit
1241
- const textSize = this.getTextSize();
1242
- uiSystem.drawText(this.text, this.nativePos.add(this.textOffset), textSize,
1243
- this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
1244
- }
1245
- }
1246
-
1247
- ///////////////////////////////////////////////////////////////////////////////
1248
- /**
1249
- * UICheckbox - A UI object that acts as a checkbox
1250
- * @extends UIObject
1251
- * @memberof UISystem
1252
- */
1253
- class UICheckbox extends UIObject
1254
- {
1255
- /** Create a UICheckbox object
1256
- * @param {Vector2} [pos]
1257
- * @param {Vector2} [size]
1258
- * @param {boolean} [checked]
1259
- * @param {string} [text]
1260
- * @param {Color} [color=uiSystem.defaultButtonColor]
1261
- */
1262
- constructor(pos, size, checked=false, text='', color=uiSystem.defaultButtonColor)
1263
- {
1264
- super(pos, size);
1265
-
1266
- ASSERT(isStringLike(text), 'ui checkbox must be a string');
1267
- ASSERT(isColor(color), 'ui checkbox color must be a color');
1268
-
1269
- /** @property {boolean} - Is the checkbox currently checked? */
1270
- this.checked = checked;
1271
- // set properties
1272
- this.text = text;
1273
- this.color = color.copy();
1274
- this.interactive = true;
1275
- }
1276
- click()
1277
- {
1278
- this.checked = !this.checked;
1279
- this.onClick();
1280
- this.onChange();
1281
- }
1282
- render()
1283
- {
1284
- super.render();
1285
- if (this.checked)
1286
- {
1287
- const p = this.cornerRadius / min(this.size.x, this.size.y) * 2;
1288
- const length = lerp(1, 2**.5/2, p) / 2;
1289
- let s = this.size.scale(length);
1290
- uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1))), this.nativePos.add(s.multiply(vec2(1))), this.lineWidth, this.lineColor);
1291
- uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1,1))), this.nativePos.add(s.multiply(vec2(1,-1))), this.lineWidth, this.lineColor);
1292
- }
1293
-
1294
- // draw the text next to the checkbox
1295
- const textSize = this.getTextSize();
1296
- const pos = this.nativePos.add(vec2(this.size.x,0));
1297
- uiSystem.drawText(this.text, pos, textSize,
1298
- this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
1299
- }
1300
- }
1301
-
1302
- ///////////////////////////////////////////////////////////////////////////////
1303
- /**
1304
- * UISlider - A UI object that acts as a slider or scrollbar
1305
- * @extends UIObject
1306
- * @memberof UISystem
1307
- */
1308
- class UISlider extends UIObject
1309
- {
1310
- /** Create a UISlider object
1311
- * @param {Vector2} [pos]
1312
- * @param {Vector2} [size]
1313
- * @param {number} [value]
1314
- * @param {string} [text]
1315
- * @param {Color} [color=uiSystem.defaultButtonColor]
1316
- * @param {Color} [handleColor=WHITE]
1317
- */
1318
- constructor(pos, size, value=.5, text='', color=uiSystem.defaultButtonColor, handleColor=WHITE)
1319
- {
1320
- super(pos, size);
1321
-
1322
- ASSERT(isNumber(value), 'ui slider value must be a number');
1323
- ASSERT(isStringLike(text), 'ui slider must be a string');
1324
- ASSERT(isColor(color), 'ui slider color must be a color');
1325
- ASSERT(isColor(handleColor), 'ui slider handleColor must be a color');
1326
-
1327
- /** @property {number} - Current percentage value of this slider 0-1 */
1328
- this.value = value;
1329
- /** @property {Color} - Color for the handle part of the slider */
1330
- this.handleColor = handleColor.copy();
1331
- /** @property {boolean} - Should it fill up like a progress bar? */
1332
- this.fillMode = false;
1333
-
1334
- // set properties
1335
- this.text = text;
1336
- this.color = color.copy();
1337
- this.interactive = true;
1338
- }
1339
- update()
1340
- {
1341
- super.update();
1342
- if (!this.interactive)
1343
- return;
1344
-
1345
- const oldValue = this.value;
1346
- if (this.isActiveObject())
1347
- {
1348
- // handle horizontal or vertical slider
1349
- const isHorizontal = this.size.x > this.size.y;
1350
- const handleSize = isHorizontal ? this.size.y : this.size.x;
1351
- const barSize = isHorizontal ? this.size.x : this.size.y;
1352
- const centerPos = isHorizontal ? this.nativePos.x : this.nativePos.y;
1353
-
1354
- // check if value changed
1355
- const handleWidth = barSize - handleSize;
1356
- const p1 = centerPos - handleWidth/2;
1357
- const p2 = centerPos + handleWidth/2;
1358
- const p = uiSystem.screenToNative(mousePosScreen);
1359
- this.value = isHorizontal ?
1360
- percent(p.x, p1, p2) :
1361
- percent(p.y, p2, p1);
1362
- }
1363
- else if (this.isNavigationObject())
1364
- {
1365
- // gamepad/keyboard navigation adjustment
1366
- const direction = uiSystem.getNavigationOtherDirection();
1367
- if (!uiSystem.navigationTimer.active())
1368
- this.value = clamp(this.value + direction*.01);
1369
- }
1370
- this.value === oldValue || this.onChange();
1371
- }
1372
- render()
1373
- {
1374
- super.render();
1375
-
1376
- // handle horizontal or vertical slider
1377
- const isHorizontal = this.size.x > this.size.y;
1378
- const barWidth = isHorizontal ? this.size.x : this.size.y;
1379
- const handleWidth = isHorizontal ? this.size.y : this.size.x;
1380
- if (this.fillMode)
1381
- {
1382
- // draw progress bar
1383
- const minWidth = min(handleWidth, this.cornerRadius * 2);
1384
- const progressWidth = lerp(minWidth, barWidth, this.value);
1385
- const p = (progressWidth - barWidth) * (isHorizontal ? .5 : -.5);
1386
- const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
1387
- const color = this.disabled ? this.disabledColor : this.handleColor;
1388
- const drawSize = isHorizontal ?
1389
- vec2(progressWidth, this.size.y) : vec2(this.size.x, progressWidth);
1390
- uiSystem.drawRect(pos, drawSize, color, this.lineWidth, this.lineColor, this.cornerRadius, this.gradientColor);
1391
- }
1392
- else
1393
- {
1394
- // draw the slider handle
1395
- const value = clamp(isHorizontal ? this.value : 1 - this.value);
1396
- const p = (barWidth - handleWidth) * (value - .5);
1397
- const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
1398
- const color = this.disabled ? this.disabledColor : this.handleColor;
1399
- const drawSize = vec2(handleWidth);
1400
- uiSystem.drawRect(pos, drawSize, color, this.lineWidth, this.lineColor, this.cornerRadius, this.gradientColor);
1401
- }
1402
-
1403
- // draw the text scaled to fit on the slider
1404
- const textSize = this.getTextSize();
1405
- uiSystem.drawText(this.text, this.nativePos, textSize,
1406
- this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
1407
- }
1408
- navigatePressed()
1409
- {
1410
- // toggle value between 0 and 1
1411
- this.value = this.value ? 0 : 1;
1412
- this.onChange();
1413
- this.onRelease();
1414
- super.navigatePressed();
1415
- }
1416
- }
1417
-
1418
- ///////////////////////////////////////////////////////////////////////////////
1419
- /**
1420
- * VideoPlayerUIObject - A UI object that plays video
1421
- * @extends UIObject
1422
- * @example
1423
- * // Create a video player UI object
1424
- * const video = new VideoPlayerUIObject(vec2(400, 300), vec2(320, 240), 'video.mp4', true);
1425
- * video.play();
1426
- * @memberof UISystem
1427
- */
1428
- class UIVideo extends UIObject
1429
- {
1430
- /** Create a video player UI object
1431
- * @param {Vector2} pos
1432
- * @param {Vector2} size
1433
- * @param {string} src - Video file path or URL
1434
- * @param {boolean} [autoplay=false] - Start playing immediately?
1435
- * @param {boolean} [loop=false] - Loop the video?
1436
- * @param {number} [volume=1] - Volume percent scaled by global volume (0-1)
1437
- */
1438
- constructor(pos, size, src, autoplay=false, loop=false, volume=1)
1439
- {
1440
- super(pos, size || vec2());
1441
-
1442
- ASSERT(isStringLike(src), 'video src must be a string');
1443
- ASSERT(isNumber(volume), 'video volume must be a number');
1444
-
1445
- this.color = BLACK; // default to black background
1446
- this.cornerRadius = 0; // default to no corner radius
1447
-
1448
- /** @property {number} - The video volume */
1449
- this.volume = volume;
1450
-
1451
- // create video element
1452
- /** @property {HTMLVideoElement} - The video player */
1453
- this.video = document.createElement('video');
1454
- this.video.loop = loop;
1455
- this.video.volume = clamp(volume * soundVolume);
1456
- this.video.muted = !soundEnable;
1457
- this.video.style.display = 'none';
1458
- this.video.src = src;
1459
- document.body.appendChild(this.video);
1460
- autoplay && this.play();
1461
- }
1462
-
1463
- /** Play or resume the video
1464
- * @return {Promise} Promise that resolves when playback starts */
1465
- async play()
1466
- {
1467
- // try to play the video, catch any errors (autoplay may be blocked)
1468
- try { await this.video.play(); }
1469
- catch(e) {}
1470
- }
1471
-
1472
- /** Pause the video */
1473
- pause() { this.video.pause(); }
1474
-
1475
- /** Stop and reset the video */
1476
- stop() { this.video.pause(); this.video.currentTime = 0; }
1477
-
1478
- /** Check if video is currently loading
1479
- * @return {boolean} */
1480
- isLoading()
1481
- { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
1482
-
1483
- /** Check if video is currently paused
1484
- * @return {boolean} */
1485
- isPaused() { return this.video.paused; }
1486
-
1487
- /** Check if video is currently playing
1488
- * @return {boolean} */
1489
- isPlaying()
1490
- { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); }
1491
-
1492
- /** Check if video has ended playing
1493
- * @return {boolean} */
1494
- hasEnded() { return this.video.ended; }
1495
-
1496
- /** Set volume (0-1)
1497
- * @param {number} volume - Volume level (0-1) */
1498
- setVolume(volume)
1499
- {
1500
- this.volume = volume;
1501
- this.video.volume = clamp(volume * soundVolume);
1502
- }
1503
-
1504
- /** Set playback speed
1505
- * @param {number} rate - Playback rate multiplier */
1506
- setPlaybackRate(rate) { this.video.playbackRate = rate; }
1507
-
1508
- /** Get current time in seconds
1509
- * @return {number} Current playback time */
1510
- getCurrentTime() { return this.video.currentTime || 0; }
1511
-
1512
- /** Get duration in seconds
1513
- * @return {number} Total video duration */
1514
- getDuration() { return this.video.duration || 0; }
1515
-
1516
- /** Get the native video dimensions
1517
- * @return {Vector2} Video dimensions (may be 0,0 if metadata not loaded) */
1518
- getVideoSize()
1519
- { return vec2(this.video.videoWidth, this.video.videoHeight); }
1520
-
1521
- /** Seek to time in seconds
1522
- * @param {number} time - Time in seconds to seek to */
1523
- setTime(time)
1524
- { this.video.currentTime = clamp(time, 0, this.getDuration()); }
1525
-
1526
- update()
1527
- {
1528
- super.update();
1529
-
1530
- // update volume based on global sound volume
1531
- this.video.volume = clamp(this.volume * soundVolume);
1532
- }
1533
-
1534
- /** Render video to UI canvas */
1535
- render()
1536
- {
1537
- super.render();
1538
-
1539
- if (this.isLoading())
1540
- return;
1541
- const context = uiSystem.uiContext;
1542
- const s = this.size;
1543
- context.save();
1544
- context.translate(this.nativePos.x, this.nativePos.y);
1545
- context.drawImage(this.video, -s.x/2, -s.y/2, s.x, s.y);
1546
- context.restore();
1547
- }
1548
-
1549
- /** Clean up video on destroy */
1550
- destroy()
1551
- {
1552
- if (this.destroyed)
1553
- return;
1554
-
1555
- this.video.pause();
1556
- this.video.remove();
1557
- super.destroy();
1558
- }
1559
- }
1560
-
1561
- ///////////////////////////////////////////////////////////////////////////////
1562
- /**
1563
- * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
1564
- * - Set columns to 1 for a vertical list (default)
1565
- * - Set columns to children.length for a horizontal list
1566
- * - Set columns to N (1 < N < children.length) for a grid with N columns
1567
- * - Per-child sizing: each row's height = max child.size.y in that row, each column's width = max child.size.x in that column
1568
- * - Children are positioned centered in their cell
1569
- * - Container auto-sizes to fit children plus padding
1570
- * @extends UIObject
1571
- * @memberof UISystem
1572
- */
1573
- class UILayout extends UIObject
1574
- {
1575
- /** Create a UILayout container that auto-arranges children
1576
- * @param {Vector2} [pos]
1577
- * @param {number} [columns=1] - Number of columns (1 = vertical list)
1578
- * @param {number} [gap=10] - Space between children
1579
- * @param {number} [padding=10] - Space between container border and children
1580
- * @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
1581
- */
1582
- constructor(pos, columns=1, gap=10, padding=10, transparent=false)
1583
- {
1584
- super(pos);
1585
-
1586
- ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
1587
- ASSERT(isNumber(gap), 'ui layout gap must be a number');
1588
- ASSERT(isNumber(padding), 'ui layout padding must be a number');
1589
-
1590
- /** @property {number} - Number of columns in the layout */
1591
- this.columns = columns;
1592
- /** @property {number} - Space between children */
1593
- this.gap = gap;
1594
- /** @property {number} - Space between container border and children */
1595
- this.padding = padding;
1596
-
1597
- if (transparent)
1598
- {
1599
- // pure positioning helper - skip background, outline, and shadow
1600
- this.color = CLEAR_BLACK;
1601
- this.gradientColor = undefined;
1602
- this.lineWidth = 0;
1603
- this.shadowColor = CLEAR_BLACK;
1604
- }
1605
- this.relayout();
1606
- }
1607
-
1608
- /** Add a child UIObject and re-layout
1609
- * @param {UIObject} child
1610
- * @return {UIObject} The child object added */
1611
- addChild(child)
1612
- {
1613
- super.addChild(child);
1614
- this.relayout();
1615
- return child;
1616
- }
1617
-
1618
- /** Remove a child UIObject and re-layout
1619
- * @param {UIObject} child */
1620
- removeChild(child)
1621
- {
1622
- super.removeChild(child);
1623
- this.relayout();
1624
- }
1625
-
1626
- /** Recompute child positions and container size based on per-child sizes.
1627
- * Called automatically by addChild and removeChild. Call manually if you
1628
- * mutate a child's size or change columns, gap, or padding. */
1629
- relayout()
1630
- {
1631
- const n = this.children.length;
1632
- if (!n)
1633
- {
1634
- this.size = vec2(this.padding * 2);
1635
- return;
1636
- }
1637
-
1638
- const cols = this.columns;
1639
- const rows = ceil(n / cols);
1640
- const colWidths = new Array(cols).fill(0);
1641
- const rowHeights = new Array(rows).fill(0);
1642
-
1643
- // first pass: compute column widths and row heights from child sizes
1644
- for (let i = 0; i < n; ++i)
1645
- {
1646
- const col = i % cols;
1647
- const row = floor(i / cols);
1648
- const child = this.children[i];
1649
- colWidths[col] = max(colWidths[col], child.size.x);
1650
- rowHeights[row] = max(rowHeights[row], child.size.y);
1651
- }
1652
-
1653
- // total content size (sum of column widths/row heights plus gaps between them)
1654
- let contentWidth = this.gap * (cols - 1);
1655
- for (const w of colWidths) contentWidth += w;
1656
- let contentHeight = this.gap * (rows - 1);
1657
- for (const h of rowHeights) contentHeight += h;
1658
-
1659
- // cumulative column/row offsets so positioning is O(n) not O(n^2)
1660
- const colOffsets = new Array(cols);
1661
- let xAcc = 0;
1662
- for (let c = 0; c < cols; ++c)
1663
- {
1664
- colOffsets[c] = xAcc;
1665
- xAcc += colWidths[c];
1666
- }
1667
- const rowOffsets = new Array(rows);
1668
- let yAcc = 0;
1669
- for (let r = 0; r < rows; ++r)
1670
- {
1671
- rowOffsets[r] = yAcc;
1672
- yAcc += rowHeights[r];
1673
- }
1674
-
1675
- // second pass: position each child centered in its cell
1676
- for (let i = 0; i < n; ++i)
1677
- {
1678
- const col = i % cols;
1679
- const row = floor(i / cols);
1680
- const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
1681
- const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
1682
- this.children[i].localPos = vec2(x, y);
1683
- }
1684
-
1685
- // container size = content + padding on all sides
1686
- this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
1687
- }
1
+ /**
2
+ * LittleJS User Interface Plugin
3
+ * - call new UISystemPlugin() to setup the UI system
4
+ * - Gamepad and keyboard navigation support
5
+ * - Nested Menus
6
+ * - Text
7
+ * - Buttons
8
+ * - Checkboxes
9
+ * - Images
10
+ * - Sliders
11
+ * - Video
12
+ * @namespace UISystem
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ ///////////////////////////////////////////////////////////////////////////////
18
+
19
+ /** Global UI system plugin object
20
+ * @type {UISystemPlugin}
21
+ * @memberof UISystem */
22
+ let uiSystem;
23
+
24
+ /** Enable UI system debug drawing
25
+ * 0=off, 1=normal, 2=show invisible
26
+ * @type {number}
27
+ * @default
28
+ * @memberof UISystem */
29
+ let uiDebug = 0;
30
+
31
+ /** Enable UI system debug drawing
32
+ * 0=off, 1=normal, 2=show invisible
33
+ * @param {number|boolean} debugMode
34
+ * @memberof UISystem */
35
+ function uiSetDebug(debugMode)
36
+ { uiDebug = typeof debugMode === 'boolean' ? (debugMode ? 1 : 0) : debugMode; }
37
+
38
+ ///////////////////////////////////////////////////////////////////////////////
39
+ /**
40
+ * UI System Global Object
41
+ * @memberof UISystem
42
+ */
43
+ class UISystemPlugin
44
+ {
45
+ /** Create the global UI system object
46
+ * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
47
+ * @example
48
+ * // create the ui plugin object
49
+ * new UISystemPlugin;
50
+ */
51
+ constructor(context=mainContext)
52
+ {
53
+ ASSERT(!uiSystem, 'UI system already initialized');
54
+ ASSERT(context || headlessMode, 'create the UISystemPlugin after engineInit, in gameInit');
55
+ uiSystem = this;
56
+
57
+ // default settings
58
+ /** @property {boolean} - Activate when mouse is pressed down instead of clicked */
59
+ this.activateOnPress = false;
60
+ /** @property {Color} - Default fill color for UI elements */
61
+ this.defaultColor = WHITE;
62
+ /** @property {Color} - Default outline color for UI elements */
63
+ this.defaultLineColor = BLACK;
64
+ /** @property {Color} - Default text color for UI elements */
65
+ this.defaultTextColor = BLACK;
66
+ /** @property {Color} - Default button color for UI elements */
67
+ this.defaultButtonColor = hsl(0,0,.7);
68
+ /** @property {Color} - Default hover color for UI elements */
69
+ this.defaultHoverColor = hsl(0,0,.9);
70
+ /** @property {Color} - Default color for disabled UI elements */
71
+ this.defaultDisabledColor = hsl(0,0,.3);
72
+ /** @property {Color|undefined} - Uses a gradient fill combined with color
73
+ * @type {Color|undefined} */
74
+ this.defaultGradientColor = undefined;
75
+ /** @property {number} - Default line width for UI elements */
76
+ this.defaultLineWidth = 4;
77
+ /** @property {number} - Default rounded rect corner radius for UI elements */
78
+ this.defaultCornerRadius = 0;
79
+ /** @property {number} - Default scale to use for fitting text to object */
80
+ this.defaultTextFitScale = .8;
81
+ /** @property {string} - Default font for UI elements */
82
+ this.defaultFont = fontDefault;
83
+ /** @property {Sound|undefined} - Default sound when interactive UI element is pressed
84
+ * @type {Sound|undefined} */
85
+ this.defaultSoundPress = undefined;
86
+ /** @property {Sound|undefined} - Default sound when interactive UI element is released
87
+ * @type {Sound|undefined} */
88
+ this.defaultSoundRelease = undefined;
89
+ /** @property {Sound|undefined} - Default sound when interactive UI element is clicked
90
+ * @type {Sound|undefined} */
91
+ this.defaultSoundClick = undefined;
92
+ /** @property {Color} - Color for shadow */
93
+ this.defaultShadowColor = CLEAR_BLACK;
94
+ /** @property {number} - Size of shadow blur */
95
+ this.defaultShadowBlur = 5;
96
+ /** @property {Vector2} - Offset of shadow blur */
97
+ this.defaultShadowOffset = vec2(5);
98
+ /** @property {TileSlice|undefined} - Style to draw UI elements with in place of their rectangle, tinted by
99
+ * their color, undefined for rectangles; needs the drawUtilities plugin
100
+ * @type {TileSlice|undefined} */
101
+ this.defaultSlice = undefined;
102
+ /** @property {TileSlice|undefined} - Style to draw slider handles with, undefined for the slider's own slice
103
+ * @type {TileSlice|undefined} */
104
+ this.defaultHandleSlice = undefined;
105
+ /** @property {number} - If set ui coords will be renormalized to this canvas height */
106
+ this.nativeHeight = 0;
107
+
108
+ // navigation properties
109
+ /** @property {UIObject|undefined} - Object currently selected by navigation (gamepad or keyboard)
110
+ * @type {UIObject|undefined} */
111
+ this.navigationObject = undefined;
112
+ /** @property {Timer} - Cool down timer for navigation inputs */
113
+ this.navigationTimer = new Timer(undefined, true);
114
+ /** @property {number} - Time between navigation inputs in seconds */
115
+ this.navigationDelay = .2;
116
+ /** @property {number} - Which way keys and gamepads move the selection: 0 horizontal, 1 vertical, 2 both */
117
+ this.navigationDirection = 1;
118
+ /** @property {boolean} - True if user last used navigation instead of mouse */
119
+ this.navigationMode = false;
120
+
121
+ // system state
122
+ /** @property {Array<UIObject>} - List of all UI elements
123
+ * @type {Array<UIObject>} */
124
+ this.uiObjects = [];
125
+ /** @property {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} - Context to render UI elements to */
126
+ this.uiContext = context;
127
+ /** @property {UIObject|undefined} - Object user is currently interacting with
128
+ * @type {UIObject|undefined} */
129
+ this.activeObject = undefined;
130
+ /** @property {UIObject|undefined} - Top most object user is over
131
+ * @type {UIObject|undefined} */
132
+ this.hoverObject = undefined;
133
+ /** @property {UIObject|undefined} - Hover object at start of update
134
+ * @type {UIObject|undefined} */
135
+ this.lastHoverObject = undefined;
136
+ /** @property {UIObject|undefined} - Current confirm menu being shown
137
+ * @type {UIObject|undefined} */
138
+ this.confirmDialog = undefined;
139
+ /** @private
140
+ * @type {UIObject|undefined} */
141
+ this._keyInputObject = undefined;
142
+ /** @private
143
+ * @type {Array<Array<any>>|undefined} */
144
+ this._dragListeners = undefined;
145
+ /** @private */
146
+ this._onKeyDown = (e) =>
147
+ {
148
+ // a field that was hidden, disabled or destroyed since it took focus, or is behind a confirm dialog opened
149
+ // since, does not take the key, the game gets it, and the next UI update ends the edit, so it ends one
150
+ // way whichever comes first
151
+ const o = this._keyInputObject;
152
+ if (!o || !uiObjectIsUsable(o) || uiObjectIsBehindDialog(o)) return;
153
+
154
+ // the field has the key, the game's input never sees it; browser shortcuts still work,
155
+ // and only the keys a field uses lose their default, so F5, F11, F12 and the like still work
156
+ e.stopPropagation();
157
+ const key = e.key || '';
158
+ if (!e.ctrlKey && !e.metaKey && !e.altKey)
159
+ if (key.length === 1 ||
160
+ /^(Backspace|Delete|Tab|Arrow(Up|Down|Left|Right)|Home|End|Enter|Escape)$/.test(key))
161
+ e.preventDefault(); // no scrolling, find as you type or going back
162
+ e.type === 'keydown' && o.onKeyDown(e);
163
+ };
164
+
165
+ engineAddPlugin(uiUpdate, uiRender);
166
+
167
+ // set object position based on anchor target (parent box, or canvas for roots),
168
+ // self-pivot, and localPos offset
169
+ function updateTransforms(o)
170
+ {
171
+ let targetPos, targetSize;
172
+ if (o.parent)
173
+ {
174
+ targetPos = o.parent.nativePos;
175
+ targetSize = o.parent.size;
176
+ }
177
+ else
178
+ {
179
+ // anchor to canvas in native coords (handles nativeHeight if set)
180
+ targetPos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
181
+ targetSize = uiSystem.nativeHeight
182
+ ? vec2(mainCanvasSize.x * uiSystem.nativeHeight / mainCanvasSize.y,
183
+ uiSystem.nativeHeight)
184
+ : mainCanvasSize;
185
+ }
186
+
187
+ const a = o.anchor;
188
+ o.nativePos = targetPos
189
+ .add(targetSize.multiply(a).scale(.5)) // anchor point on target
190
+ .subtract(o.size.multiply(a).scale(.5)) // pivot shift on self
191
+ .add(o.localPos); // user offset
192
+ }
193
+
194
+ // setup recursive update and render
195
+ // update in reverse order to detect mouse enter/leave
196
+ function uiUpdate()
197
+ {
198
+ // a held or focused object that can no longer be used, itself or through a parent, lets go,
199
+ // and one that was hidden or disabled is still released, a destroyed one stays silent
200
+ const activeObject = uiSystem.activeObject;
201
+ if (activeObject && !uiObjectIsUsable(activeObject))
202
+ {
203
+ // a text field being edited is active without being held, it was released when the press ended
204
+ uiSystem.activeObject = undefined;
205
+ activeObject.destroyed || activeObject === uiSystem.keyInputObject || activeObject.onRelease();
206
+ }
207
+ // an edit whose field can no longer be used, or is behind a confirm dialog, ends as if it were finished,
208
+ // so onChange keeps the text
209
+ const keyInputObject = uiSystem.keyInputObject;
210
+ if (keyInputObject && (!uiObjectIsUsable(keyInputObject) || uiObjectIsBehindDialog(keyInputObject)))
211
+ {
212
+ if (keyInputObject instanceof UITextInput && !keyInputObject.destroyed)
213
+ keyInputObject.stopEditing();
214
+ else
215
+ uiSystem.keyInputObject = undefined;
216
+ }
217
+
218
+ // a click off the field being edited ends the edit before anything updates, so the click goes on to
219
+ // what it lands on, another field or a button, as in a web form
220
+ const editing = uiSystem.keyInputObject;
221
+ const clickedOff = editing instanceof UITextInput && mouseWasPressed(0) && !editing.isMouseOverlapping();
222
+ clickedOff && editing.stopEditing();
223
+
224
+ // reset hover object at start of update
225
+ uiSystem.lastHoverObject = uiSystem.hoverObject;
226
+ uiSystem.hoverObject = undefined;
227
+
228
+ // a hidden object is not updated, so the one whose hover ends by hiding it, itself or through a parent,
229
+ // is left here; a disabled one still updates and handles its own leave
230
+ const lastHoverObject = uiSystem.lastHoverObject;
231
+ if (lastHoverObject && !lastHoverObject.destroyed && uiObjectIsHidden(lastHoverObject))
232
+ {
233
+ uiSystem.lastHoverObject = undefined;
234
+ lastHoverObject.onLeave();
235
+ }
236
+
237
+ if (mouseWasPressed(0))
238
+ {
239
+ // exit navigation mode on mouse press
240
+ uiSystem.navigationMode = false;
241
+ uiSystem.navigationObject = undefined;
242
+ }
243
+ if (uiSystem.keyInputObject)
244
+ {
245
+ // handle text input, navigation keeps its place for when the edit ends, and the hover stays where it
246
+ // was, so an edit started by navigation does not end with an onLeave that had no onEnter
247
+ uiSystem.activeObject = uiSystem.keyInputObject;
248
+ uiSystem.hoverObject = uiSystem.lastHoverObject;
249
+ }
250
+
251
+ // navigation with gamepad/keyboard
252
+ const navigableObjects = uiSystem.getNavigableObjects();
253
+ if (!navigableObjects.length)
254
+ uiSystem.navigationObject = undefined;
255
+ else if (!uiSystem.keyInputObject)
256
+ {
257
+ // unselect object if it is no longer navigable
258
+ if (!navigableObjects.includes(uiSystem.navigationObject))
259
+ uiSystem.navigationObject = undefined;
260
+
261
+ if (!isTouchDevice)
262
+ if (uiSystem.navigationMode && !uiSystem.navigationObject)
263
+ {
264
+ // select first auto focus object
265
+ uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
266
+ }
267
+
268
+ // navigate with dpad or left stick
269
+ if (!uiSystem.navigationTimer.active())
270
+ {
271
+ // navigate through list with gamepad or keyboard
272
+ const direction = sign(uiSystem.getNavigationDirection());
273
+ if (direction)
274
+ {
275
+ let newNavigationObject;
276
+ if (!uiSystem.navigationObject)
277
+ {
278
+ // use auto select object
279
+ newNavigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
280
+
281
+ if (!newNavigationObject)
282
+ {
283
+ // try first or last object
284
+ const newIndex = direction > 0 ? 0 : navigableObjects.length-1;
285
+ newNavigationObject = navigableObjects[newIndex];
286
+ }
287
+ }
288
+ else
289
+ {
290
+ const currentIndex = navigableObjects.indexOf(uiSystem.navigationObject);
291
+ const newIndex = mod(currentIndex + direction, navigableObjects.length);
292
+ newNavigationObject = navigableObjects[newIndex];
293
+ }
294
+
295
+ if (uiSystem.navigationObject !== newNavigationObject)
296
+ {
297
+ uiSystem.navigationMode = true;
298
+ uiSystem.hoverObject = undefined;
299
+ uiSystem.navigationObject = newNavigationObject;
300
+ uiSystem.navigationTimer.set(uiSystem.navigationDelay);
301
+ newNavigationObject.soundPress &&
302
+ newNavigationObject.soundPress.play();
303
+ }
304
+ }
305
+ }
306
+
307
+ // activate the navigation object when pressed, the press is used up as a mouse click is
308
+ if (uiSystem.navigationObject)
309
+ if (uiSystem.getNavigationWasPressed())
310
+ {
311
+ uiSystem.navigationObject.navigatePressed();
312
+ if (isUsingGamepad)
313
+ inputClearKey(0, gamepadPrimary+1, false, true, false);
314
+ else
315
+ {
316
+ inputClearKey('Space', 0, false, true, false);
317
+ inputClearKey('Enter', 0, false, true, false);
318
+ }
319
+ }
320
+ }
321
+
322
+ // update in reverse order so topmost objects get priority, from the list as it was
323
+ // since a callback may call destroyObjects, which swaps in a shorter one
324
+ // a confirm dialog is modal, so it goes first however late it was made, and UI made after it opened
325
+ // can not take the mouse through it
326
+ const uiObjects = uiSystem.uiObjects, dialog = uiSystem.confirmDialog;
327
+ dialog && updateObject(dialog);
328
+ for (let i = uiObjects.length; i--;)
329
+ {
330
+ const o = uiObjects[i];
331
+ o.parent || o === dialog || updateObject(o);
332
+ }
333
+
334
+ // a click off the field that no UI object took is used up, the game does not see the click that ended it
335
+ clickedOff && mouseWasPressed(0) && inputClearKey(0, 0);
336
+
337
+ // remove destroyed objects
338
+ uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed);
339
+
340
+ function updateObject(o)
341
+ {
342
+ if (o.destroyed || !o.visible) return;
343
+
344
+ // update in reverse order to detect mouse enter/leave, from a copy since a child may destroy
345
+ // siblings mid-update (e.g. dialog close) and the ones after it would shift under the loop
346
+ updateTransforms(o);
347
+ const children = o.children.slice();
348
+ for (let i=children.length; i--;)
349
+ updateObject(children[i]);
350
+ if (!o.destroyed)
351
+ o.update();
352
+ }
353
+ }
354
+ function uiRender()
355
+ {
356
+ const context = uiSystem.uiContext;
357
+ context.save();
358
+ if (uiSystem.nativeHeight)
359
+ {
360
+ // convert to native height
361
+ const s = mainCanvasSize.y / uiSystem.nativeHeight;
362
+ context.translate(-s*mainCanvasSize.x/2,0);
363
+ context.scale(s,s);
364
+ context.translate(mainCanvasSize.x/2/s,0);
365
+ }
366
+
367
+ function renderObject(o)
368
+ {
369
+ // a destroyed object stays in the list until the next update, but is gone now
370
+ if (o.destroyed || !o.visible) return;
371
+
372
+ // render object and children
373
+ updateTransforms(o);
374
+ o.render();
375
+ for (const c of o.children)
376
+ renderObject(c);
377
+ }
378
+ // a confirm dialog is drawn over everything, UI made after it opened too
379
+ const dialog = uiSystem.confirmDialog;
380
+ uiSystem.uiObjects.forEach(o=> o.parent || o === dialog || renderObject(o));
381
+ dialog && renderObject(dialog);
382
+
383
+ if (uiDebug > 0)
384
+ {
385
+ // debug render all objects
386
+ function renderDebug(o, visible=true)
387
+ {
388
+ visible &&= !!o.visible;
389
+ if (o.destroyed || !visible && uiDebug < 2)
390
+ return; // only mode 2 shows the invisible ones
391
+ updateTransforms(o);
392
+ o.renderDebug(visible);
393
+ for (const c of o.children)
394
+ renderDebug(c, visible);
395
+ }
396
+ uiSystem.uiObjects.forEach(o=> o.parent || o === dialog || renderDebug(o));
397
+ dialog && renderDebug(dialog);
398
+ }
399
+ context.restore();
400
+ }
401
+ }
402
+
403
+ /** Draw a rectangle to the UI context
404
+ * @param {Vector2} pos
405
+ * @param {Vector2} size
406
+ * @param {Color} [color]
407
+ * @param {number} [lineWidth]
408
+ * @param {Color} [lineColor]
409
+ * @param {number} [cornerRadius]
410
+ * @param {Color} [gradientColor]
411
+ * @param {Color} [shadowColor]
412
+ * @param {number} [shadowBlur]
413
+ * @param {Vector2} [shadowOffset] */
414
+ drawRect(pos, size, color=WHITE, lineWidth=0, lineColor=BLACK, cornerRadius=0, gradientColor, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
415
+ {
416
+ ASSERT(isVector2(pos), 'pos must be a vec2');
417
+ ASSERT(isVector2(size), 'size must be a vec2');
418
+ ASSERT(isColor(color), 'color must be a color');
419
+ ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
420
+ ASSERT(isColor(lineColor), 'lineColor must be a color');
421
+ ASSERT(isNumber(cornerRadius), 'cornerRadius must be a number');
422
+
423
+ const context = uiSystem.uiContext;
424
+ if (gradientColor)
425
+ {
426
+ const g = context.createLinearGradient(
427
+ pos.x, pos.y-size.y/2, pos.x, pos.y+size.y/2);
428
+ const c = color.toString();
429
+ g.addColorStop(0, c);
430
+ g.addColorStop(.5, gradientColor.toString());
431
+ g.addColorStop(1, c);
432
+ context.fillStyle = g;
433
+ }
434
+ else
435
+ context.fillStyle = color.toString();
436
+ uiSetShadow(context, shadowColor, shadowBlur, shadowOffset);
437
+ context.beginPath();
438
+ if (cornerRadius && context['roundRect'])
439
+ context['roundRect'](pos.x-size.x/2, pos.y-size.y/2, size.x, size.y, cornerRadius);
440
+ else
441
+ context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
442
+ context.fill();
443
+ context.shadowColor = '#0000';
444
+ if (lineWidth && lineColor.a > 0)
445
+ {
446
+ context.strokeStyle = lineColor.toString();
447
+ context.lineWidth = lineWidth;
448
+ context.stroke();
449
+ }
450
+ }
451
+
452
+ /** Draw a TileSlice to the UI context, in place of a rectangle
453
+ * @param {TileSlice} slice
454
+ * @param {Vector2} pos
455
+ * @param {Vector2} size
456
+ * @param {Color} [color] */
457
+ drawSlice(slice, pos, size, color=WHITE)
458
+ {
459
+ ASSERT(typeof TileSlice === 'function' && slice instanceof TileSlice, 'slice must be a TileSlice, from the drawUtilities plugin');
460
+ ASSERT(isVector2(pos), 'pos must be a vec2');
461
+ ASSERT(isVector2(size), 'size must be a vec2');
462
+ ASSERT(isColor(color), 'color must be a color');
463
+ if (color.a > 0) // a clear one, like a UIText's, costs a tint of each piece for nothing
464
+ slice.drawScreen(pos, size, color, undefined, 0, false, uiSystem.uiContext);
465
+ }
466
+
467
+ /** Draw a line to the UI context
468
+ * @param {Vector2} posA
469
+ * @param {Vector2} posB
470
+ * @param {number} [lineWidth=uiSystem.defaultLineWidth]
471
+ * @param {Color} [lineColor=uiSystem.defaultLineColor] */
472
+ drawLine(posA, posB, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor)
473
+ {
474
+ ASSERT(isVector2(posA), 'posA must be a vec2');
475
+ ASSERT(isVector2(posB), 'posB must be a vec2');
476
+ ASSERT(isNumber(lineWidth), 'lineWidth must be a number');
477
+ ASSERT(isColor(lineColor), 'lineColor must be a color');
478
+
479
+ const context = uiSystem.uiContext;
480
+ context.strokeStyle = lineColor.toString();
481
+ context.lineWidth = lineWidth;
482
+ context.beginPath();
483
+ context.lineTo(posA.x, posA.y);
484
+ context.lineTo(posB.x, posB.y);
485
+ context.stroke();
486
+ }
487
+
488
+ /** Draw a tile to the UI context
489
+ * @param {Vector2} pos
490
+ * @param {Vector2} size
491
+ * @param {TileInfo} tileInfo
492
+ * @param {Color} [color=uiSystem.defaultColor]
493
+ * @param {number} [angle]
494
+ * @param {boolean} [mirror]
495
+ * @param {Color} [shadowColor]
496
+ * @param {number} [shadowBlur]
497
+ * @param {Vector2} [shadowOffset] */
498
+ drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
499
+ {
500
+ const context = uiSystem.uiContext;
501
+ uiSetShadow(context, shadowColor, shadowBlur, shadowOffset);
502
+ drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, context);
503
+ context.shadowColor = '#0000';
504
+ }
505
+
506
+ /** Draw text to the UI context
507
+ * @param {string} text
508
+ * @param {Vector2} pos
509
+ * @param {Vector2} size
510
+ * @param {Color} [color=uiSystem.defaultColor]
511
+ * @param {number} [lineWidth=uiSystem.defaultLineWidth]
512
+ * @param {Color} [lineColor=uiSystem.defaultLineColor]
513
+ * @param {'left'|'center'|'right'} [align]
514
+ * @param {string} [font=uiSystem.defaultFont]
515
+ * @param {string} [fontStyle]
516
+ * @param {boolean} [applyMaxWidth=true]
517
+ * @param {Vector2} [textShadow]
518
+ * @param {Color} [shadowColor]
519
+ * @param {number} [shadowBlur]
520
+ * @param {Vector2} [shadowOffset] */
521
+ drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
522
+ {
523
+ const context = uiSystem.uiContext;
524
+ if (textShadow && shadowColor.a > 0)
525
+ drawTextScreen(text, pos.add(textShadow), size.y, shadowColor, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
526
+ uiSetShadow(context, shadowColor, shadowBlur, shadowOffset);
527
+ drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
528
+ context.shadowColor = '#0000';
529
+ }
530
+
531
+ /**
532
+ * @callback DragAndDropCallback - Callback for drag and drop events
533
+ * @param {DragEvent} event - The drag event
534
+ * @memberof UISystem
535
+ */
536
+
537
+ /** Setup drag and drop event handlers
538
+ * Automatically prevents defaults and calls the given functions
539
+ * @param {DragAndDropCallback} [onDrop] - when a file is dropped
540
+ * @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window
541
+ * @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window
542
+ * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
543
+ setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
544
+ {
545
+ // remove any prior listeners so repeated setup calls don't stack
546
+ if (this._dragListeners)
547
+ for (const [type, listener] of this._dragListeners)
548
+ document.removeEventListener(type, listener);
549
+ this._dragListeners = [];
550
+ const setCallback = (callback, listenerType, when=()=> true)=>
551
+ {
552
+ const listener = (e)=> { e.preventDefault(); when() && callback && callback(e); };
553
+ document.addEventListener(listenerType, listener);
554
+ this._dragListeners.push([listenerType, listener]);
555
+ };
556
+
557
+ // every element the drag crosses sends its own enter and leave, and a move between two ends with a leave,
558
+ // so they are counted, and only the first enter and the last leave are the window's
559
+ let depth = 0;
560
+ setCallback(onDrop, 'drop', ()=> { depth = 0; return true; });
561
+ setCallback(onDragEnter, 'dragenter', ()=> !depth++);
562
+ setCallback(onDragLeave, 'dragleave', ()=> !!depth && !--depth);
563
+ setCallback(onDragOver, 'dragover');
564
+ }
565
+
566
+ /** Convert a screen space position to native UI position
567
+ * @param {Vector2} pos
568
+ * @return {Vector2} */
569
+ screenToNative(pos)
570
+ {
571
+ if (!uiSystem.nativeHeight)
572
+ return pos;
573
+
574
+ const s = mainCanvasSize.y / uiSystem.nativeHeight;
575
+ const sInv = 1/s;
576
+ const p = pos.copy();
577
+ p.x += s*mainCanvasSize.x/2;
578
+ p.x *= sInv;
579
+ p.y *= sInv;
580
+ p.x -= sInv*mainCanvasSize.x/2;
581
+ return p;
582
+ }
583
+
584
+ /** Object to send keyboard input to (typically a UITextInput), which keeps the keys from the game while set.
585
+ * The keyboard listeners are only attached while this is set,
586
+ * so games that never use text input pay no event-handling cost.
587
+ * To end typing in a field, call its stopEditing(), which also fires its onChange and release sound
588
+ * @type {UIObject|undefined} */
589
+ get keyInputObject() { return this._keyInputObject; }
590
+ set keyInputObject(obj)
591
+ {
592
+ const had = !!this._keyInputObject;
593
+ this._keyInputObject = obj;
594
+ // listen on the window as the event comes down, before the engine's input on the document can see it
595
+ if (!had && obj)
596
+ {
597
+ addEventListener('keydown', this._onKeyDown, true);
598
+ addEventListener('keyup', this._onKeyDown, true);
599
+ inputClearKeyboard(); // keys held when editing starts let go, or they would stay down
600
+
601
+ // a press that starts the edit, as with activateOnPress, is let go now, the updates skip its release
602
+ // while the edit goes on; a click on release already had its release
603
+ const held = this.activeObject;
604
+ if (held && mouseIsDown(0))
605
+ {
606
+ this.activeObject = undefined;
607
+ held.onRelease();
608
+ held.soundRelease && held.soundRelease.play();
609
+ }
610
+ }
611
+ else if (had && !obj)
612
+ {
613
+ removeEventListener('keydown', this._onKeyDown, true);
614
+ removeEventListener('keyup', this._onKeyDown, true);
615
+ }
616
+ }
617
+
618
+ /** Destroy and remove all objects
619
+ * @memberof UISystem */
620
+ destroyObjects()
621
+ {
622
+ for (const o of this.uiObjects)
623
+ o.parent || o.destroy();
624
+ this.uiObjects = this.uiObjects.filter(o=>!o.destroyed);
625
+ this.activeObject = undefined;
626
+ this.hoverObject = undefined;
627
+ this.lastHoverObject = undefined;
628
+ this.keyInputObject = undefined;
629
+ this.confirmDialog = undefined;
630
+ }
631
+
632
+ /** Get all navigable UI objects sorted by navigationIndex
633
+ * @return {Array<UIObject>} */
634
+ getNavigableObjects()
635
+ {
636
+ function getNavigableRecursive(o)
637
+ {
638
+ if (o.destroyed || !o.visible || o.disabled)
639
+ return; // skip children if parent is destroyed, invisible or disabled
640
+
641
+ if (o.isInteractive() && o.navigationIndex !== undefined)
642
+ objects.push(o);
643
+ for (const child of o.children)
644
+ getNavigableRecursive(child);
645
+ }
646
+
647
+ // get all the valid navigable objects recursively
648
+ // while the confirm dialog is open only its buttons can be navigated
649
+ const objects = [];
650
+ if (uiSystem.confirmDialog)
651
+ getNavigableRecursive(uiSystem.confirmDialog);
652
+ else for (const o of uiSystem.uiObjects)
653
+ o.parent || getNavigableRecursive(o);
654
+
655
+ // sort by navigationIndex (lower numbers first), ties keep creation and child order
656
+ objects.sort((a, b)=> a.navigationIndex - b.navigationIndex);
657
+ return objects;
658
+ }
659
+
660
+ /** Check if the mouse is over a visible UI object that can be hovered, or anywhere while the
661
+ * confirm dialog is open, so a game can leave world clicks on the UI alone, on touch too.
662
+ * The UI uses up a click before objects update and gameUpdatePost, so read world clicks there,
663
+ * or check this in gameUpdate, which runs first. Positions are from the last UI update.
664
+ * @return {boolean} */
665
+ isMouseOverUI()
666
+ {
667
+ function isOverRecursive(o)
668
+ {
669
+ if (o.destroyed || !o.visible)
670
+ return false; // a hidden parent hides its children
671
+ if (o.canBeHover && o.isMouseOverlapping())
672
+ return true;
673
+ return o.children.some(isOverRecursive);
674
+ }
675
+
676
+ // a click while a text field is being edited ends the edit, the UI takes it
677
+ if (uiSystem.keyInputObject)
678
+ return true;
679
+
680
+ // while the confirm dialog is open it blocks everything else
681
+ if (uiSystem.confirmDialog)
682
+ return isOverRecursive(uiSystem.confirmDialog);
683
+ return uiSystem.uiObjects.some(o=> !o.parent && isOverRecursive(o));
684
+ }
685
+
686
+ /** Get navigation direction from gamepad or keyboard
687
+ * @return {number} */
688
+ getNavigationDirection()
689
+ {
690
+ const vertical = uiSystem.navigationDirection === 1;
691
+ const both = uiSystem.navigationDirection === 2;
692
+ if (isUsingGamepad)
693
+ {
694
+ const stick = gamepadStick(0, gamepadPrimary);
695
+ const dpad = gamepadDpad(gamepadPrimary);
696
+ if (both)
697
+ return -(stick.y || dpad.y) || (stick.x || dpad.x);
698
+ return vertical ? -(stick.y || dpad.y) : (stick.x || dpad.x);
699
+ }
700
+ const up = 'ArrowUp', down = 'ArrowDown', left = 'ArrowLeft', right = 'ArrowRight';
701
+ if (both)
702
+ {
703
+ return keyIsDown(up) || keyIsDown(left) ? -1 :
704
+ keyIsDown(down) || keyIsDown(right) ? 1 : 0;
705
+ }
706
+ const back = vertical ? up : left;
707
+ const forward = vertical ? down : right;
708
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
709
+ }
710
+
711
+ /** Get other axis navigation direction from gamepad or keyboard
712
+ * @return {number} */
713
+ getNavigationOtherDirection()
714
+ {
715
+ if (uiSystem.navigationDirection === 2)
716
+ return 0; // other direction disabled
717
+
718
+ const vertical = uiSystem.navigationDirection === 1;
719
+ if (isUsingGamepad)
720
+ {
721
+ const stick = gamepadStick(0, gamepadPrimary);
722
+ const dpad = gamepadDpad(gamepadPrimary);
723
+ return !vertical ? (stick.y || dpad.y) : (stick.x || dpad.x);
724
+ }
725
+ // up is positive, as it is on the gamepad
726
+ if (!vertical)
727
+ return keyIsDown('ArrowUp') ? 1 : keyIsDown('ArrowDown') ? -1 : 0;
728
+ return keyIsDown('ArrowLeft') ? -1 : keyIsDown('ArrowRight') ? 1 : 0;
729
+ }
730
+
731
+ /** Get if navigation button was pressed from gamepad or keyboard
732
+ * @return {boolean} */
733
+ getNavigationWasPressed()
734
+ {
735
+ return isUsingGamepad ? gamepadWasPressed(0, gamepadPrimary) :
736
+ keyWasPressed('Space') || keyWasPressed('Enter');
737
+ }
738
+
739
+ /** Show a confirmation dialog with Yes/No buttons
740
+ * Centers the dialog on the screen with darkened background
741
+ * @param {string} [text] - The message to display
742
+ * @param {Function} [yesCallback] - Called when Yes is clicked
743
+ * @param {Function} [noCallback] - Called when No is clicked, or the exit key or gamepad B closes it
744
+ * @param {Vector2} [size] - Size of the confirmation dialog, the title and buttons are placed by it
745
+ * @param {string} [exitKey] - Key that closes the menu as No, gamepad B (button 1) does too
746
+ * @return {UIObject} The confirmation menu object
747
+ */
748
+ showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
749
+ {
750
+ ASSERT(!uiSystem.confirmDialog, 'a confirm dialog is already open, check uiSystem.confirmDialog');
751
+
752
+ const savedNavigationDirection = uiSystem.navigationDirection;
753
+ const savedNavigationObject = uiSystem.navigationObject;
754
+
755
+ // allow both axes for navigation
756
+ uiSystem.navigationDirection = 2;
757
+
758
+ // confirm menu
759
+ const confirmMenu = new UIObject(vec2(), size);
760
+ uiSystem.confirmDialog = confirmMenu;
761
+ confirmMenu.onRender = ()=>
762
+ {
763
+ const backgroundColor = hsl(0,0,0,.7);
764
+ uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
765
+ }
766
+ let opened = false;
767
+ confirmMenu.onUpdate = ()=>
768
+ {
769
+ // not the press that opened it, a game may open it on the same back button, so its first update is skipped
770
+ // (not by frame number, which does not advance while paused, where a confirm is usually shown)
771
+ if (opened && (keyWasPressed(exitKey) || gamepadWasPressed(1)))
772
+ {
773
+ closeMenu(); // the exit key or gamepad B answers no
774
+ noCallback && noCallback();
775
+ }
776
+ opened = true;
777
+ }
778
+ confirmMenu.isMouseOverlapping = ()=> true; // always hover
779
+
780
+ // the title and buttons are laid out for the default 500 by 250 and scaled to the dialog's size, so a
781
+ // small or narrow one still fits them
782
+ const scaleX = size.x/500, scaleY = size.y/250;
783
+ const gap = 50, y = size.y/5;
784
+ const textTitle = new UIText(vec2(0,-y), vec2(size.x-gap, 70*scaleY), text);
785
+ confirmMenu.addChild(textTitle);
786
+
787
+ // yes button
788
+ const buttonSize = vec2(120*scaleX, 70*scaleY);
789
+ const buttonYes = new UIButton(vec2(-80*scaleX,y), buttonSize, 'Yes');
790
+ buttonYes.textHeight = 40*scaleY;
791
+ buttonYes.navigationIndex = 1;
792
+ buttonYes.hoverColor = hsl(0,1,.5);
793
+ buttonYes.onClick = ()=> { closeMenu(); yesCallback && yesCallback(); };
794
+ confirmMenu.addChild(buttonYes);
795
+
796
+ // no button
797
+ const buttonNo = new UIButton(vec2(80*scaleX,y), buttonSize, 'No');
798
+ buttonNo.textHeight = 40*scaleY;
799
+ buttonNo.navigationIndex = 2;
800
+ buttonNo.navigationAutoSelect = true;
801
+ buttonNo.onClick = ()=> { closeMenu(); noCallback && noCallback(); };
802
+ confirmMenu.addChild(buttonNo);
803
+
804
+ // return to normal navigation however the menu goes, by its buttons,
805
+ // destroyObjects or a call to its destroy, with the item that opened it selected again
806
+ const destroy = confirmMenu.destroy.bind(confirmMenu);
807
+ confirmMenu.destroy = ()=>
808
+ {
809
+ const closing = uiSystem.confirmDialog === confirmMenu;
810
+ if (closing)
811
+ {
812
+ uiSystem.confirmDialog = undefined;
813
+ uiSystem.navigationDirection = savedNavigationDirection;
814
+ }
815
+ destroy(); // its buttons go, and the one selected clears the selection
816
+ if (closing && savedNavigationObject && !savedNavigationObject.destroyed)
817
+ uiSystem.navigationObject = savedNavigationObject;
818
+ };
819
+
820
+ // close menu and clear the input that closed it
821
+ function closeMenu()
822
+ {
823
+ ASSERT(uiSystem.confirmDialog === confirmMenu);
824
+ confirmMenu.destroy();
825
+ inputClear();
826
+ }
827
+ return confirmMenu;
828
+ }
829
+ }
830
+
831
+ // whether a UI object can still be used: it and every parent visible, enabled and not destroyed
832
+ function uiObjectIsUsable(o)
833
+ {
834
+ for (; o; o = o.parent)
835
+ if (o.destroyed || !o.visible || o.disabled)
836
+ return false;
837
+ return true;
838
+ }
839
+
840
+ // set the canvas shadow for the next draw, when it has a color and a blur or an offset; the caller clears it
841
+ function uiSetShadow(context, shadowColor, shadowBlur, shadowOffset)
842
+ {
843
+ if (shadowColor.a > 0 && (shadowBlur || shadowOffset.x || shadowOffset.y))
844
+ {
845
+ context.shadowColor = shadowColor.toString();
846
+ context.shadowBlur = shadowBlur;
847
+ context.shadowOffsetX = shadowOffset.x;
848
+ context.shadowOffsetY = shadowOffset.y;
849
+ }
850
+ }
851
+
852
+ // whether an open confirm dialog shuts a UI object out, it is modal: all but the dialog and what is in it
853
+ function uiObjectIsBehindDialog(o)
854
+ {
855
+ const dialog = uiSystem.confirmDialog;
856
+ if (!dialog)
857
+ return false;
858
+ for (; o; o = o.parent)
859
+ if (o === dialog)
860
+ return false;
861
+ return true;
862
+ }
863
+
864
+ // whether a UI object is disabled, itself or through a parent
865
+ function uiObjectIsDisabled(o)
866
+ {
867
+ for (; o; o = o.parent)
868
+ if (o.disabled)
869
+ return true;
870
+ return false;
871
+ }
872
+
873
+ // whether a UI object is hidden, itself or through a parent
874
+ function uiObjectIsHidden(o)
875
+ {
876
+ for (; o; o = o.parent)
877
+ if (!o.visible)
878
+ return true;
879
+ return false;
880
+ }
881
+
882
+ ///////////////////////////////////////////////////////////////////////////////
883
+ /**
884
+ * UI Object - Base level object for all UI elements
885
+ * @memberof UISystem */
886
+ class UIObject
887
+ {
888
+ /** Create a UIObject
889
+ * @param {Vector2} [pos=vec2()]
890
+ * @param {Vector2} [size=vec2()]
891
+ */
892
+ constructor(pos=vec2(), size=vec2())
893
+ {
894
+ ASSERT(uiSystem, 'create the UISystemPlugin before UI objects');
895
+ ASSERT(isVector2(pos), 'ui object pos must be a vec2');
896
+ ASSERT(isVector2(size), 'ui object size must be a vec2');
897
+
898
+ /** @property {Vector2} - Position you set: an offset from this object's
899
+ * anchor point (the parent box, or the canvas for roots). This is the
900
+ * input that controls placement — set this, not nativePos. */
901
+ this.localPos = pos.copy();
902
+ /** @property {Vector2} - Resolved position in native UI space, recomputed
903
+ * every frame from localPos + anchor (and nativeHeight, if set). This is a
904
+ * derived output used for drawing and hit-testing; assigning to it has no
905
+ * effect since it is overwritten each frame. Set localPos instead. */
906
+ this.nativePos = pos.copy();
907
+ /** @property {Vector2} - Screen space size of the object */
908
+ this.size = size.copy();
909
+ /** @property {Color} - Color of the object */
910
+ this.color = uiSystem.defaultColor.copy();
911
+ /** @property {Color|undefined} - Color of the object when active, uses hoverColor if undefined
912
+ * @type {Color|undefined} */
913
+ this.activeColor = undefined;
914
+ /** @property {string|undefined} - Text for this ui object
915
+ * @type {string|undefined} */
916
+ this.text = undefined;
917
+ /** @property {Color} - Color when disabled */
918
+ this.disabledColor = uiSystem.defaultDisabledColor.copy();
919
+ /** @property {boolean} - Is this object disabled? */
920
+ this.disabled = false;
921
+ /** @property {Color} - Color for text */
922
+ this.textColor = uiSystem.defaultTextColor.copy();
923
+ /** @property {Color} - Color used when hovering over the object */
924
+ this.hoverColor = uiSystem.defaultHoverColor.copy();
925
+ /** @property {Color} - Color for line drawing */
926
+ this.lineColor = uiSystem.defaultLineColor.copy();
927
+ /** @property {Color|undefined} - Uses a gradient fill combined with color
928
+ * @type {Color|undefined} */
929
+ this.gradientColor = uiSystem.defaultGradientColor ? uiSystem.defaultGradientColor.copy() : undefined;
930
+ /** @property {number} - Width for line drawing */
931
+ this.lineWidth = uiSystem.defaultLineWidth;
932
+ /** @property {number} - Corner radius for rounded rects */
933
+ this.cornerRadius = uiSystem.defaultCornerRadius;
934
+ /** @property {TileSlice|undefined} - Style to draw with in place of the rectangle, tinted by the color for its
935
+ * state; its art has the frame, so the outline, corner radius and shadow are not drawn
936
+ * @type {TileSlice|undefined} */
937
+ this.slice = uiSystem.defaultSlice;
938
+ /** @property {string} - Font for this object */
939
+ this.font = uiSystem.defaultFont;
940
+ /** @property {string|undefined} - Font style for this object or undefined
941
+ * @type {string|undefined} */
942
+ this.fontStyle = undefined;
943
+ /** @property {number|undefined} - Override for text width
944
+ * @type {number|undefined} */
945
+ this.textWidth = undefined;
946
+ /** @property {number|undefined} - Override for text height
947
+ * @type {number|undefined} */
948
+ this.textHeight = undefined;
949
+ /** @property {number} - Scale text to fit in the object */
950
+ this.textFitScale = uiSystem.defaultTextFitScale;
951
+ /** @property {Vector2|undefined} - How much to offset the text shadow or undefined.
952
+ * UIText draws it in its shadowColor, which is clear by default, so set that too;
953
+ * the blurred shadow then shows as well unless shadowBlur and shadowOffset are zero.
954
+ * The other widgets draw it in black
955
+ * @type {Vector2|undefined} */
956
+ this.textShadow = undefined;
957
+ /** @property {Color} - Color for text line drawing */
958
+ this.textLineColor = uiSystem.defaultLineColor.copy();
959
+ /** @property {number} - Width for text line drawing */
960
+ this.textLineWidth = 0;
961
+ /** @property {boolean} - Should this object be drawn */
962
+ this.visible = true;
963
+ /** @property {Array<UIObject>} - A list of this object's children
964
+ * @type {Array<UIObject>} */
965
+ this.children = [];
966
+ /** @property {UIObject|undefined} - This object's parent, position is in parent space
967
+ * @type {UIObject|undefined} */
968
+ this.parent = undefined;
969
+ /** @property {number} - Added size to make small buttons easier to touch on mobile devices */
970
+ this.extraTouchSize = 0;
971
+ /** @property {Sound|undefined} - Sound when interactive element is pressed
972
+ * @type {Sound|undefined} */
973
+ this.soundPress = uiSystem.defaultSoundPress;
974
+ /** @property {Sound|undefined} - Sound when interactive element is released
975
+ * @type {Sound|undefined} */
976
+ this.soundRelease = uiSystem.defaultSoundRelease;
977
+ /** @property {Sound|undefined} - Sound when interactive element is clicked
978
+ * @type {Sound|undefined} */
979
+ this.soundClick = uiSystem.defaultSoundClick;
980
+ /** @property {boolean} - Is this element interactive */
981
+ this.interactive = false;
982
+ /** @property {boolean} - Activate when dragged over with mouse held down */
983
+ this.dragActivate = false;
984
+ /** @property {boolean} - True if this can be a hover object */
985
+ this.canBeHover = true;
986
+ /** @property {Color} - Color for shadow */
987
+ this.shadowColor = uiSystem.defaultShadowColor?.copy();
988
+ /** @property {number} - Size of shadow blur */
989
+ this.shadowBlur = uiSystem.defaultShadowBlur;
990
+ /** @property {Vector2} - Offset of shadow blur */
991
+ this.shadowOffset = uiSystem.defaultShadowOffset?.copy();
992
+ /** @property {number|undefined} - Optional navigation order index, lower values are selected first
993
+ * @type {number|undefined} */
994
+ this.navigationIndex = undefined;
995
+ /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
996
+ this.navigationAutoSelect = false;
997
+ /** @property {Vector2} - Where on parent (or canvas if no parent) this object is anchored.
998
+ * Components in [-1, 1]: (0,0)=center, (-1,-1)=top-left, (1,1)=bottom-right.
999
+ * Also acts as self-pivot — e.g. (1,-1) puts your top-right corner at the anchor point. */
1000
+ this.anchor = vec2();
1001
+ /** @property {'left'|'center'|'right'} - Horizontal text alignment: left, center, or right
1002
+ * @type {'left'|'center'|'right'} */
1003
+ this.align = 'center';
1004
+ /** @property {boolean} - Has this object been destroyed? */
1005
+ this.destroyed = false;
1006
+
1007
+ uiSystem.uiObjects.push(this);
1008
+ }
1009
+
1010
+ /** Add a child UIObject to this object, returns child for chaining
1011
+ * @param {UIObject} child
1012
+ * @return {UIObject} The child object added */
1013
+ addChild(child)
1014
+ {
1015
+ ASSERT(!child.parent && !this.children.includes(child), 'child already has a parent, removeChild it first');
1016
+ this.children.push(child);
1017
+ child.parent = this;
1018
+ return child;
1019
+ }
1020
+
1021
+ /** Remove a child UIObject from this object
1022
+ * @param {UIObject} child */
1023
+ removeChild(child)
1024
+ {
1025
+ ASSERT(child.parent === this && this.children.includes(child));
1026
+ this.children.splice(this.children.indexOf(child), 1);
1027
+ child.parent = undefined;
1028
+ }
1029
+
1030
+ /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
1031
+ destroy()
1032
+ {
1033
+ if (this.destroyed)
1034
+ return;
1035
+
1036
+ // clear ui-system references that point at this object so events
1037
+ // don't keep firing against a destroyed target (especially the
1038
+ // keydown listener attached for keyInputObject)
1039
+ if (uiSystem.activeObject === this) uiSystem.activeObject = undefined;
1040
+ if (uiSystem.hoverObject === this) uiSystem.hoverObject = undefined;
1041
+ if (uiSystem.lastHoverObject === this) uiSystem.lastHoverObject = undefined;
1042
+ if (uiSystem.navigationObject === this) uiSystem.navigationObject = undefined;
1043
+ if (uiSystem.keyInputObject === this) uiSystem.keyInputObject = undefined;
1044
+
1045
+ // disconnect from parent and destroy children
1046
+ this.destroyed = true;
1047
+ this.parent?.removeChild(this);
1048
+ for (const child of this.children)
1049
+ {
1050
+ child.parent = undefined;
1051
+ child.destroy();
1052
+ }
1053
+ // clear references so destroyed children can be GC'd
1054
+ this.children.length = 0;
1055
+ }
1056
+
1057
+ /** Check if the mouse is overlapping this ui object
1058
+ * @return {boolean} - True if overlapping */
1059
+ isMouseOverlapping()
1060
+ {
1061
+ if (!mouseInWindow) return false;
1062
+
1063
+ const size = !isTouchDevice ? this.size :
1064
+ this.size.add(vec2(this.extraTouchSize || 0));
1065
+ const pos = uiSystem.screenToNative(mousePosScreen);
1066
+ return isOverlapping(this.nativePos, size, pos);
1067
+ }
1068
+
1069
+ /** Update the object, called automatically by plugin once each frame
1070
+ * @return {void} */
1071
+ update()
1072
+ {
1073
+ // call the custom update callback, which may destroy this object
1074
+ this.onUpdate();
1075
+ if (this.destroyed) return;
1076
+
1077
+ // unset active if disabled, itself or through a parent, and let go of it
1078
+ const disabled = uiObjectIsDisabled(this);
1079
+ if (disabled)
1080
+ {
1081
+ if (this === uiSystem.activeObject)
1082
+ {
1083
+ uiSystem.activeObject = undefined;
1084
+ if (this !== uiSystem.keyInputObject) // a field being edited was released when its press ended
1085
+ {
1086
+ this.onRelease();
1087
+ if (this.destroyed) return;
1088
+ }
1089
+ }
1090
+ // a field being edited is ended by the next UI update, through stopEditing, so onChange keeps its text
1091
+ }
1092
+
1093
+ if (uiSystem.keyInputObject)
1094
+ return;
1095
+
1096
+ const wasHover = uiSystem.lastHoverObject === this;
1097
+ const isActive = this.isActiveObject();
1098
+ const mouseDown = mouseIsDown(0);
1099
+ // a press and release in one frame is a press too, as it is for a button that is not drag activated
1100
+ const mousePress = mouseWasPressed(0) || this.dragActivate && mouseDown;
1101
+ if (this.canBeHover)
1102
+ if (!uiSystem.navigationMode) // no mouse hover in navigation mode
1103
+ if (mousePress || isActive || (!mouseDown && !isTouchDevice))
1104
+ if (!uiSystem.hoverObject && this.isMouseOverlapping())
1105
+ uiSystem.hoverObject = this;
1106
+ if (this.isHoverObject())
1107
+ {
1108
+ if (!disabled)
1109
+ {
1110
+ if (mousePress)
1111
+ {
1112
+ if (this.interactive)
1113
+ {
1114
+ // drag activate sees the held mouse as a press every frame, only a new one counts
1115
+ const newPress = !this.dragActivate || !wasHover || mouseWasPressed(0);
1116
+ if (newPress)
1117
+ {
1118
+ this.onPress();
1119
+ if (this.destroyed) // the press took it away, and the press is used up
1120
+ return void inputClearKey(0, 0, false, true, false);
1121
+ this.soundPress && this.soundPress.play();
1122
+ if (uiSystem.activeObject && !isActive)
1123
+ uiSystem.activeObject.onRelease();
1124
+ }
1125
+ if (uiSystem.keyInputObject && uiSystem.keyInputObject !== this)
1126
+ {
1127
+ // its onPress started an edit elsewhere, the updates skip its release while it goes on
1128
+ this.onRelease();
1129
+ this.soundRelease && this.soundRelease.play();
1130
+ }
1131
+ else
1132
+ uiSystem.activeObject = this;
1133
+
1134
+ if (newPress && uiSystem.activateOnPress)
1135
+ this.click(!this.soundPress);
1136
+ if (this.destroyed)
1137
+ return void inputClearKey(0, 0, false, true, false);
1138
+ }
1139
+ }
1140
+ if (!uiSystem.activateOnPress)
1141
+ if (!mouseDown && this.isActiveObject() && this.interactive)
1142
+ this.click();
1143
+ if (this.destroyed) return;
1144
+ }
1145
+
1146
+ // clear mouse was pressed state even when disabled
1147
+ mousePress && inputClearKey(0, 0, false, true, false);
1148
+ }
1149
+ if (isActive)
1150
+ if (!mouseDown || (this.dragActivate && !this.isHoverObject()))
1151
+ {
1152
+ this.onRelease();
1153
+ this.soundRelease && this.soundRelease.play();
1154
+ uiSystem.activeObject = undefined;
1155
+ }
1156
+
1157
+ // call enter/leave events
1158
+ if (this.isHoverObject() !== wasHover)
1159
+ this.isHoverObject() ? this.onEnter() : this.onLeave();
1160
+ }
1161
+
1162
+ /** Render the object, called automatically by plugin once each frame */
1163
+ render()
1164
+ {
1165
+ // call the custom render callback
1166
+ this.onRender();
1167
+
1168
+ if (!this.size.x || !this.size.y) return;
1169
+
1170
+ const isNavigationObject = this.isNavigationObject();
1171
+ const disabled = this.interactive ? uiObjectIsDisabled(this) : this.disabled;
1172
+ const lineColor = isNavigationObject ? this.color :
1173
+ this.interactive && this.isActiveObject() && !disabled ?
1174
+ this.color : this.lineColor;
1175
+ const color = isNavigationObject ? this.hoverColor :
1176
+ disabled ? this.disabledColor :
1177
+ this.interactive ?
1178
+ this.isActiveObject() ? this.activeColor || this.hoverColor :
1179
+ this.isHoverObject() ? this.hoverColor :
1180
+ this.color : this.color;
1181
+ const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
1182
+
1183
+ if (this.slice)
1184
+ uiSystem.drawSlice(this.slice, this.nativePos, this.size, color);
1185
+ else
1186
+ uiSystem.drawRect(this.nativePos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor || CLEAR_BLACK, this.shadowBlur, this.shadowOffset);
1187
+ }
1188
+
1189
+ /** Get the size for text with overrides and scale
1190
+ * @return {Vector2} */
1191
+ getTextSize()
1192
+ {
1193
+ // text fitted to the size shares its height between its lines, a set textHeight is the height of each line
1194
+ const lines = this.textHeight ? 1 : (this.text + '').split('\n').length;
1195
+ return vec2(
1196
+ this.textWidth || this.textFitScale * this.size.x,
1197
+ this.textHeight || this.textFitScale * this.size.y / lines);
1198
+ }
1199
+
1200
+ /** Get where the text is drawn, the center, or the edge of the text area its align puts it against
1201
+ * @param {Vector2} textSize - From getTextSize
1202
+ * @return {Vector2} */
1203
+ getTextPos(textSize)
1204
+ {
1205
+ const side = this.align === 'left' ? -1 : this.align === 'right' ? 1 : 0;
1206
+ return this.nativePos.add(vec2(side * textSize.x / 2, 0));
1207
+ }
1208
+
1209
+ /** Called when the navigation button is pressed on this object */
1210
+ navigatePressed() { this.click(); }
1211
+
1212
+ /** @return {boolean} - Is the mouse hovering over this element */
1213
+ isHoverObject() { return uiSystem.hoverObject === this; }
1214
+
1215
+ /** @return {boolean} - Is the mouse held onto this element */
1216
+ isActiveObject() { return uiSystem.activeObject === this; }
1217
+
1218
+ /** @return {boolean} - Is the gamepad or keyboard navigation object */
1219
+ isNavigationObject() { return uiSystem.navigationObject === this; }
1220
+
1221
+ /** @return {boolean} - Is this object in keyboard input mode */
1222
+ isKeyInputObject() { return uiSystem.keyInputObject === this; }
1223
+
1224
+ /** @return {boolean} - Can it be interacted with, it and every parent visible and enabled */
1225
+ isInteractive() { return this.interactive && uiObjectIsUsable(this); }
1226
+
1227
+ /** Returns string containing info about this object for debugging
1228
+ * @return {string} */
1229
+ toString()
1230
+ {
1231
+ let text = 'type = ' + this.constructor.name;
1232
+ if (this.text)
1233
+ text += '\ntext = ' + this.text;
1234
+ if (this.nativePos.x || this.nativePos.y)
1235
+ text += '\nnativePos = ' + this.nativePos;
1236
+ if (this.localPos.x || this.localPos.y)
1237
+ text += '\nlocalPos = ' + this.localPos;
1238
+ if (this.size.x || this.size.y)
1239
+ text += '\nsize = ' + this.size;
1240
+ if (this.color)
1241
+ text += '\ncolor = ' + this.color;
1242
+ return text;
1243
+ }
1244
+
1245
+ /** Called if uiDebug is enabled
1246
+ * @param {boolean} [visible] */
1247
+ renderDebug(visible=true)
1248
+ {
1249
+ // apply color based on state
1250
+ const color =
1251
+ !visible ? GREEN :
1252
+ this.isHoverObject() ? YELLOW :
1253
+ uiObjectIsDisabled(this) ? PURPLE :
1254
+ this.interactive ? RED : BLUE;
1255
+ uiSystem.drawRect(this.nativePos, this.size, CLEAR_BLACK, 4, color);
1256
+ }
1257
+
1258
+ /** Internal function called when object is clicked
1259
+ * @param {boolean} [playSound] */
1260
+ click(playSound=true)
1261
+ {
1262
+ this.onClick();
1263
+ if (playSound && this.soundClick)
1264
+ this.soundClick.play();
1265
+ }
1266
+
1267
+ /** Called each frame before object updates */
1268
+ onUpdate() {}
1269
+
1270
+ /** Called each frame before object renders */
1271
+ onRender() {}
1272
+
1273
+ /** Called when the mouse enters the object */
1274
+ onEnter() {}
1275
+
1276
+ /** Called when the mouse leaves the object */
1277
+ onLeave() {}
1278
+
1279
+ /** Called when the mouse is pressed while over the object */
1280
+ onPress() {}
1281
+
1282
+ /** Called when a press on it ends, wherever the mouse is let go, or when it is disabled or hidden while held, or
1283
+ * an edit takes the press; onClick is the one for a press let go over it */
1284
+ onRelease() {}
1285
+
1286
+ /** Called when user clicks on this object */
1287
+ onClick() {}
1288
+
1289
+ /** Called when the state of this object changes */
1290
+ onChange() {}
1291
+
1292
+ /** Called with each key while this is the keyInputObject
1293
+ * @param {KeyboardEvent} e */
1294
+ onKeyDown(e) {}
1295
+ };
1296
+
1297
+ ///////////////////////////////////////////////////////////////////////////////
1298
+ /**
1299
+ * UIText - A UI object that displays text
1300
+ * @extends UIObject
1301
+ * @memberof UISystem
1302
+ */
1303
+ class UIText extends UIObject
1304
+ {
1305
+ /** Create a UIText object
1306
+ * @param {Vector2} [pos]
1307
+ * @param {Vector2} [size]
1308
+ * @param {string} [text]
1309
+ * @param {'left'|'center'|'right'} [align]
1310
+ * @param {string} [font=uiSystem.defaultFont]
1311
+ */
1312
+ constructor(pos, size, text='', align='center', font=uiSystem.defaultFont)
1313
+ {
1314
+ super(pos, size);
1315
+
1316
+ ASSERT(isStringLike(text), 'ui text must be a string');
1317
+ ASSERT(['left','center','right'].includes(align), 'ui text align must be left, center, or right');
1318
+ ASSERT(isStringLike(font), 'ui text font must be a string');
1319
+
1320
+ // set properties
1321
+ this.text = text;
1322
+ this.align = align;
1323
+ this.font = font;
1324
+
1325
+ // text can not be a hover object by default
1326
+ this.canBeHover = false;
1327
+
1328
+ // no background by default
1329
+ this.color = CLEAR_BLACK.copy();
1330
+ this.shadowColor = CLEAR_BLACK.copy();
1331
+ /** @type {Color|undefined} */
1332
+ this.gradientColor = undefined;
1333
+ this.lineWidth = 0;
1334
+
1335
+ // use max fit scale by default
1336
+ this.textFitScale = 1;
1337
+ }
1338
+ render()
1339
+ {
1340
+ super.render();
1341
+
1342
+ // render the text
1343
+ const textSize = this.getTextSize();
1344
+ uiSystem.drawText(this.text, this.getTextPos(textSize), textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor || CLEAR_BLACK, this.shadowBlur, this.shadowOffset);
1345
+ }
1346
+ }
1347
+
1348
+ ///////////////////////////////////////////////////////////////////////////////
1349
+ /**
1350
+ * UITextInput - An editable text input field
1351
+ * - A simple text entry field that supports basic editing
1352
+ * - Suitable for short text input like names or numbers
1353
+ * - Reads a physical keyboard: no on-screen keyboard opens on touch devices, and IME composition and paste do not type,
1354
+ * use an HTML input element for those
1355
+ * @extends UIObject
1356
+ * @memberof UISystem
1357
+ */
1358
+ class UITextInput extends UIObject
1359
+ {
1360
+ /** Create a UITextInput object
1361
+ * @param {Vector2} [pos]
1362
+ * @param {Vector2} [size]
1363
+ * @param {string} [text]
1364
+ */
1365
+ constructor(pos, size, text='')
1366
+ {
1367
+ super(pos, size);
1368
+
1369
+ ASSERT(isStringLike(text), 'ui text must be a string');
1370
+
1371
+ /** @property {number} - Max length of input (0 = no limit) */
1372
+ this.maxLength = 0;
1373
+
1374
+ // set properties, as a string, which typing adds to
1375
+ this.text = text + '';
1376
+ this.interactive = true;
1377
+ this.canBeHover = true;
1378
+ }
1379
+
1380
+ /** Start editing the text, called when it is clicked
1381
+ * @param {boolean} [playSound] */
1382
+ click(playSound=true)
1383
+ {
1384
+ // an edit in another field ends first, with its onChange, like a Tab to the next field
1385
+ const editing = uiSystem.keyInputObject;
1386
+ if (editing !== this && editing instanceof UITextInput)
1387
+ editing.stopEditing();
1388
+
1389
+ // start editing the text, the gamepad press that started it is used up so it does not stop it too
1390
+ uiSystem.keyInputObject = this;
1391
+ inputClearKey(0, gamepadPrimary+1, false, true, false);
1392
+ this.onClick();
1393
+ playSound && this.soundClick && this.soundClick.play();
1394
+ }
1395
+
1396
+ /** Stop editing the text */
1397
+ stopEditing()
1398
+ {
1399
+ if (!this.isKeyInputObject())
1400
+ return;
1401
+
1402
+ if (this.soundRelease)
1403
+ this.soundRelease.play();
1404
+ uiSystem.activeObject = undefined;
1405
+ uiSystem.keyInputObject = undefined;
1406
+ this.onChange();
1407
+ }
1408
+
1409
+ /** Key down event handler if this object is being edited
1410
+ * @param {KeyboardEvent} e */
1411
+ onKeyDown(e)
1412
+ {
1413
+ // named keys by key, so numpad Enter works as Enter
1414
+ const code = e.code, key = e.key;
1415
+ if (e.repeat && (key === 'Enter' || code === 'Space'))
1416
+ return; // a key held when editing began repeats, it should not type or stop editing
1417
+ this.text += ''; // a game may have set a number
1418
+ if (key === 'Backspace')
1419
+ this.text = [...this.text].slice(0, -1).join(''); // a whole character, an emoji is two code units
1420
+ else if (key === 'Enter' || key === 'Escape')
1421
+ this.stopEditing();
1422
+ else if (key.length === 1) // printable characters
1423
+ {
1424
+ // ctrl and cmd shortcuts do not type, but AltGr reports ctrl and alt and types characters like @
1425
+ if ((e.ctrlKey || e.metaKey) && !e.getModifierState?.('AltGraph'))
1426
+ return;
1427
+ if (!this.maxLength || this.text.length < this.maxLength)
1428
+ this.text += key;
1429
+ }
1430
+ }
1431
+
1432
+ update()
1433
+ {
1434
+ super.update();
1435
+
1436
+ if (!this.isKeyInputObject())
1437
+ return;
1438
+
1439
+ // the gamepad's press stops editing and is used up, a click off the field is handled by the UI update
1440
+ if (gamepadWasPressed(0, gamepadPrimary))
1441
+ {
1442
+ this.stopEditing();
1443
+ inputClearKey(0, gamepadPrimary+1, false, true, false);
1444
+ }
1445
+ else if (mouseWasPressed(0))
1446
+ inputClearKey(0, 0, false, true, false); // a click inside is used up like any click on the UI
1447
+ }
1448
+
1449
+ render()
1450
+ {
1451
+ super.render();
1452
+
1453
+ // draw the text scaled to fit
1454
+ const textSize = this.getTextSize();
1455
+ let text = this.text;
1456
+ if (this.isKeyInputObject()) // add a cursor to end of text
1457
+ text += timeReal%1 < .5 ? '█' : '░';
1458
+ uiSystem.drawText(text, this.getTextPos(textSize), textSize,
1459
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
1460
+ }
1461
+ }
1462
+
1463
+ ///////////////////////////////////////////////////////////////////////////////
1464
+ /**
1465
+ * UITile - A UI object that displays a tile image
1466
+ * @extends UIObject
1467
+ * @memberof UISystem
1468
+ */
1469
+ class UITile extends UIObject
1470
+ {
1471
+ /** Create a UITile object
1472
+ * @param {Vector2} pos
1473
+ * @param {Vector2} size
1474
+ * @param {TileInfo} tileInfo
1475
+ * @param {Color} [color=WHITE]
1476
+ * @param {number} [angle]
1477
+ * @param {boolean} [mirror]
1478
+ */
1479
+ constructor(pos, size, tileInfo, color=WHITE, angle=0, mirror=false)
1480
+ {
1481
+ super(pos, size);
1482
+
1483
+ ASSERT(tileInfo instanceof TileInfo, 'ui tile tileInfo must be a TileInfo');
1484
+ ASSERT(isColor(color), 'ui tile color must be a color');
1485
+ ASSERT(isNumber(angle), 'ui tile angle must be a number');
1486
+
1487
+ /** @property {TileInfo} - Tile image to use */
1488
+ this.tileInfo = tileInfo;
1489
+ /** @property {number} - Angle to rotate in radians */
1490
+ this.angle = angle;
1491
+ /** @property {boolean} - Should it be mirrored? */
1492
+ this.mirror = mirror;
1493
+ // set properties
1494
+ this.color = color.copy();
1495
+
1496
+ // no shadow by default
1497
+ this.shadowColor = CLEAR_BLACK.copy();
1498
+ }
1499
+ render()
1500
+ {
1501
+ // call the custom render callback
1502
+ this.onRender();
1503
+
1504
+ uiSystem.drawTile(this.nativePos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor || CLEAR_BLACK, this.shadowBlur, this.shadowOffset);
1505
+ }
1506
+ }
1507
+
1508
+ ///////////////////////////////////////////////////////////////////////////////
1509
+ /**
1510
+ * UIButton - A UI object that acts as a button
1511
+ * @extends UIObject
1512
+ * @memberof UISystem
1513
+ */
1514
+ class UIButton extends UIObject
1515
+ {
1516
+ /** Create a UIButton object
1517
+ * @param {Vector2} [pos]
1518
+ * @param {Vector2} [size]
1519
+ * @param {string} [text]
1520
+ * @param {Color} [color=uiSystem.defaultButtonColor]
1521
+ */
1522
+ constructor(pos, size, text='', color=uiSystem.defaultButtonColor)
1523
+ {
1524
+ super(pos, size);
1525
+
1526
+ ASSERT(isStringLike(text), 'ui button must be a string');
1527
+ ASSERT(isColor(color), 'ui button color must be a color');
1528
+
1529
+ /** @property {Vector2} - Text offset for the button */
1530
+ this.textOffset = vec2();
1531
+
1532
+ // set properties
1533
+ this.text = text;
1534
+ this.color = color.copy();
1535
+ this.interactive = true;
1536
+ }
1537
+ render()
1538
+ {
1539
+ super.render();
1540
+
1541
+ // draw the text scaled to fit
1542
+ const textSize = this.getTextSize();
1543
+ uiSystem.drawText(this.text, this.getTextPos(textSize).add(this.textOffset), textSize,
1544
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
1545
+ }
1546
+ }
1547
+
1548
+ ///////////////////////////////////////////////////////////////////////////////
1549
+ /**
1550
+ * UICheckbox - A UI object that acts as a checkbox
1551
+ * @extends UIObject
1552
+ * @memberof UISystem
1553
+ */
1554
+ class UICheckbox extends UIObject
1555
+ {
1556
+ /** Create a UICheckbox object
1557
+ * @param {Vector2} [pos]
1558
+ * @param {Vector2} [size]
1559
+ * @param {boolean} [checked]
1560
+ * @param {string} [text]
1561
+ * @param {Color} [color=uiSystem.defaultButtonColor]
1562
+ */
1563
+ constructor(pos, size, checked=false, text='', color=uiSystem.defaultButtonColor)
1564
+ {
1565
+ super(pos, size);
1566
+
1567
+ ASSERT(isStringLike(text), 'ui checkbox must be a string');
1568
+ ASSERT(isColor(color), 'ui checkbox color must be a color');
1569
+
1570
+ /** @property {boolean} - Is the checkbox currently checked? */
1571
+ this.checked = checked;
1572
+ // set properties
1573
+ this.text = text;
1574
+ this.color = color.copy();
1575
+ this.interactive = true;
1576
+ }
1577
+ /** Toggle the checkbox, called when it is clicked
1578
+ * @param {boolean} [playSound] */
1579
+ click(playSound=true)
1580
+ {
1581
+ this.checked = !this.checked;
1582
+ super.click(playSound); // the click callback and sound, as every UI object has
1583
+ this.onChange();
1584
+ }
1585
+ render()
1586
+ {
1587
+ super.render();
1588
+ if (this.checked)
1589
+ {
1590
+ const p = this.cornerRadius / min(this.size.x, this.size.y) * 2;
1591
+ const length = lerp(1, 2**.5/2, p) / 2;
1592
+ let s = this.size.scale(length);
1593
+ uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1))), this.nativePos.add(s.multiply(vec2(1))), this.lineWidth, this.lineColor);
1594
+ uiSystem.drawLine(this.nativePos.add(s.multiply(vec2(-1,1))), this.nativePos.add(s.multiply(vec2(1,-1))), this.lineWidth, this.lineColor);
1595
+ }
1596
+
1597
+ // draw the text next to the checkbox
1598
+ const textSize = this.getTextSize();
1599
+ const pos = this.nativePos.add(vec2(this.size.x,0));
1600
+ uiSystem.drawText(this.text, pos, textSize,
1601
+ this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
1602
+ }
1603
+ }
1604
+
1605
+ ///////////////////////////////////////////////////////////////////////////////
1606
+ /**
1607
+ * UISlider - A UI object that acts as a slider or scrollbar
1608
+ * @extends UIObject
1609
+ * @memberof UISystem
1610
+ */
1611
+ class UISlider extends UIObject
1612
+ {
1613
+ /** Create a UISlider object
1614
+ * @param {Vector2} [pos]
1615
+ * @param {Vector2} [size]
1616
+ * @param {number} [value]
1617
+ * @param {string} [text]
1618
+ * @param {Color} [color=uiSystem.defaultButtonColor]
1619
+ * @param {Color} [handleColor=WHITE]
1620
+ */
1621
+ constructor(pos, size, value=.5, text='', color=uiSystem.defaultButtonColor, handleColor=WHITE)
1622
+ {
1623
+ super(pos, size);
1624
+
1625
+ ASSERT(isNumber(value), 'ui slider value must be a number');
1626
+ ASSERT(isStringLike(text), 'ui slider must be a string');
1627
+ ASSERT(isColor(color), 'ui slider color must be a color');
1628
+ ASSERT(isColor(handleColor), 'ui slider handleColor must be a color');
1629
+
1630
+ /** @property {number} - Current percentage value of this slider 0-1 */
1631
+ this.value = value;
1632
+ /** @property {Color} - Color for the handle part of the slider */
1633
+ this.handleColor = handleColor.copy();
1634
+ /** @property {TileSlice|undefined} - Style to draw the handle, or the fill, with; undefined for the slider's own
1635
+ * slice, or a rectangle when it has none
1636
+ * @type {TileSlice|undefined} */
1637
+ this.handleSlice = uiSystem.defaultHandleSlice;
1638
+ /** @property {boolean} - Should it fill up like a progress bar? */
1639
+ this.fillMode = false;
1640
+
1641
+ // set properties
1642
+ this.text = text;
1643
+ this.color = color.copy();
1644
+ this.interactive = true;
1645
+ }
1646
+ update()
1647
+ {
1648
+ super.update();
1649
+ if (!this.interactive)
1650
+ return;
1651
+
1652
+ const oldValue = this.value;
1653
+ if (this.isActiveObject())
1654
+ {
1655
+ // handle horizontal or vertical slider
1656
+ const isHorizontal = this.size.x > this.size.y;
1657
+ const handleSize = isHorizontal ? this.size.y : this.size.x;
1658
+ const barSize = isHorizontal ? this.size.x : this.size.y;
1659
+ const centerPos = isHorizontal ? this.nativePos.x : this.nativePos.y;
1660
+
1661
+ // check if value changed
1662
+ const handleWidth = barSize - handleSize;
1663
+ const p1 = centerPos - handleWidth/2;
1664
+ const p2 = centerPos + handleWidth/2;
1665
+ const p = uiSystem.screenToNative(mousePosScreen);
1666
+ this.value = isHorizontal ?
1667
+ percent(p.x, p1, p2) :
1668
+ percent(p.y, p2, p1);
1669
+ }
1670
+ else if (this.isNavigationObject())
1671
+ {
1672
+ // gamepad/keyboard navigation adjustment
1673
+ const direction = uiSystem.getNavigationOtherDirection();
1674
+ if (!uiSystem.navigationTimer.active())
1675
+ this.value = clamp(this.value + direction*.01);
1676
+ }
1677
+ this.value === oldValue || this.onChange();
1678
+ }
1679
+
1680
+ /** Draw the handle, or the fill of a fill mode slider, with the handle slice, the slider's own, or a rectangle
1681
+ * @param {Vector2} pos
1682
+ * @param {Vector2} size
1683
+ * @param {Color} color */
1684
+ drawHandle(pos, size, color)
1685
+ {
1686
+ const slice = this.handleSlice || this.slice;
1687
+ if (slice)
1688
+ uiSystem.drawSlice(slice, pos, size, color);
1689
+ else
1690
+ uiSystem.drawRect(pos, size, color, this.lineWidth, this.lineColor, this.cornerRadius, this.gradientColor);
1691
+ }
1692
+ render()
1693
+ {
1694
+ super.render();
1695
+
1696
+ // handle horizontal or vertical slider
1697
+ const isHorizontal = this.size.x > this.size.y;
1698
+ const barWidth = isHorizontal ? this.size.x : this.size.y;
1699
+ const handleWidth = isHorizontal ? this.size.y : this.size.x;
1700
+ const color = uiObjectIsDisabled(this) ? this.disabledColor : this.handleColor;
1701
+ if (this.fillMode)
1702
+ {
1703
+ // draw progress bar
1704
+ const minWidth = min(handleWidth, this.cornerRadius * 2);
1705
+ const progressWidth = lerp(minWidth, barWidth, this.value);
1706
+ const p = (progressWidth - barWidth) * (isHorizontal ? .5 : -.5);
1707
+ const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
1708
+ const drawSize = isHorizontal ?
1709
+ vec2(progressWidth, this.size.y) : vec2(this.size.x, progressWidth);
1710
+ this.drawHandle(pos, drawSize, color);
1711
+ }
1712
+ else
1713
+ {
1714
+ // draw the slider handle
1715
+ const value = clamp(isHorizontal ? this.value : 1 - this.value);
1716
+ const p = (barWidth - handleWidth) * (value - .5);
1717
+ const pos = this.nativePos.add(isHorizontal ? vec2(p, 0) : vec2(0, p));
1718
+ const drawSize = vec2(handleWidth);
1719
+ this.drawHandle(pos, drawSize, color);
1720
+ }
1721
+
1722
+ // draw the text scaled to fit on the slider
1723
+ const textSize = this.getTextSize();
1724
+ uiSystem.drawText(this.text, this.getTextPos(textSize), textSize,
1725
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
1726
+ }
1727
+ navigatePressed()
1728
+ {
1729
+ // toggle value between 0 and 1
1730
+ this.value = this.value ? 0 : 1;
1731
+ this.onChange();
1732
+ this.onRelease();
1733
+ super.navigatePressed();
1734
+ }
1735
+ }
1736
+
1737
+ ///////////////////////////////////////////////////////////////////////////////
1738
+ /**
1739
+ * UIVideo - A UI object that plays video
1740
+ * @extends UIObject
1741
+ * @example
1742
+ * // Create a video player UI object
1743
+ * const video = new UIVideo(vec2(400, 300), vec2(320, 240), 'video.mp4', true);
1744
+ * video.play();
1745
+ * @memberof UISystem
1746
+ */
1747
+ class UIVideo extends UIObject
1748
+ {
1749
+ /** Create a video player UI object
1750
+ * @param {Vector2} pos
1751
+ * @param {Vector2} size
1752
+ * @param {string} src - Video file path or URL
1753
+ * @param {boolean} [autoplay=false] - Start playing immediately?
1754
+ * @param {boolean} [loop=false] - Loop the video?
1755
+ * @param {number} [volume=1] - Volume percent scaled by global volume (0-1)
1756
+ */
1757
+ constructor(pos, size, src, autoplay=false, loop=false, volume=1)
1758
+ {
1759
+ super(pos, size || vec2());
1760
+
1761
+ ASSERT(isStringLike(src), 'video src must be a string');
1762
+ ASSERT(isNumber(volume), 'video volume must be a number');
1763
+
1764
+ this.color = BLACK.copy(); // default to black background
1765
+ this.cornerRadius = 0; // default to no corner radius
1766
+
1767
+ /** @property {number} - The video volume */
1768
+ this.volume = volume;
1769
+
1770
+ // create video element
1771
+ /** @property {HTMLVideoElement} - The video player */
1772
+ this.video = document.createElement('video');
1773
+ this.video.loop = loop;
1774
+ this.video.volume = clamp(volume * soundVolume);
1775
+ this.video.muted = !soundEnable;
1776
+ /** @private */
1777
+ this._soundEnabled = soundEnable; // the sound setting the mute last followed
1778
+ this.video.style.display = 'none';
1779
+ this.video.playsInline = true; // an iPhone would play it fullscreen and block its muted autoplay
1780
+ this.video.src = src;
1781
+ document.body.appendChild(this.video);
1782
+ autoplay && this.play();
1783
+ }
1784
+
1785
+ /** Play or resume the video
1786
+ * @return {Promise<boolean>} Resolves true once playback starts, false if the browser refused it */
1787
+ async play()
1788
+ {
1789
+ // try to play the video, catch any errors (autoplay may be blocked)
1790
+ try { await this.video.play(); return true; }
1791
+ catch(e) { return false; }
1792
+ }
1793
+
1794
+ /** Pause the video */
1795
+ pause() { this.video.pause(); }
1796
+
1797
+ /** Stop and reset the video */
1798
+ stop() { this.video.pause(); this.video.currentTime = 0; }
1799
+
1800
+ /** Check if video is currently loading
1801
+ * @return {boolean} */
1802
+ isLoading()
1803
+ { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
1804
+
1805
+ /** Check if video is currently paused
1806
+ * @return {boolean} */
1807
+ isPaused() { return this.video.paused; }
1808
+
1809
+ /** Check if video is currently playing
1810
+ * @return {boolean} */
1811
+ isPlaying()
1812
+ { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); }
1813
+
1814
+ /** Check if video has ended playing
1815
+ * @return {boolean} */
1816
+ hasEnded() { return this.video.ended; }
1817
+
1818
+ /** Set volume (0-1)
1819
+ * @param {number} volume - Volume level (0-1) */
1820
+ setVolume(volume)
1821
+ {
1822
+ this.volume = volume;
1823
+ this.video.volume = clamp(volume * soundVolume);
1824
+ }
1825
+
1826
+ /** Set playback speed
1827
+ * @param {number} rate - Playback rate multiplier */
1828
+ setPlaybackRate(rate) { this.video.playbackRate = rate; }
1829
+
1830
+ /** Get current time in seconds
1831
+ * @return {number} Current playback time */
1832
+ getCurrentTime() { return this.video.currentTime || 0; }
1833
+
1834
+ /** Get duration in seconds
1835
+ * @return {number} Total video duration */
1836
+ getDuration() { return this.video.duration || 0; }
1837
+
1838
+ /** Get the native video dimensions
1839
+ * @return {Vector2} Video dimensions (may be 0,0 if metadata not loaded) */
1840
+ getVideoSize()
1841
+ { return vec2(this.video.videoWidth, this.video.videoHeight); }
1842
+
1843
+ /** Seek to time in seconds
1844
+ * @param {number} time - Time in seconds to seek to */
1845
+ setTime(time)
1846
+ {
1847
+ // before the metadata loads there is no duration to clamp to, and the time set is where it starts
1848
+ const duration = this.video.duration;
1849
+ this.video.currentTime = duration ? clamp(time, 0, duration) : max(time, 0);
1850
+ }
1851
+
1852
+ update()
1853
+ {
1854
+ super.update();
1855
+
1856
+ // update volume based on global sound volume, and the mute when sound is turned on or off,
1857
+ // so a game's own mute stays until then
1858
+ this.video.volume = clamp(this.volume * soundVolume);
1859
+ if (this._soundEnabled !== soundEnable)
1860
+ this.video.muted = !(this._soundEnabled = soundEnable);
1861
+ }
1862
+
1863
+ /** Render video to UI canvas */
1864
+ render()
1865
+ {
1866
+ super.render();
1867
+
1868
+ if (this.isLoading())
1869
+ return;
1870
+ const context = uiSystem.uiContext;
1871
+ const s = this.size;
1872
+ context.save();
1873
+ context.translate(this.nativePos.x, this.nativePos.y);
1874
+ context.drawImage(this.video, -s.x/2, -s.y/2, s.x, s.y);
1875
+ context.restore();
1876
+ }
1877
+
1878
+ /** Clean up video on destroy */
1879
+ destroy()
1880
+ {
1881
+ if (this.destroyed)
1882
+ return;
1883
+
1884
+ this.video.pause();
1885
+ this.video.remove();
1886
+ super.destroy();
1887
+ }
1888
+ }
1889
+
1890
+ ///////////////////////////////////////////////////////////////////////////////
1891
+ /**
1892
+ * UILayout - A container that auto-arranges children in a vertical list, horizontal list, or grid
1893
+ * - Set columns to 1 for a vertical list (default)
1894
+ * - Set columns to children.length for a horizontal list
1895
+ * - Set columns to N (1 < N < children.length) for a grid with N columns
1896
+ * - Per-child sizing: each row's height = max child.size.y in that row, each column's width = max child.size.x in that column
1897
+ * - Children are positioned centered in their cell
1898
+ * - Container auto-sizes to fit children plus padding
1899
+ * - Hidden children take no cell, call relayout after showing or hiding one
1900
+ * @extends UIObject
1901
+ * @memberof UISystem
1902
+ */
1903
+ class UILayout extends UIObject
1904
+ {
1905
+ /** Create a UILayout container that auto-arranges children
1906
+ * @param {Vector2} [pos]
1907
+ * @param {number} [columns=1] - Number of columns (1 = vertical list)
1908
+ * @param {number} [gap=10] - Space between children
1909
+ * @param {number} [padding=10] - Space between container border and children
1910
+ * @param {boolean} [transparent=false] - If true, draws no background, outline, or shadow
1911
+ */
1912
+ constructor(pos, columns=1, gap=10, padding=10, transparent=false)
1913
+ {
1914
+ super(pos);
1915
+
1916
+ ASSERT(isNumber(columns) && columns >= 1, 'ui layout columns must be a number >= 1');
1917
+ ASSERT(isNumber(gap), 'ui layout gap must be a number');
1918
+ ASSERT(isNumber(padding), 'ui layout padding must be a number');
1919
+
1920
+ /** @property {number} - Number of columns in the layout */
1921
+ this.columns = columns;
1922
+ /** @property {number} - Space between children */
1923
+ this.gap = gap;
1924
+ /** @property {number} - Space between container border and children */
1925
+ this.padding = padding;
1926
+
1927
+ if (transparent)
1928
+ {
1929
+ // pure positioning helper - skip background, outline, and shadow,
1930
+ // and leave clicks in its gaps and padding to the game
1931
+ this.color = CLEAR_BLACK.copy();
1932
+ this.gradientColor = undefined;
1933
+ this.lineWidth = 0;
1934
+ this.shadowColor = CLEAR_BLACK.copy();
1935
+ this.canBeHover = false;
1936
+ }
1937
+ this.relayout();
1938
+ }
1939
+
1940
+ /** Add a child UIObject and re-layout
1941
+ * @param {UIObject} child
1942
+ * @return {UIObject} The child object added */
1943
+ addChild(child)
1944
+ {
1945
+ super.addChild(child);
1946
+ this.relayout();
1947
+ return child;
1948
+ }
1949
+
1950
+ /** Remove a child UIObject and re-layout
1951
+ * @param {UIObject} child */
1952
+ removeChild(child)
1953
+ {
1954
+ super.removeChild(child);
1955
+ this.relayout();
1956
+ }
1957
+
1958
+ /** Recompute child positions and container size based on per-child sizes.
1959
+ * Called automatically by addChild and removeChild. Call manually if you
1960
+ * mutate a child's size, show or hide one, or change columns, gap, or padding. */
1961
+ relayout()
1962
+ {
1963
+ // a hidden child leaves no gap, like a Continue button hidden when there is nothing to continue
1964
+ const children = this.children.filter(child=> child.visible);
1965
+ const n = children.length;
1966
+ if (!n)
1967
+ {
1968
+ this.size = vec2(this.padding * 2);
1969
+ this.parent instanceof UILayout && this.parent.relayout(); // a layout it is in takes its new size
1970
+ return;
1971
+ }
1972
+
1973
+ // whole columns, at least one, and no empty ones, they would push it off center
1974
+ const cols = clamp(floor(this.columns), 1, n);
1975
+ const rows = ceil(n / cols);
1976
+ const colWidths = new Array(cols).fill(0);
1977
+ const rowHeights = new Array(rows).fill(0);
1978
+
1979
+ // first pass: compute column widths and row heights from child sizes
1980
+ for (let i = 0; i < n; ++i)
1981
+ {
1982
+ const col = i % cols;
1983
+ const row = floor(i / cols);
1984
+ const child = children[i];
1985
+ colWidths[col] = max(colWidths[col], child.size.x);
1986
+ rowHeights[row] = max(rowHeights[row], child.size.y);
1987
+ }
1988
+
1989
+ // total content size (sum of column widths/row heights plus gaps between them)
1990
+ let contentWidth = this.gap * (cols - 1);
1991
+ for (const w of colWidths) contentWidth += w;
1992
+ let contentHeight = this.gap * (rows - 1);
1993
+ for (const h of rowHeights) contentHeight += h;
1994
+
1995
+ // cumulative column/row offsets so positioning is O(n) not O(n^2)
1996
+ const colOffsets = new Array(cols);
1997
+ let xAcc = 0;
1998
+ for (let c = 0; c < cols; ++c)
1999
+ {
2000
+ colOffsets[c] = xAcc;
2001
+ xAcc += colWidths[c];
2002
+ }
2003
+ const rowOffsets = new Array(rows);
2004
+ let yAcc = 0;
2005
+ for (let r = 0; r < rows; ++r)
2006
+ {
2007
+ rowOffsets[r] = yAcc;
2008
+ yAcc += rowHeights[r];
2009
+ }
2010
+
2011
+ // second pass: position each child centered in its cell
2012
+ for (let i = 0; i < n; ++i)
2013
+ {
2014
+ const col = i % cols;
2015
+ const row = floor(i / cols);
2016
+ const x = -contentWidth/2 + colOffsets[col] + this.gap * col + colWidths[col] / 2;
2017
+ const y = -contentHeight/2 + rowOffsets[row] + this.gap * row + rowHeights[row] / 2;
2018
+ children[i].localPos = vec2(x, y);
2019
+ }
2020
+
2021
+ // container size = content + padding on all sides
2022
+ this.size = vec2(contentWidth + this.padding * 2, contentHeight + this.padding * 2);
2023
+ this.parent instanceof UILayout && this.parent.relayout(); // a layout it is in takes its new size
2024
+ }
1688
2025
  }