littlejsengine 1.15.3 → 1.15.9

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.
@@ -33,7 +33,7 @@ const engineName = 'LittleJS';
33
33
  * @type {string}
34
34
  * @default
35
35
  * @memberof Engine */
36
- const engineVersion = '1.15.3';
36
+ const engineVersion = '1.15.9';
37
37
 
38
38
  /** Frames per second to update
39
39
  * @type {number}
@@ -279,7 +279,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
279
279
  o.destroyed || o.render();
280
280
  gameRenderPost();
281
281
  pluginList.forEach(plugin=>plugin.render?.());
282
- touchGamepadRender();
282
+ inputRender();
283
283
  debugRender();
284
284
  glFlush();
285
285
  debugVideoCaptureUpdate();
@@ -308,23 +308,48 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
308
308
 
309
309
  if (canvasFixedSize.x)
310
310
  {
311
- // clear canvas and set fixed size
312
- mainCanvas.width = canvasFixedSize.x;
313
- mainCanvas.height = canvasFixedSize.y;
314
-
315
- // fit to window by adding space on top or bottom if necessary
316
- const aspect = innerWidth / innerHeight;
317
- const fixedAspect = mainCanvas.width / mainCanvas.height;
318
- (glCanvas||mainCanvas).style.width = mainCanvas.style.width = overlayCanvas.style.width = aspect < fixedAspect ? '100%' : '';
319
- (glCanvas||mainCanvas).style.height = mainCanvas.style.height = overlayCanvas.style.height = aspect < fixedAspect ? '' : '100%';
311
+ // set canvas fixed size
312
+ mainCanvasSize = canvasFixedSize.copy();
313
+
314
+ // fit to window using css width and height
315
+ const innerAspect = innerWidth / innerHeight;
316
+ const fixedAspect = canvasFixedSize.x / canvasFixedSize.y;
317
+ const w = innerAspect < fixedAspect ? '100%' : '';
318
+ const h = innerAspect < fixedAspect ? '' : '100%';
319
+ overlayCanvas.style.width = mainCanvas.style.width = w;
320
+ overlayCanvas.style.height = mainCanvas.style.height = h;
321
+ if (glCanvas)
322
+ {
323
+ glCanvas.style.width = w;
324
+ glCanvas.style.height = h;
325
+ }
320
326
  }
321
327
  else
322
328
  {
323
- // clear canvas and set size to same as window
324
- mainCanvas.width = min(innerWidth, canvasMaxSize.x);
325
- mainCanvas.height = min(innerHeight, canvasMaxSize.y);
329
+ // get main canvas size based on window size
330
+ mainCanvasSize.x = min(innerWidth, canvasMaxSize.x);
331
+ mainCanvasSize.y = min(innerHeight, canvasMaxSize.y);
332
+
333
+ // responsive aspect ratio with native resolution
334
+ const innerAspect = innerWidth / innerHeight;
335
+ if (canvasMaxAspect && innerAspect > canvasMaxAspect)
336
+ {
337
+ // full height
338
+ const w = mainCanvasSize.y * canvasMaxAspect | 0;
339
+ mainCanvasSize.x = min(w, canvasMaxSize.x);
340
+ }
341
+ else if (innerAspect < canvasMinAspect)
342
+ {
343
+ // full width
344
+ const h = mainCanvasSize.x / canvasMinAspect | 0;
345
+ mainCanvasSize.y = min(h, canvasMaxSize.y);
346
+ }
326
347
  }
327
348
 
349
+ // clear main and overlay canvas and set size
350
+ overlayCanvas.width = mainCanvas.width = mainCanvasSize.x;
351
+ overlayCanvas.height = mainCanvas.height = mainCanvasSize.y;
352
+
328
353
  // apply the clear color to main canvas
329
354
  if (canvasClearColor.a > 0)
330
355
  {
@@ -333,13 +358,6 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
333
358
  mainContext.fillStyle = BLACK.toString();
334
359
  }
335
360
 
336
- // clear overlay canvas and set size
337
- overlayCanvas.width = mainCanvas.width;
338
- overlayCanvas.height = mainCanvas.height;
339
-
340
- // save canvas size
341
- mainCanvasSize = vec2(mainCanvas.width, mainCanvas.height);
342
-
343
361
  // set default line join and cap
344
362
  const lineJoin = 'round', lineCap = 'round';
345
363
  mainContext.lineJoin = overlayContext.lineJoin = lineJoin;
@@ -363,7 +381,7 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
363
381
  'user-select:none;' + // prevent hold to select
364
382
  '-webkit-user-select:none;' + // compatibility for ios
365
383
  'touch-action:none;' + // prevent mobile pinch to resize
366
- '-webkit-touch-callout:none';// compatibility for ios
384
+ '-webkit-touch-callout:none'; // compatibility for ios
367
385
  rootElement.style.cssText = styleRoot;
368
386
  drawCanvas = mainCanvas = document.createElement('canvas');
369
387
  rootElement.appendChild(mainCanvas);
@@ -380,7 +398,8 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
380
398
  rootElement.appendChild(overlayCanvas);
381
399
  overlayContext = overlayCanvas.getContext('2d');
382
400
 
383
- // set canvases
401
+ // setup canvases
402
+ // transform way is still more reliable then flexbox or grid
384
403
  const styleCanvas = 'position:absolute;'+ // allow canvases to overlap
385
404
  'top:50%;left:50%;transform:translate(-50%,-50%)'; // center on screen
386
405
  mainCanvas.style.cssText = overlayCanvas.style.cssText = styleCanvas;
@@ -444,9 +463,176 @@ async function engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, game
444
463
  // wait for all the promises to finish
445
464
  await Promise.all(promises);
446
465
  return startEngine();
466
+
467
+ ///////////////////////////////////////////////////////////////////////////
468
+ // LittleJS Splash Screen
469
+ function drawEngineSplashScreen(t)
470
+ {
471
+ const x = overlayContext;
472
+ const w = overlayCanvas.width = innerWidth;
473
+ const h = overlayCanvas.height = innerHeight;
474
+
475
+ {
476
+ // background
477
+ const p3 = percent(t, 1, .8);
478
+ const p4 = percent(t, 0, .5);
479
+ const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.7);
480
+ g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
481
+ g.addColorStop(1,hsl(0,0,0,p3).toString());
482
+ x.save();
483
+ x.fillStyle = g;
484
+ x.fillRect(0,0,w,h);
485
+ }
486
+
487
+ // draw LittleJS logo...
488
+ const rect = (X, Y, W, H, C)=>
489
+ {
490
+ x.beginPath();
491
+ x.rect(X,Y,W,C?H*p:H);
492
+ x.fillStyle = C;
493
+ C ? x.fill() : x.stroke();
494
+ };
495
+ const line = (X, Y, Z, W)=>
496
+ {
497
+ x.beginPath();
498
+ x.lineTo(X,Y);
499
+ x.lineTo(Z,W);
500
+ x.stroke();
501
+ };
502
+ const circle = (X, Y, R, A=0, B=2*PI, C, F)=>
503
+ {
504
+ const D = (A+B)/2, E = p*(B-A)/2;
505
+ x.beginPath();
506
+ F && x.lineTo(X,Y);
507
+ x.arc(X,Y,R,D-E,D+E);
508
+ x.fillStyle = C;
509
+ C ? x.fill() : x.stroke();
510
+ };
511
+ const color = (c=0, l=0) =>
512
+ hsl([.98,.3,.57,.14][c%4],.8,[0,.3,.5,.8,.9][l]).toString();
513
+ const alpha = wave(1,1,t);
514
+ const p = percent(alpha, .1, .5);
515
+
516
+ // setup
517
+ x.translate(w/2,h/2);
518
+ const size = min(6, min(w,h)/99); // fit to screen
519
+ x.scale(size,size);
520
+ x.translate(-40,-35);
521
+ x.lineJoin = x.lineCap = 'round';
522
+ x.lineWidth = .1 + p*1.9;
523
+
524
+ // drawing effect
525
+ const p2 = percent(alpha,.1,1);
526
+ x.setLineDash([99*p2,99]);
527
+
528
+ // cab top
529
+ rect(7,16,18,-8,color(2,2));
530
+ rect(7,8,18,4,color(2,3));
531
+ rect(25,8,8,8,color(2,1));
532
+ rect(25,8,-18,8);
533
+ rect(25,8,8,8);
534
+
535
+ // cab
536
+ rect(25,16,7,23,color());
537
+ rect(11,39,14,-23,color(1,1));
538
+ rect(11,16,14,18,color(1,2));
539
+ rect(11,16,14,8,color(1,3));
540
+ rect(25,16,-14,24);
541
+
542
+ // cab window
543
+ rect(15,29,6,-9,color(2,2));
544
+ circle(15,21,5,0,PI/2,color(2,4),1);
545
+ rect(21,21,-6,9);
546
+
547
+ // little stack
548
+ rect(37,14,9,6,color(3,2));
549
+ rect(37,14,4.5,6,color(3,3));
550
+ rect(37,14,9,6);
551
+
552
+ // big stack
553
+ rect(50,20,10,-8,color(0,1));
554
+ rect(50,20,6.5,-8,color(0,2));
555
+ rect(50,20,3.5,-8,color(0,3));
556
+ rect(50,20,10,-8);
557
+ circle(55,2,11.4,.5,PI-.5,color(3,3));
558
+ circle(55,2,11.4,.5,PI/2,color(3,2),1);
559
+ circle(55,2,11.4,.5,PI-.5);
560
+ rect(45,7,20,-7,color(0,2));
561
+ rect(45,-1,20,4,color(0,3));
562
+ rect(45,-1,20,8);
563
+
564
+ // engine
565
+ for (let i=5; i--;)
566
+ {
567
+ // stagger radius to fix slight seam
568
+ circle(60-i*6,30, 9.9,0,2*PI,color(i+2,3));
569
+ circle(60-i*6,30,10.0,-.5,PI+.5,color(i+2,2));
570
+ circle(60-i*6,30,10.1,.5,PI-.5,color(i+2,1));
571
+ }
572
+
573
+ // engine outline
574
+ circle(36,30,10,PI/2,PI*3/2);
575
+ circle(48,30,10,PI/2,PI*3/2);
576
+ circle(60,30,10);
577
+ line(36,20,60,20);
578
+
579
+ // engine front light
580
+ circle(60,30,4,PI,3*PI,color(3,2));
581
+ circle(60,30,4,PI,2*PI,color(3,3));
582
+ circle(60,30,4,PI,3*PI);
583
+
584
+ // front brush
585
+ for (let i=6; i--;)
586
+ {
587
+ x.beginPath();
588
+ x.lineTo(53,54);
589
+ x.lineTo(53,40);
590
+ x.lineTo(53+(1+i*2.9)*p,40);
591
+ x.lineTo(53+(4+i*3.5)*p,54);
592
+ x.fillStyle = color(0,i%2+2);
593
+ x.fill();
594
+ i%2 && x.stroke();
595
+ }
596
+
597
+ // wheels
598
+ rect(6,40,5,5);
599
+ rect(6,40,5,5,color());
600
+ rect(15,54,38,-14,color());
601
+ for (let i=3; i--;)
602
+ for (let j=2; j--;)
603
+ {
604
+ circle(15*i+15,47,j?7:1,PI,3*PI,color(i,3));
605
+ x.stroke();
606
+ circle(15*i+15,47,j?7:1,0,PI,color(i,2));
607
+ x.stroke();
608
+ }
609
+ line(6,40,68,40); // center
610
+ line(77,54,4,54); // bottom
611
+
612
+ // draw engine name
613
+ const s = engineName;
614
+ x.font = '900 16px arial';
615
+ x.textAlign = 'center';
616
+ x.textBaseline = 'top';
617
+ x.lineWidth = .1+p*3.9;
618
+ let w2 = 0;
619
+ for (let i=0; i<s.length; ++i)
620
+ w2 += x.measureText(s[i]).width;
621
+ for (let j=2; j--;)
622
+ for (let i=0, X=41-w2/2; i<s.length; ++i)
623
+ {
624
+ x.fillStyle = color(i,2);
625
+ const w = x.measureText(s[i]).width;
626
+ x[j?'strokeText':'fillText'](s[i],X+w/2,55.5,17*p);
627
+ X += w;
628
+ }
629
+ x.restore();
630
+ }
631
+ ///////////////////////////////////////////////////////////////////////////
447
632
  }
448
633
 
449
634
  /** Update each engine object, remove destroyed objects, and update time
635
+ * can be called manually if objects need to be updated outside of main loop
450
636
  * @memberof Engine */
451
637
  function engineObjectsUpdate()
452
638
  {
@@ -498,18 +684,21 @@ function engineObjectsDestroy()
498
684
  function engineObjectsCollect(pos, size, objects=engineObjects)
499
685
  {
500
686
  const collectedObjects = [];
501
- if (!pos) // all objects
687
+ if (!pos)
502
688
  {
689
+ // all objects
503
690
  for (const o of objects)
504
691
  collectedObjects.push(o);
505
692
  }
506
- else if (size instanceof Vector2) // bounding box test
693
+ else if (size instanceof Vector2)
507
694
  {
695
+ // bounding box test
508
696
  for (const o of objects)
509
697
  o.isOverlapping(pos, size) && collectedObjects.push(o);
510
698
  }
511
- else // circle test
699
+ else
512
700
  {
701
+ // circle test
513
702
  const sizeSquared = size*size;
514
703
  for (const o of objects)
515
704
  pos.distanceSquared(o.pos) < sizeSquared && collectedObjects.push(o);
@@ -552,173 +741,6 @@ function engineObjectsRaycast(start, end, objects=engineObjects)
552
741
 
553
742
  debugRaycast && debugLine(start, end, hitObjects.length ? '#f00' : '#00f', .02);
554
743
  return hitObjects;
555
- }
556
-
557
- ///////////////////////////////////////////////////////////////////////////////
558
- // LittleJS splash screen and logo
559
-
560
- function drawEngineSplashScreen(t)
561
- {
562
- const x = overlayContext;
563
- const w = overlayCanvas.width = innerWidth;
564
- const h = overlayCanvas.height = innerHeight;
565
-
566
- {
567
- // background
568
- const p3 = percent(t, 1, .8);
569
- const p4 = percent(t, 0, .5);
570
- const g = x.createRadialGradient(w/2,h/2,0,w/2,h/2,hypot(w,h)*.7);
571
- g.addColorStop(0,hsl(0,0,lerp(0,p3/2,p4),p3).toString());
572
- g.addColorStop(1,hsl(0,0,0,p3).toString());
573
- x.save();
574
- x.fillStyle = g;
575
- x.fillRect(0,0,w,h);
576
- }
577
-
578
- // draw LittleJS logo...
579
- const rect = (X, Y, W, H, C)=>
580
- {
581
- x.beginPath();
582
- x.rect(X,Y,W,C?H*p:H);
583
- x.fillStyle = C;
584
- C ? x.fill() : x.stroke();
585
- };
586
- const line = (X, Y, Z, W)=>
587
- {
588
- x.beginPath();
589
- x.lineTo(X,Y);
590
- x.lineTo(Z,W);
591
- x.stroke();
592
- };
593
- const circle = (X, Y, R, A=0, B=2*PI, C, F)=>
594
- {
595
- const D = (A+B)/2, E = p*(B-A)/2;
596
- x.beginPath();
597
- F && x.lineTo(X,Y);
598
- x.arc(X,Y,R,D-E,D+E);
599
- x.fillStyle = C;
600
- C ? x.fill() : x.stroke();
601
- };
602
- const color = (c=0, l=0) =>
603
- hsl([.98,.3,.57,.14][c%4]-10,.8,[0,.3,.5,.8,.9][l]).toString();
604
- const alpha = wave(1,1,t);
605
- const p = percent(alpha, .1, .5);
606
-
607
- // setup
608
- x.translate(w/2,h/2);
609
- const size = min(6, min(w,h)/99); // fit to screen
610
- x.scale(size,size);
611
- x.translate(-40,-35);
612
- x.lineJoin = x.lineCap = 'round';
613
- x.lineWidth = .1 + p*1.9;
614
-
615
- // drawing effect
616
- const p2 = percent(alpha,.1,1);
617
- x.setLineDash([99*p2,99]);
618
-
619
- // cab top
620
- rect(7,16,18,-8,color(2,2));
621
- rect(7,8,18,4,color(2,3));
622
- rect(25,8,8,8,color(2,1));
623
- rect(25,8,-18,8);
624
- rect(25,8,8,8);
625
-
626
- // cab
627
- rect(25,16,7,23,color());
628
- rect(11,39,14,-23,color(1,1));
629
- rect(11,16,14,18,color(1,2));
630
- rect(11,16,14,8,color(1,3));
631
- rect(25,16,-14,24);
632
-
633
- // cab window
634
- rect(15,29,6,-9,color(2,2));
635
- circle(15,21,5,0,PI/2,color(2,4),1);
636
- rect(21,21,-6,9);
637
-
638
- // little stack
639
- rect(37,14,9,6,color(3,2));
640
- rect(37,14,4.5,6,color(3,3));
641
- rect(37,14,9,6);
642
-
643
- // big stack
644
- rect(50,20,10,-8,color(0,1));
645
- rect(50,20,6.5,-8,color(0,2));
646
- rect(50,20,3.5,-8,color(0,3));
647
- rect(50,20,10,-8);
648
- circle(55,2,11.4,.5,PI-.5,color(3,3));
649
- circle(55,2,11.4,.5,PI/2,color(3,2),1);
650
- circle(55,2,11.4,.5,PI-.5);
651
- rect(45,7,20,-7,color(0,2));
652
- rect(45,-1,20,4,color(0,3));
653
- rect(45,-1,20,8);
654
-
655
- // engine
656
- for (let i=5; i--;)
657
- {
658
- // stagger radius to fix slight seam
659
- circle(60-i*6,30, 9.9,0,2*PI,color(i+2,3));
660
- circle(60-i*6,30,10.0,-.5,PI+.5,color(i+2,2));
661
- circle(60-i*6,30,10.1,.5,PI-.5,color(i+2,1));
662
- }
663
-
664
- // engine outline
665
- circle(36,30,10,PI/2,PI*3/2);
666
- circle(48,30,10,PI/2,PI*3/2);
667
- circle(60,30,10);
668
- line(36,20,60,20);
669
-
670
- // engine front light
671
- circle(60,30,4,PI,3*PI,color(3,2));
672
- circle(60,30,4,PI,2*PI,color(3,3));
673
- circle(60,30,4,PI,3*PI);
674
-
675
- // front brush
676
- for (let i=6; i--;)
677
- {
678
- x.beginPath();
679
- x.lineTo(53,54);
680
- x.lineTo(53,40);
681
- x.lineTo(53+(1+i*2.9)*p,40);
682
- x.lineTo(53+(4+i*3.5)*p,54);
683
- x.fillStyle = color(0,i%2+2);
684
- x.fill();
685
- i%2 && x.stroke();
686
- }
687
-
688
- // wheels
689
- rect(6,40,5,5);
690
- rect(6,40,5,5,color());
691
- rect(15,54,38,-14,color());
692
- for (let i=3; i--;)
693
- for (let j=2; j--;)
694
- {
695
- circle(15*i+15,47,j?7:1,PI,3*PI,color(i,3));
696
- x.stroke();
697
- circle(15*i+15,47,j?7:1,0,PI,color(i,2));
698
- x.stroke();
699
- }
700
- line(6,40,68,40); // center
701
- line(77,54,4,54); // bottom
702
-
703
- // draw engine name
704
- const s = engineName;
705
- x.font = '900 16px arial';
706
- x.textAlign = 'center';
707
- x.textBaseline = 'top';
708
- x.lineWidth = .1+p*3.9;
709
- let w2 = 0;
710
- for (let i=0; i<s.length; ++i)
711
- w2 += x.measureText(s[i]).width;
712
- for (let j=2; j--;)
713
- for (let i=0, X=41-w2/2; i<s.length; ++i)
714
- {
715
- x.fillStyle = color(i,2);
716
- const w = x.measureText(s[i]).width;
717
- x[j?'strokeText':'fillText'](s[i],X+w/2,55.5,17*p);
718
- X += w;
719
- }
720
-
721
- x.restore();
722
744
  }
723
745
  /**
724
746
  * LittleJS - Release Mode
@@ -1383,9 +1405,15 @@ class Vector2
1383
1405
  {
1384
1406
  this.x = x;
1385
1407
  this.y = y;
1408
+ ASSERT_VECTOR2_VALID(this);
1386
1409
  return this;
1387
1410
  }
1388
1411
 
1412
+ /** Sets this vector from another vector and returns self
1413
+ * @param {Vector2} v - other vector
1414
+ * @return {Vector2} */
1415
+ setFrom(v) { return this.set(v.x, v.y); }
1416
+
1389
1417
  /** Returns a new vector that is a copy of this
1390
1418
  * @return {Vector2} */
1391
1419
  copy() { return new Vector2(this.x, this.y); }
@@ -1448,7 +1476,7 @@ class Vector2
1448
1476
  clampLength(length=1)
1449
1477
  {
1450
1478
  const l = this.length();
1451
- return l > length ? this.scale(length/l) : this;
1479
+ return l > length ? this.scale(length/l) : this.copy();
1452
1480
  }
1453
1481
 
1454
1482
  /** Returns the dot product of this and the vector passed in
@@ -1535,6 +1563,10 @@ class Vector2
1535
1563
  * @return {number} */
1536
1564
  area() { return abs(this.x * this.y); }
1537
1565
 
1566
+ /** Returns true if this vector is (0,0)
1567
+ * @return {boolean} */
1568
+ isZero() { return !this.x && !this.y; }
1569
+
1538
1570
  /** Returns a new vector that is p percent between this and the vector passed in
1539
1571
  * @param {Vector2} v - other vector
1540
1572
  * @param {number} percent
@@ -1645,9 +1677,15 @@ class Color
1645
1677
  this.g = g;
1646
1678
  this.b = b;
1647
1679
  this.a = a;
1680
+ ASSERT_COLOR_VALID(this);
1648
1681
  return this;
1649
1682
  }
1650
1683
 
1684
+ /** Sets this color from another color and returns self
1685
+ * @param {Color} c - other color
1686
+ * @return {Color} */
1687
+ setFrom(c) { return this.set(c.r, c.g, c.b, c.a); }
1688
+
1651
1689
  /** Returns a new color that is a copy of this
1652
1690
  * @return {Color} */
1653
1691
  copy() { return new Color(this.r, this.g, this.b, this.a); }
@@ -2023,6 +2061,20 @@ let canvasClearColor = CLEAR_BLACK;
2023
2061
  * @memberof Settings */
2024
2062
  let canvasMaxSize = vec2(1920, 1080);
2025
2063
 
2064
+ /** Minimum aspect ratio of the canvas (width/height), unused if 0
2065
+ * Can be used with canvasMaxAspect to limit aspect ratio
2066
+ * @type {number}
2067
+ * @default
2068
+ * @memberof Settings */
2069
+ let canvasMinAspect = 0;
2070
+
2071
+ /** Maximum aspect ratio of the canvas (width/height), unused if 0
2072
+ * Can be used with canvasMinAspect to limit aspect ratio
2073
+ * @type {number}
2074
+ * @default
2075
+ * @memberof Settings */
2076
+ let canvasMaxAspect = 0;
2077
+
2026
2078
  /** Fixed size of the canvas, if enabled canvas size never changes
2027
2079
  * - you may also need to set mainCanvasSize if using screen space coords in startup
2028
2080
  * @type {Vector2}
@@ -2199,17 +2251,17 @@ let touchGamepadEnable = false;
2199
2251
  * @memberof Settings */
2200
2252
  let touchGamepadCenterButton = true;
2201
2253
 
2202
- /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2203
- * @type {boolean}
2254
+ /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2255
+ * @type {number}
2204
2256
  * @default
2205
2257
  * @memberof Settings */
2206
- let touchGamepadAnalog = true;
2258
+ let touchGamepadButtonCount = 4;
2207
2259
 
2208
- /** Number of buttons on touch gamepad
2209
- * @type {number}
2260
+ /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2261
+ * @type {boolean}
2210
2262
  * @default
2211
2263
  * @memberof Settings */
2212
- let touchGamepadButtonCount = 4;
2264
+ let touchGamepadAnalog = true;
2213
2265
 
2214
2266
  /** Size of virtual gamepad for touch devices in pixels
2215
2267
  * @type {number}
@@ -2319,6 +2371,16 @@ function setCanvasClearColor(color) { canvasClearColor = color.copy(); }
2319
2371
  * @memberof Settings */
2320
2372
  function setCanvasMaxSize(size) { canvasMaxSize = size.copy(); }
2321
2373
 
2374
+ /** Set minimum aspect ratio of the canvas (width/height), unused if 0
2375
+ * @param {number} aspect
2376
+ * @memberof Settings */
2377
+ function setCanvasMinAspect(aspect) { canvasMinAspect = aspect; }
2378
+
2379
+ /** Set maximum aspect ratio of the canvas (width/height), unused if 0
2380
+ * @param {number} aspect
2381
+ * @memberof Settings */
2382
+ function setCanvasMaxAspect(aspect) { canvasMaxAspect = aspect; }
2383
+
2322
2384
  /** Set fixed size of the canvas
2323
2385
  * @param {Vector2} size
2324
2386
  * @memberof Settings */
@@ -2475,6 +2537,11 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
2475
2537
  * @memberof Settings */
2476
2538
  function setTouchGamepadCenterButton(enable) { touchGamepadCenterButton = enable; }
2477
2539
 
2540
+ /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2541
+ * @param {number} count
2542
+ * @memberof Settings */
2543
+ function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; }
2544
+
2478
2545
  /** Set if touch gamepad should be analog stick or 8 way dpad
2479
2546
  * @param {boolean} analog
2480
2547
  * @memberof Settings */
@@ -2683,7 +2750,7 @@ class EngineObject
2683
2750
  child.updateTransforms();
2684
2751
  }
2685
2752
 
2686
- /** Update the object physics, called automatically by engine once each frame */
2753
+ /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
2687
2754
  updatePhysics()
2688
2755
  {
2689
2756
  // child objects do not have physics
@@ -2716,7 +2783,7 @@ class EngineObject
2716
2783
  if (!enablePhysicsSolver || !this.mass) // don't do collision for static objects
2717
2784
  return;
2718
2785
 
2719
- const wasMovingDown = this.velocity.y < 0;
2786
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
2720
2787
  if (this.groundObject)
2721
2788
  {
2722
2789
  // apply friction in local space of ground object
@@ -2772,10 +2839,10 @@ class EngineObject
2772
2839
  {
2773
2840
  // push outside object collision
2774
2841
  this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
2775
- if ((o.groundObject && wasMovingDown) || !o.mass)
2842
+ if ((o.groundObject && wasFalling) || !o.mass)
2776
2843
  {
2777
2844
  // set ground object if landed on something
2778
- if (wasMovingDown)
2845
+ if (wasFalling)
2779
2846
  this.groundObject = o;
2780
2847
 
2781
2848
  // bounce if other object is fixed or grounded
@@ -2862,12 +2929,15 @@ class EngineObject
2862
2929
  const restitution = max(this.restitution, hitLayer.restitution);
2863
2930
  this.velocity.y *= -restitution;
2864
2931
 
2865
- if (wasMovingDown)
2932
+ if (wasFalling)
2866
2933
  {
2867
- // adjust position to slightly above nearest tile boundary
2934
+ // adjust position to slightly away from nearest tile
2868
2935
  // this prevents gap between object and ground
2869
2936
  const epsilon = .0001;
2870
- this.pos.y = (oldPos.y-this.size.y/2|0)+this.size.y/2+epsilon;
2937
+ const offset = this.size.y/2 + epsilon;
2938
+ this.pos.y = gravity.y < 0 ?
2939
+ floor(oldPos.y-this.size.y/2) + offset :
2940
+ ceil( oldPos.y+this.size.y/2) - offset;
2871
2941
 
2872
2942
  // set ground object for tile collision
2873
2943
  this.groundObject = hitLayer;
@@ -3036,21 +3106,20 @@ class EngineObject
3036
3106
  * @return {string} */
3037
3107
  toString()
3038
3108
  {
3039
- if (debug)
3040
- {
3041
- let text = 'type = ' + this.constructor.name;
3042
- if (this.pos.x || this.pos.y)
3043
- text += '\npos = ' + this.pos;
3044
- if (this.velocity.x || this.velocity.y)
3045
- text += '\nvelocity = ' + this.velocity;
3046
- if (this.size.x || this.size.y)
3047
- text += '\nsize = ' + this.size;
3048
- if (this.angle)
3049
- text += '\nangle = ' + this.angle.toFixed(3);
3050
- if (this.color)
3051
- text += '\ncolor = ' + this.color;
3052
- return text;
3053
- }
3109
+ if (!debug) return;
3110
+
3111
+ let text = 'type = ' + this.constructor.name;
3112
+ if (this.pos.x || this.pos.y)
3113
+ text += '\npos = ' + this.pos;
3114
+ if (this.velocity.x || this.velocity.y)
3115
+ text += '\nvelocity = ' + this.velocity;
3116
+ if (this.size.x || this.size.y)
3117
+ text += '\nsize = ' + this.size;
3118
+ if (this.angle)
3119
+ text += '\nangle = ' + this.angle.toFixed(3);
3120
+ if (this.color)
3121
+ text += '\ncolor = ' + this.color;
3122
+ return text;
3054
3123
  }
3055
3124
 
3056
3125
  /** Render debug info for this object */
@@ -3219,7 +3288,7 @@ class TileInfo
3219
3288
  this.padding = padding;
3220
3289
  /** @property {TextureInfo} - The texture info for this tile */
3221
3290
  this.textureInfo = textureInfos[this.textureIndex];
3222
- /** @property {float} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3291
+ /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3223
3292
  this.bleedScale = bleedScale;
3224
3293
  }
3225
3294
 
@@ -3706,7 +3775,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
3706
3775
 
3707
3776
  /** Draw text on main canvas in world space
3708
3777
  * Automatically splits new lines into rows
3709
- * @param {string} text
3778
+ * @param {string|number} text
3710
3779
  * @param {Vector2} pos
3711
3780
  * @param {number} [size]
3712
3781
  * @param {Color} [color=(1,1,1,1)]
@@ -3733,7 +3802,7 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
3733
3802
 
3734
3803
  /** Draw text on overlay canvas in world space
3735
3804
  * Automatically splits new lines into rows
3736
- * @param {string} text
3805
+ * @param {string|number} text
3737
3806
  * @param {Vector2} pos
3738
3807
  * @param {number} [size]
3739
3808
  * @param {Color} [color=(1,1,1,1)]
@@ -3752,7 +3821,7 @@ function drawTextOverlay(text, pos, size=1, color, lineWidth=0, lineColor, textA
3752
3821
 
3753
3822
  /** Draw text on overlay canvas in screen space
3754
3823
  * Automatically splits new lines into rows
3755
- * @param {string} text
3824
+ * @param {string|number} text
3756
3825
  * @param {Vector2} pos
3757
3826
  * @param {number} [size]
3758
3827
  * @param {Color} [color=(1,1,1,1)]
@@ -3917,11 +3986,28 @@ function worldToScreenTransform(worldPos, worldSize, worldAngle=0)
3917
3986
  ];
3918
3987
  }
3919
3988
 
3920
- /** Get the camera's visible area in world space
3989
+ /** Get the size of the camera window in world space
3921
3990
  * @return {Vector2}
3922
3991
  * @memberof Draw */
3923
3992
  function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
3924
3993
 
3994
+ /** Check if a point or circle is on screen
3995
+ * If size is a Vector2, uses the largest dimension as diameter
3996
+ * This can be used to cull offscreen objects from render or update
3997
+ * @param {Vector2} pos - world space position
3998
+ * @param {Vector2|number} size - world space size or diameter
3999
+ * @return {boolean}
4000
+ * @memberof Draw */
4001
+ function isOnScreen(pos, size=0)
4002
+ {
4003
+ pos = worldToScreen(pos);
4004
+ if (size instanceof Vector2)
4005
+ size = max(size.x, size.y); // use largest dimension
4006
+ size *= cameraScale/2;
4007
+ return pos.x + size > 0 && pos.x - size < mainCanvasSize.x &&
4008
+ pos.y + size > 0 && pos.y - size < mainCanvasSize.y;
4009
+ }
4010
+
3925
4011
  /** Enable normal or additive blend mode
3926
4012
  * @param {boolean} [additive]
3927
4013
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -4085,7 +4171,7 @@ class FontImage
4085
4171
  }
4086
4172
 
4087
4173
  /** Draw text in world space using the image font
4088
- * @param {string} text
4174
+ * @param {string|number} text
4089
4175
  * @param {Vector2} pos
4090
4176
  * @param {number} [scale=.25]
4091
4177
  * @param {boolean} [center]
@@ -4097,7 +4183,7 @@ class FontImage
4097
4183
  }
4098
4184
 
4099
4185
  /** Draw text on overlay canvas in world space using the image font
4100
- * @param {string} text
4186
+ * @param {string|number} text
4101
4187
  * @param {Vector2} pos
4102
4188
  * @param {number} [scale]
4103
4189
  * @param {boolean} [center]
@@ -4106,7 +4192,7 @@ class FontImage
4106
4192
  { this.drawText(text, pos, scale, center, overlayContext); }
4107
4193
 
4108
4194
  /** Draw text on overlay canvas in screen space using the image font
4109
- * @param {string} text
4195
+ * @param {string|number} text
4110
4196
  * @param {Vector2} pos
4111
4197
  * @param {number} [scale]
4112
4198
  * @param {boolean} [center]
@@ -4150,6 +4236,85 @@ class FontImage
4150
4236
  * @namespace Input
4151
4237
  */
4152
4238
 
4239
+ /** Mouse pos in world space
4240
+ * @type {Vector2}
4241
+ * @memberof Input */
4242
+ let mousePos = vec2();
4243
+
4244
+ /** Mouse pos in screen space
4245
+ * @type {Vector2}
4246
+ * @memberof Input */
4247
+ let mousePosScreen = vec2();
4248
+
4249
+ /** Mouse movement delta in world space
4250
+ * @type {Vector2}
4251
+ * @memberof Input */
4252
+ let mouseDelta = vec2();
4253
+
4254
+ /** Mouse movement delta in screen space
4255
+ * @type {Vector2}
4256
+ * @memberof Input */
4257
+ let mouseDeltaScreen = vec2();
4258
+
4259
+ /** Mouse wheel delta this frame
4260
+ * @type {number}
4261
+ * @memberof Input */
4262
+ let mouseWheel = 0;
4263
+
4264
+ /** True if mouse was inside the document window, set to false when mouse leaves
4265
+ * @type {boolean}
4266
+ * @memberof Input */
4267
+ let mouseInWindow = true;
4268
+
4269
+ /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4270
+ * @type {boolean}
4271
+ * @memberof Input */
4272
+ let isUsingGamepad = false;
4273
+
4274
+ /** Prevents input continuing to the default browser handling (true by default)
4275
+ * @type {boolean}
4276
+ * @memberof Input */
4277
+ let inputPreventDefault = true;
4278
+
4279
+ /** Primary gamepad index, automatically set to first gamepad with input
4280
+ * @type {number}
4281
+ * @memberof Input */
4282
+ let gamepadPrimary = 0;
4283
+
4284
+ /** Prevents input continuing to the default browser handling
4285
+ * This is useful to disable for html menus so the browser can handle input normally
4286
+ * @param {boolean} preventDefault
4287
+ * @memberof Input */
4288
+ function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4289
+
4290
+ /** Clears an input key state
4291
+ * @param {string|number} key
4292
+ * @param {number} [device]
4293
+ * @param {boolean} [clearDown=true]
4294
+ * @param {boolean} [clearPressed=true]
4295
+ * @param {boolean} [clearReleased=true]
4296
+ * @memberof Input */
4297
+ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4298
+ {
4299
+ if (!inputData[device])
4300
+ return;
4301
+ inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4302
+ }
4303
+
4304
+ /** Clears all input
4305
+ * @memberof Input */
4306
+ function inputClear()
4307
+ {
4308
+ inputData.length = 0;
4309
+ inputData[0] = [];
4310
+ touchGamepadButtons.length = 0;
4311
+ touchGamepadSticks.length = 0;
4312
+ gamepadStickData.length = 0;
4313
+ gamepadDpadData.length = 0;
4314
+ }
4315
+
4316
+ ///////////////////////////////////////////////////////////////////////////////
4317
+
4153
4318
  /** Returns true if device key is down
4154
4319
  * @param {string|number} key
4155
4320
  * @param {number} [device]
@@ -4157,9 +4322,9 @@ class FontImage
4157
4322
  * @memberof Input */
4158
4323
  function keyIsDown(key, device=0)
4159
4324
  {
4160
- ASSERT(key !== undefined, 'key is undefined');
4325
+ ASSERT(isString(key), 'key must be a number or string');
4161
4326
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4162
- return inputData[device] && !!(inputData[device][key] & 1);
4327
+ return !!(inputData[device]?.[key] & 1);
4163
4328
  }
4164
4329
 
4165
4330
  /** Returns true if device key was pressed this frame
@@ -4169,9 +4334,9 @@ function keyIsDown(key, device=0)
4169
4334
  * @memberof Input */
4170
4335
  function keyWasPressed(key, device=0)
4171
4336
  {
4172
- ASSERT(key !== undefined, 'key is undefined');
4337
+ ASSERT(isString(key), 'key must be a number or string');
4173
4338
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4174
- return inputData[device] && !!(inputData[device][key] & 2);
4339
+ return !!(inputData[device]?.[key] & 2);
4175
4340
  }
4176
4341
 
4177
4342
  /** Returns true if device key was released this frame
@@ -4181,172 +4346,192 @@ function keyWasPressed(key, device=0)
4181
4346
  * @memberof Input */
4182
4347
  function keyWasReleased(key, device=0)
4183
4348
  {
4184
- ASSERT(key !== undefined, 'key is undefined');
4349
+ ASSERT(isString(key), 'key must be a number or string');
4185
4350
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4186
- return inputData[device] && !!(inputData[device][key] & 4);
4351
+ return !!(inputData[device]?.[key] & 4);
4187
4352
  }
4188
4353
 
4189
4354
  /** Returns input vector from arrow keys or WASD if enabled
4355
+ * @param {string} [up]
4356
+ * @param {string} [down]
4357
+ * @param {string} [left]
4358
+ * @param {string} [right]
4190
4359
  * @return {Vector2}
4191
4360
  * @memberof Input */
4192
4361
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
4193
4362
  {
4363
+ ASSERT(isString(up), 'up key must be a string');
4364
+ ASSERT(isString(down), 'down key must be a string');
4365
+ ASSERT(isString(left), 'left key must be a string');
4366
+ ASSERT(isString(right), 'right key must be a string');
4194
4367
  const k = (key)=> keyIsDown(key) ? 1 : 0;
4195
4368
  return vec2(k(right) - k(left), k(up) - k(down));
4196
4369
  }
4197
4370
 
4198
- /** Clears all input
4199
- * @memberof Input */
4200
- function inputClear() { inputData = [[]]; touchGamepadButtons = []; }
4201
-
4202
- /** Clears an input key state
4203
- * @param {string|number} key
4204
- * @param {number} [device]
4205
- * @param {boolean} [clearDown=true]
4206
- * @param {boolean} [clearPressed=true]
4207
- * @param {boolean} [clearReleased=true]
4208
- * @memberof Input */
4209
- function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4210
- {
4211
- if (!inputData[device])
4212
- return;
4213
- inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4214
- }
4215
-
4216
4371
  /** Returns true if mouse button is down
4217
4372
  * @function
4218
4373
  * @param {number} button
4219
4374
  * @return {boolean}
4220
4375
  * @memberof Input */
4221
- function mouseIsDown(button) { return keyIsDown(button); }
4376
+ function mouseIsDown(button)
4377
+ {
4378
+ ASSERT(isNumber(button), 'mouse button must be a number');
4379
+ return keyIsDown(button);
4380
+ }
4222
4381
 
4223
4382
  /** Returns true if mouse button was pressed
4224
4383
  * @function
4225
4384
  * @param {number} button
4226
4385
  * @return {boolean}
4227
4386
  * @memberof Input */
4228
- function mouseWasPressed(button) { return keyWasPressed(button); }
4387
+ function mouseWasPressed(button)
4388
+ {
4389
+ ASSERT(isNumber(button), 'mouse button must be a number');
4390
+ return keyWasPressed(button);
4391
+ }
4229
4392
 
4230
4393
  /** Returns true if mouse button was released
4231
4394
  * @function
4232
4395
  * @param {number} button
4233
4396
  * @return {boolean}
4234
4397
  * @memberof Input */
4235
- function mouseWasReleased(button) { return keyWasReleased(button); }
4236
-
4237
- /** Mouse pos in world space
4238
- * @type {Vector2}
4239
- * @memberof Input */
4240
- let mousePos = vec2();
4241
-
4242
- /** Mouse pos in screen space
4243
- * @type {Vector2}
4244
- * @memberof Input */
4245
- let mousePosScreen = vec2();
4246
-
4247
- /** Mouse movement delta in world space
4248
- * @type {Vector2}
4249
- * @memberof Input */
4250
- let mouseDelta = vec2();
4251
-
4252
- /** Mouse movement delta in screen space
4253
- * @type {Vector2}
4254
- * @memberof Input */
4255
- let mouseDeltaScreen = vec2();
4256
-
4257
- /** Mouse wheel delta this frame
4258
- * @type {number}
4259
- * @memberof Input */
4260
- let mouseWheel = 0;
4261
-
4262
- /** True if mouse was inside the document window, set to false when mouse leaves
4263
- * @type {boolean}
4264
- * @memberof Input */
4265
- let mouseInWindow = true;
4266
-
4267
- /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4268
- * @type {boolean}
4269
- * @memberof Input */
4270
- let isUsingGamepad = false;
4271
-
4272
- /** Prevents input continuing to the default browser handling (true by default)
4273
- * @type {boolean}
4274
- * @memberof Input */
4275
- let inputPreventDefault = true;
4276
-
4277
- /** Prevents input continuing to the default browser handling
4278
- * This is useful to disable for html menus so the browser can handle input normally
4279
- * @param {boolean} preventDefault
4280
- * @memberof Input */
4281
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4398
+ function mouseWasReleased(button)
4399
+ {
4400
+ ASSERT(isNumber(button), 'mouse button must be a number');
4401
+ return keyWasReleased(button);
4402
+ }
4282
4403
 
4283
4404
  /** Returns true if gamepad button is down
4284
4405
  * @param {number} button
4285
4406
  * @param {number} [gamepad]
4286
4407
  * @return {boolean}
4287
4408
  * @memberof Input */
4288
- function gamepadIsDown(button, gamepad=0)
4289
- { return keyIsDown(button, gamepad+1); }
4409
+ function gamepadIsDown(button, gamepad=gamepadPrimary)
4410
+ {
4411
+ ASSERT(isNumber(button), 'button must be a number');
4412
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4413
+ return keyIsDown(button, gamepad+1);
4414
+ }
4290
4415
 
4291
4416
  /** Returns true if gamepad button was pressed
4292
4417
  * @param {number} button
4293
4418
  * @param {number} [gamepad]
4294
4419
  * @return {boolean}
4295
4420
  * @memberof Input */
4296
- function gamepadWasPressed(button, gamepad=0)
4297
- { return keyWasPressed(button, gamepad+1); }
4421
+ function gamepadWasPressed(button, gamepad=gamepadPrimary)
4422
+ {
4423
+ ASSERT(isNumber(button), 'button must be a number');
4424
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4425
+ return keyWasPressed(button, gamepad+1);
4426
+ }
4298
4427
 
4299
4428
  /** Returns true if gamepad button was released
4300
4429
  * @param {number} button
4301
4430
  * @param {number} [gamepad]
4302
4431
  * @return {boolean}
4303
4432
  * @memberof Input */
4304
- function gamepadWasReleased(button, gamepad=0)
4305
- { return keyWasReleased(button, gamepad+1); }
4433
+ function gamepadWasReleased(button, gamepad=gamepadPrimary)
4434
+ {
4435
+ ASSERT(isNumber(button), 'button must be a number');
4436
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4437
+ return keyWasReleased(button, gamepad+1);
4438
+ }
4306
4439
 
4307
4440
  /** Returns gamepad stick value
4308
4441
  * @param {number} stick
4309
4442
  * @param {number} [gamepad]
4310
4443
  * @return {Vector2}
4311
4444
  * @memberof Input */
4312
- function gamepadStick(stick, gamepad=0)
4313
- { return gamepadStickData[gamepad] ? gamepadStickData[gamepad][stick] || vec2() : vec2(); }
4445
+ function gamepadStick(stick, gamepad=gamepadPrimary)
4446
+ {
4447
+ ASSERT(isNumber(stick), 'stick must be a number');
4448
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4449
+ return gamepadStickData[gamepad]?.[stick] ?? vec2();
4450
+ }
4314
4451
 
4315
- ///////////////////////////////////////////////////////////////////////////////
4316
- // Input system functions called automatically by engine
4452
+ /** Returns gamepad dpad value
4453
+ * @param {number} [gamepad]
4454
+ * @return {Vector2}
4455
+ * @memberof Input */
4456
+ function gamepadDpad(gamepad=gamepadPrimary)
4457
+ {
4458
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4459
+ return gamepadDpadData[gamepad] ?? vec2();
4460
+ }
4317
4461
 
4318
- // input is stored as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased
4319
- // mouse and keyboard are stored together in device 0, gamepads are in devices > 0
4320
- let inputData = [[]];
4462
+ /** Returns true if passed in gamepad is connected
4463
+ * @param {number} [gamepad]
4464
+ * @return {boolean}
4465
+ * @memberof Input */
4466
+ function gamepadConnected(gamepad=gamepadPrimary)
4467
+ {
4468
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4469
+ return !!inputData[gamepad+1];
4470
+ }
4321
4471
 
4322
- function inputUpdate()
4472
+ /** Returns how many control sticks the passed in gamepad has
4473
+ * @param {number} [gamepad]
4474
+ * @return {number}
4475
+ * @memberof Input */
4476
+ function gamepadStickCount(gamepad=gamepadPrimary)
4323
4477
  {
4324
- if (headlessMode) return;
4478
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
4479
+ return gamepadStickData[gamepad]?.length ?? 0;
4480
+ }
4325
4481
 
4326
- // clear input when lost focus (prevent stuck keys)
4327
- if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4328
- inputClear();
4482
+ /** True if a touch device has been detected
4483
+ * @memberof Input */
4484
+ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4329
4485
 
4330
- // update mouse world space position and delta
4331
- mousePos = screenToWorld(mousePosScreen);
4332
- mouseDelta = screenToWorldDelta(mouseDeltaScreen);
4486
+ ///////////////////////////////////////////////////////////////////////////////
4333
4487
 
4334
- // update gamepads if enabled
4335
- gamepadsUpdate();
4488
+ /** Pulse the vibration hardware if it exists
4489
+ * @param {number|Array} [pattern] - single value in ms or vibration interval array
4490
+ * @memberof Input */
4491
+ function vibrate(pattern=100)
4492
+ {
4493
+ ASSERT(isNumber(pattern) || isArray(pattern), 'pattern must be a number or array');
4494
+ vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern);
4336
4495
  }
4337
4496
 
4338
- function inputUpdatePost()
4339
- {
4340
- if (headlessMode) return;
4497
+ /** Cancel any ongoing vibration
4498
+ * @memberof Input */
4499
+ function vibrateStop() { vibrate(0); }
4341
4500
 
4342
- // clear input to prepare for next frame
4343
- for (const deviceInputData of inputData)
4344
- for (const i in deviceInputData)
4345
- deviceInputData[i] &= 1;
4346
- mouseWheel = 0;
4347
- mouseDelta = vec2();
4348
- mouseDeltaScreen = vec2();
4349
- }
4501
+ ///////////////////////////////////////////////////////////////////////////////
4502
+ // Pointer Lock
4503
+
4504
+ /** Request to lock the pointer, does not work on touch devices
4505
+ * @memberof Input */
4506
+ function pointerLockRequest()
4507
+ { !isTouchDevice && mainCanvas.requestPointerLock?.(); }
4508
+
4509
+ /** Request to unlock the pointer
4510
+ * @memberof Input */
4511
+ function pointerLockExit()
4512
+ { document.exitPointerLock?.(); }
4513
+
4514
+ /** Check if pointer is locked (true if locked)
4515
+ * @return {boolean}
4516
+ * @memberof Input */
4517
+ function pointerLockIsActive()
4518
+ { return document.pointerLockElement === mainCanvas; }
4519
+
4520
+ ///////////////////////////////////////////////////////////////////////////////
4521
+ // Input variables used by engine
4522
+
4523
+ // input uses bit field for each key: 1=isDown, 2=wasPressed, 4=wasReleased
4524
+ // mouse and keyboard stored in device 0, gamepads stored in devices > 0
4525
+ const inputData = [[]];
4526
+
4527
+ // gamepad internal variables
4528
+ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
4529
+
4530
+ // touch gamepad internal variables
4531
+ const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
4532
+
4533
+ ///////////////////////////////////////////////////////////////////////////////
4534
+ // Input system functions used by engine
4350
4535
 
4351
4536
  function inputInit()
4352
4537
  {
@@ -4376,6 +4561,18 @@ function inputInit()
4376
4561
  if (inputWASDEmulateDirection)
4377
4562
  inputData[0][remapKey(e.code)] = 3;
4378
4563
  }
4564
+
4565
+ // prevent arrow key from moving the page
4566
+ const preventDefaultKeys =
4567
+ [
4568
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // scrolling
4569
+ 'Space', // page down scroll
4570
+ 'Tab', // focus navigation
4571
+ 'Backspace', // browser back
4572
+ ];
4573
+ if (preventDefaultKeys.includes(e.code))
4574
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
4575
+ e.preventDefault();
4379
4576
  }
4380
4577
  function onKeyUp(e)
4381
4578
  {
@@ -4407,7 +4604,9 @@ function inputInit()
4407
4604
  const mousePosScreenLast = mousePosScreen;
4408
4605
  mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
4409
4606
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
4410
- inputPreventDefault && e.button && e.preventDefault();
4607
+
4608
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
4609
+ e.preventDefault();
4411
4610
  }
4412
4611
  function onMouseUp(e)
4413
4612
  {
@@ -4431,344 +4630,388 @@ function inputInit()
4431
4630
  function onMouseWheel(e) { mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY); }
4432
4631
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
4433
4632
  function onBlur() { inputClear(); } // reset input when focus is lost
4434
- }
4435
-
4436
- // convert a mouse or touch event position to screen space
4437
- function mouseEventToScreen(mousePos)
4438
- {
4439
- const rect = mainCanvas.getBoundingClientRect();
4440
- const px = percent(mousePos.x, rect.left, rect.right);
4441
- const py = percent(mousePos.y, rect.top, rect.bottom);
4442
- return vec2(px*mainCanvas.width, py*mainCanvas.height);
4443
- }
4444
-
4445
- ///////////////////////////////////////////////////////////////////////////////
4446
- // Gamepad input
4447
4633
 
4448
- // gamepad internal variables
4449
- const gamepadStickData = [];
4450
-
4451
- // gamepads are updated by engine every frame automatically
4452
- function gamepadsUpdate()
4453
- {
4454
- const applyDeadZones = (v)=>
4634
+ // enable touch input mouse passthrough
4635
+ function touchInputInit()
4455
4636
  {
4456
- const min=.3, max=.8;
4457
- const deadZone = (v)=>
4458
- v > min ? percent(v, min, max) :
4459
- v < -min ? -percent(-v, min, max) : 0;
4460
- return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
4461
- }
4637
+ // add non passive touch event listeners
4638
+ document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
4639
+ document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
4640
+ document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
4462
4641
 
4463
- // update touch gamepad if enabled
4464
- if (touchGamepadEnable && isTouchDevice)
4465
- {
4466
- if (!touchGamepadTimer.isSet())
4467
- return;
4468
-
4469
- // read virtual analog stick
4470
- const sticks = gamepadStickData[0] || (gamepadStickData[0] = []);
4471
- sticks[0] = vec2();
4472
- if (touchGamepadAnalog)
4473
- sticks[0] = applyDeadZones(touchGamepadStick);
4474
- else if (touchGamepadStick.lengthSquared() > .3)
4642
+ // handle all touch events the same way
4643
+ let wasTouching;
4644
+ function handleTouch(e)
4475
4645
  {
4476
- // convert to 8 way dpad
4477
- sticks[0].x = round(touchGamepadStick.x);
4478
- sticks[0].y = -round(touchGamepadStick.y);
4479
- sticks[0] = sticks[0].clampLength();
4480
- }
4646
+ if (!touchInputEnable)
4647
+ return;
4481
4648
 
4482
- // read virtual gamepad buttons
4483
- const data = inputData[1] || (inputData[1] = []);
4484
- for (let i=10; i--;)
4485
- {
4486
- const wasDown = gamepadIsDown(i,0);
4487
- data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
4488
- }
4649
+ // route touch to gamepad
4650
+ if (touchGamepadEnable)
4651
+ handleTouchGamepad(e);
4489
4652
 
4490
- // disable normal gamepads when touch gamepad is active
4491
- return;
4492
- }
4653
+ // fix stalled audio requiring user interaction
4654
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
4655
+ audioContext.resume();
4493
4656
 
4494
- // return if gamepads are disabled or not supported
4495
- if (!gamepadsEnable || !navigator || !navigator.getGamepads)
4496
- return;
4657
+ // check if touching and pass to mouse events
4658
+ const touching = e.touches.length;
4659
+ const button = 0; // all touches are left mouse button
4660
+ if (touching)
4661
+ {
4662
+ // set event pos and pass it along
4663
+ const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
4664
+ const mousePosScreenLast = mousePosScreen;
4665
+ mousePosScreen = mouseEventToScreen(pos);
4666
+ if (wasTouching)
4667
+ {
4668
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
4669
+ isUsingGamepad = touchGamepadEnable;
4670
+ }
4671
+ else
4672
+ inputData[0][button] = 3;
4673
+ }
4674
+ else if (wasTouching)
4675
+ inputData[0][button] = inputData[0][button] & 2 | 4;
4497
4676
 
4498
- // only poll gamepads when focused or in debug mode
4499
- if (!debug && !document.hasFocus())
4500
- return;
4677
+ // set was touching
4678
+ wasTouching = touching;
4501
4679
 
4502
- // poll gamepads
4503
- const gamepads = navigator.getGamepads();
4504
- for (let i = gamepads.length; i--;)
4505
- {
4506
- // get or create gamepad data
4507
- const gamepad = gamepads[i];
4508
- const data = inputData[i+1] || (inputData[i+1] = []);
4509
- const sticks = gamepadStickData[i] || (gamepadStickData[i] = []);
4680
+ // prevent default handling like copy, magnifier lens, and scrolling
4681
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
4682
+ e.preventDefault();
4683
+
4684
+ // must return true so the document will get focus
4685
+ return true;
4686
+ }
4510
4687
 
4511
- if (gamepad)
4688
+ // special handling for virtual gamepad mode
4689
+ function handleTouchGamepad(e)
4512
4690
  {
4513
- // read analog sticks
4514
- for (let j = 0; j < gamepad.axes.length-1; j+=2)
4515
- sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
4691
+ // clear touch gamepad input
4692
+ touchGamepadSticks.length = 0;
4693
+ touchGamepadSticks[0] = vec2();
4694
+ touchGamepadSticks[1] = vec2();
4695
+ touchGamepadButtons.length = 0;
4696
+ isUsingGamepad = true;
4516
4697
 
4517
- // read buttons
4518
- for (let j = gamepad.buttons.length; j--;)
4698
+ const touching = e.touches.length;
4699
+ if (touching)
4519
4700
  {
4520
- const button = gamepad.buttons[j];
4521
- const wasDown = gamepadIsDown(j,i);
4522
- data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
4523
- if (!button.value || button.value > .9) // must be a full press
4524
- if (!i && button.pressed)
4525
- isUsingGamepad = true;
4701
+ touchGamepadTimer.set();
4702
+ if (touchGamepadCenterButton && !wasTouching && paused)
4703
+ {
4704
+ // touch anywhere to press start when paused
4705
+ touchGamepadButtons[9] = 1;
4706
+ return;
4707
+ }
4526
4708
  }
4527
4709
 
4528
- if (gamepadDirectionEmulateStick)
4710
+ // don't process touch gamepad if paused
4711
+ if (paused)
4712
+ return;
4713
+
4714
+ // get center of left and right sides
4715
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4716
+ const buttonCenter = touchGamepadButtonCenter();
4717
+ const startCenter = mainCanvasSize.scale(.5);
4718
+
4719
+ // check each touch point
4720
+ for (const touch of e.touches)
4529
4721
  {
4530
- // copy dpad to left analog stick when pressed
4531
- const dpad = vec2(
4532
- (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
4533
- (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
4534
- if (dpad.lengthSquared())
4535
- sticks[0] = dpad.clampLength();
4722
+ const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
4723
+ if (stickCenter.distance(touchPos) < touchGamepadSize)
4724
+ {
4725
+ // virtual analog stick
4726
+ const delta = touchPos.subtract(stickCenter);
4727
+ touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
4728
+ }
4729
+ else if (buttonCenter.distance(touchPos) < touchGamepadSize)
4730
+ {
4731
+ if (touchGamepadButtonCount === 1)
4732
+ {
4733
+ // virtual right analog stick
4734
+ const delta = touchPos.subtract(buttonCenter);
4735
+ touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
4736
+ }
4737
+ // virtual face buttons
4738
+ let button = buttonCenter.subtract(touchPos).direction();
4739
+ button = mod(button+2, 4);
4740
+ if (touchGamepadButtonCount === 1)
4741
+ button = 0;
4742
+ else if (touchGamepadButtonCount === 2)
4743
+ {
4744
+ const delta = buttonCenter.subtract(touchPos);
4745
+ button = -delta.x < delta.y ? 1 : 0;
4746
+ }
4747
+ // fix button locations (swap 2 and 3 to match gamepad layout)
4748
+ button = button === 3 ? 2 : button === 2 ? 3 : button;
4749
+ if (button < touchGamepadButtonCount)
4750
+ touchGamepadButtons[button] = 1;
4751
+ }
4752
+ else if (touchGamepadCenterButton &&
4753
+ startCenter.distance(touchPos) < touchGamepadSize)
4754
+ {
4755
+ // virtual start button in center
4756
+ touchGamepadButtons[9] = 1;
4757
+ }
4536
4758
  }
4537
-
4538
- // disable touch gamepad if using real gamepad
4539
- touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
4540
4759
  }
4541
4760
  }
4542
- }
4543
-
4544
- ///////////////////////////////////////////////////////////////////////////////
4545
-
4546
- /** Pulse the vibration hardware if it exists
4547
- * @param {number|Array} [pattern] - single value in ms or vibration interval array
4548
- * @memberof Input */
4549
- function vibrate(pattern=100)
4550
- { vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern); }
4551
4761
 
4552
- /** Cancel any ongoing vibration
4553
- * @memberof Input */
4554
- function vibrateStop() { vibrate(0); }
4555
-
4556
- ///////////////////////////////////////////////////////////////////////////////
4557
- // Touch input & virtual on screen gamepad
4558
-
4559
- /** True if a touch device has been detected
4560
- * @memberof Input */
4561
- const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
4562
-
4563
- // touch gamepad internal variables
4564
- let touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadStick = vec2();
4565
-
4566
- function touchGamepadButtonCenter()
4567
- {
4568
- // draw right face buttons
4569
- const center = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4570
- if (touchGamepadButtonCount <= 2)
4571
- center.x += touchGamepadSize/2;
4572
- return center;
4762
+ // convert a mouse or touch event position to screen space
4763
+ function mouseEventToScreen(mousePos)
4764
+ {
4765
+ const rect = mainCanvas.getBoundingClientRect();
4766
+ const px = percent(mousePos.x, rect.left, rect.right);
4767
+ const py = percent(mousePos.y, rect.top, rect.bottom);
4768
+ return vec2(px*mainCanvas.width, py*mainCanvas.height);
4769
+ }
4573
4770
  }
4574
4771
 
4575
- // enable touch input mouse passthrough
4576
- function touchInputInit()
4772
+ function inputUpdate()
4577
4773
  {
4578
- // add non passive touch event listeners
4579
- document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
4580
- document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
4581
- document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
4774
+ if (headlessMode) return;
4582
4775
 
4583
- // handle all touch events the same way
4584
- let wasTouching;
4585
- function handleTouch(e)
4586
- {
4587
- if (!touchInputEnable)
4588
- return;
4776
+ // clear input when lost focus (prevent stuck keys)
4777
+ if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4778
+ inputClear();
4589
4779
 
4590
- // route touch to gamepad
4591
- if (touchGamepadEnable)
4592
- handleTouchGamepad(e);
4780
+ // update mouse world space position and delta
4781
+ mousePos = screenToWorld(mousePosScreen);
4782
+ mouseDelta = screenToWorldDelta(mouseDeltaScreen);
4593
4783
 
4594
- // fix stalled audio requiring user interaction
4595
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
4596
- audioContext.resume();
4784
+ // update gamepads if enabled
4785
+ gamepadsUpdate();
4786
+
4787
+ // gamepads are updated by engine every frame automatically
4788
+ function gamepadsUpdate()
4789
+ {
4790
+ const applyDeadZones = (v)=>
4791
+ {
4792
+ const min=.3, max=.8;
4793
+ const deadZone = (v)=>
4794
+ v > min ? percent(v, min, max) :
4795
+ v < -min ? -percent(-v, min, max) : 0;
4796
+ return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
4797
+ }
4597
4798
 
4598
- // check if touching and pass to mouse events
4599
- const touching = e.touches.length;
4600
- const button = 0; // all touches are left mouse button
4601
- if (touching)
4799
+ // update touch gamepad if enabled
4800
+ if (touchGamepadEnable && isTouchDevice)
4602
4801
  {
4603
- // set event pos and pass it along
4604
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
4605
- const mousePosScreenLast = mousePosScreen;
4606
- mousePosScreen = mouseEventToScreen(pos);
4607
- if (wasTouching)
4802
+ if (!touchGamepadTimer.isSet())
4803
+ return;
4804
+
4805
+ // read virtual analog stick
4806
+ gamepadPrimary = 0; // touch gamepad uses index 0
4807
+ const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
4808
+ const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
4809
+ sticks[0] = vec2();
4810
+ dpad.set();
4811
+ const leftTouchStick = touchGamepadSticks[0] ?? vec2();
4812
+ if (touchGamepadAnalog)
4813
+ sticks[0] = applyDeadZones(leftTouchStick);
4814
+ else if (leftTouchStick.lengthSquared() > .3)
4608
4815
  {
4609
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
4610
- isUsingGamepad = touchGamepadEnable;
4816
+ // convert to 8 way dpad
4817
+ const x = clamp(round(leftTouchStick.x), -1, 1);
4818
+ const y = clamp(round(leftTouchStick.y), -1, 1);
4819
+ dpad.set(x, -y);
4820
+ sticks[0] = dpad.clampLength(); // clamp to circle
4821
+ }
4822
+ if (touchGamepadButtonCount === 1)
4823
+ {
4824
+ const rightTouchStick = touchGamepadSticks[1] ?? vec2();
4825
+ sticks[1] = applyDeadZones(rightTouchStick);
4611
4826
  }
4612
- else
4613
- inputData[0][button] = 3;
4614
- }
4615
- else if (wasTouching)
4616
- inputData[0][button] = inputData[0][button] & 2 | 4;
4617
4827
 
4618
- // set was touching
4619
- wasTouching = touching;
4828
+ // read virtual gamepad buttons
4829
+ const data = inputData[1] ?? (inputData[1] = []);
4830
+ for (let i=10; i--;)
4831
+ {
4832
+ const wasDown = gamepadIsDown(i,0);
4833
+ data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
4834
+ }
4620
4835
 
4621
- // prevent default handling like copy, magnifier lens, and scrolling
4622
- if (inputPreventDefault && document.hasFocus() && e.cancelable)
4623
- e.preventDefault();
4836
+ // disable normal gamepads when touch gamepad is active
4837
+ return;
4838
+ }
4624
4839
 
4625
- // must return true so the document will get focus
4626
- return true;
4627
- }
4840
+ // return if gamepads are disabled or not supported
4841
+ if (!gamepadsEnable || !navigator || !navigator.getGamepads)
4842
+ return;
4628
4843
 
4629
- // special handling for virtual gamepad mode
4630
- function handleTouchGamepad(e)
4631
- {
4632
- // clear touch gamepad input
4633
- touchGamepadStick = vec2();
4634
- touchGamepadButtons = [];
4635
- isUsingGamepad = true;
4844
+ // only poll gamepads when focused or in debug mode
4845
+ if (!debug && !document.hasFocus())
4846
+ return;
4636
4847
 
4637
- const touching = e.touches.length;
4638
- if (touching)
4848
+ // poll gamepads
4849
+ const maxGamepads = 8;
4850
+ const gamepads = navigator.getGamepads();
4851
+ const gamepadCount = min(maxGamepads, gamepads.length)
4852
+ for (let i=0; i<gamepadCount; ++i)
4639
4853
  {
4640
- touchGamepadTimer.set();
4641
- if (touchGamepadCenterButton && !wasTouching && paused)
4854
+ // get or create gamepad data
4855
+ const gamepad = gamepads[i];
4856
+ if (!gamepad)
4642
4857
  {
4643
- // touch anywhere to press start when paused
4644
- touchGamepadButtons[9] = 1;
4645
- return;
4858
+ // clear gamepad data if not connected
4859
+ inputData[i+1] = undefined;
4860
+ gamepadStickData[i] = undefined;
4861
+ gamepadDpadData[i] = undefined;
4862
+ gamepadHadInput[i] = undefined;
4863
+ continue;
4646
4864
  }
4647
- }
4648
4865
 
4649
- // get center of left and right sides
4650
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4651
- const buttonCenter = touchGamepadButtonCenter();
4652
- const startCenter = mainCanvasSize.scale(.5);
4866
+ const data = inputData[i+1] ?? (inputData[i+1] = []);
4867
+ const sticks = gamepadStickData[i] ?? (gamepadStickData[i] = []);
4868
+ const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
4653
4869
 
4654
- // check each touch point
4655
- for (const touch of e.touches)
4656
- {
4657
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
4658
- if (stickCenter.distance(touchPos) < touchGamepadSize)
4870
+ // read analog sticks
4871
+ for (let j = 0; j < gamepad.axes.length-1; j+=2)
4872
+ sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
4873
+
4874
+ // read buttons
4875
+ let hadInput = false;
4876
+ for (let j = gamepad.buttons.length; j--;)
4877
+ {
4878
+ const button = gamepad.buttons[j];
4879
+ const wasDown = gamepadIsDown(j,i);
4880
+ data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
4881
+
4882
+ // check for any input on this gamepad, analog must be full press
4883
+ if (button.pressed)
4884
+ if (!button.value || button.value > .9)
4885
+ hadInput = true;
4886
+ }
4887
+
4888
+ // set new primary gamepad if current is not connected
4889
+ if (hadInput)
4659
4890
  {
4660
- // virtual analog stick
4661
- touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength();
4891
+ gamepadHadInput[i] = true;
4892
+ if (!gamepadHadInput[gamepadPrimary])
4893
+ gamepadPrimary = i;
4894
+ isUsingGamepad ||= (gamepadPrimary === i);
4662
4895
  }
4663
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
4896
+
4897
+ if (gamepad.mapping === 'standard')
4664
4898
  {
4665
- // virtual face buttons
4666
- let button = buttonCenter.subtract(touchPos).direction();
4667
- button = mod(button+2, 4);
4668
- if (touchGamepadButtonCount === 1)
4669
- button = 0;
4670
- else if (touchGamepadButtonCount === 2)
4671
- {
4672
- const delta = buttonCenter.subtract(touchPos);
4673
- button = -delta.x < delta.y ? 1 : 0;
4674
- }
4675
- // fix button locations (swap 2 and 3 to match gamepad layout)
4676
- button = button === 3 ? 2 : button === 2 ? 3 : button;
4677
- if (button < touchGamepadButtonCount)
4678
- touchGamepadButtons[button] = 1;
4899
+ // get dpad buttons (standard mapping)
4900
+ dpad.set(
4901
+ (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
4902
+ (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
4679
4903
  }
4680
- else if (touchGamepadCenterButton && !wasTouching &&
4681
- startCenter.distance(touchPos) < touchGamepadSize)
4904
+ else if (gamepad.axes && gamepad.axes.length >= 2)
4682
4905
  {
4683
- // virtual start button in center
4684
- touchGamepadButtons[9] = 1;
4906
+ // digital style dpad from axes
4907
+ const x = clamp(round(gamepad.axes[0]), -1, 1);
4908
+ const y = clamp(round(gamepad.axes[1]), -1, 1);
4909
+ dpad.set(x, -y);
4685
4910
  }
4911
+
4912
+ // copy dpad to left analog stick when pressed
4913
+ if (gamepadDirectionEmulateStick && !dpad.isZero())
4914
+ sticks[0] = dpad.clampLength();
4686
4915
  }
4916
+
4917
+ // disable touch gamepad if using real gamepad
4918
+ touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
4687
4919
  }
4688
4920
  }
4689
4921
 
4690
- // render the touch gamepad, called automatically by the engine
4691
- function touchGamepadRender()
4922
+ function inputUpdatePost()
4692
4923
  {
4693
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
4694
- if (!touchGamepadEnable || !touchGamepadTimer.isSet())
4695
- return;
4924
+ if (headlessMode) return;
4696
4925
 
4697
- // fade off when not touching or paused
4698
- const alpha = percent(touchGamepadTimer.get(), 4, 3);
4699
- if (!alpha || paused)
4700
- return;
4926
+ // clear input to prepare for next frame
4927
+ for (const deviceInputData of inputData)
4928
+ for (const i in deviceInputData)
4929
+ deviceInputData[i] &= 1;
4930
+ mouseWheel = 0;
4931
+ mouseDelta = vec2();
4932
+ mouseDeltaScreen = vec2();
4933
+ }
4701
4934
 
4702
- // setup the canvas
4703
- const context = overlayContext;
4704
- context.save();
4705
- context.globalAlpha = alpha*touchGamepadAlpha;
4706
- context.strokeStyle = '#fff';
4707
- context.lineWidth = 3;
4935
+ function inputRender()
4936
+ {
4937
+ touchGamepadRender();
4708
4938
 
4709
- // draw left analog stick
4710
- context.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000';
4711
- context.beginPath();
4712
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4713
- if (touchGamepadAnalog) // draw circle shaped gamepad
4939
+ function touchGamepadRender()
4714
4940
  {
4715
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
4941
+ if (!touchInputEnable || !isTouchDevice || headlessMode) return;
4942
+ if (!touchGamepadEnable || !touchGamepadTimer.isSet())
4943
+ return;
4944
+
4945
+ // fade off when not touching or paused
4946
+ const alpha = percent(touchGamepadTimer.get(), 4, 3);
4947
+ if (!alpha || paused)
4948
+ return;
4949
+
4950
+ // setup the canvas
4951
+ const context = overlayContext;
4952
+ context.save();
4953
+ context.globalAlpha = alpha*touchGamepadAlpha;
4954
+ context.strokeStyle = '#fff';
4955
+ context.lineWidth = 3;
4956
+
4957
+ // draw left analog stick
4958
+ const leftTouchStick = touchGamepadSticks[0] ?? vec2();
4959
+ context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
4960
+ context.beginPath();
4961
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
4962
+ if (touchGamepadAnalog)
4963
+ {
4964
+ // draw circle shaped gamepad
4965
+ context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
4966
+ }
4967
+ else
4968
+ {
4969
+ // draw cross shaped gamepad
4970
+ for (let i=10; --i;)
4971
+ {
4972
+ const angle = i*PI/4;
4973
+ context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
4974
+ i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
4975
+ }
4976
+ }
4716
4977
  context.fill();
4717
4978
  context.stroke();
4718
- }
4719
- else // draw cross shaped gamepad
4720
- {
4721
- for (let i=10; i--;)
4979
+
4980
+ // draw right face buttons
4722
4981
  {
4723
- const angle = i*PI/4;
4724
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
4725
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
4726
- i===1 && context.fill();
4982
+ const buttonCenter = touchGamepadButtonCenter();
4983
+ const buttonSize = touchGamepadButtonCount > 1 ?
4984
+ touchGamepadSize/4 : touchGamepadSize/2;
4985
+ for (let i=0; i<touchGamepadButtonCount; i++)
4986
+ {
4987
+ const j = mod(i-1, 4);
4988
+ let button = touchGamepadButtonCount > 2 ?
4989
+ j : min(j, touchGamepadButtonCount-1);
4990
+ // fix button locations (swap 2 and 3 to match gamepad layout)
4991
+ button = button === 3 ? 2 : button === 2 ? 3 : button;
4992
+ const pos = touchGamepadButtonCount < 2 ? buttonCenter :
4993
+ buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
4994
+ context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
4995
+ context.beginPath();
4996
+ context.arc(pos.x, pos.y, buttonSize, 0,9);
4997
+ context.fill();
4998
+ context.stroke();
4999
+ }
4727
5000
  }
4728
- context.stroke();
4729
- }
4730
5001
 
4731
- // draw right face buttons
4732
- const buttonCenter = touchGamepadButtonCenter();
4733
- const buttonSize = touchGamepadButtonCount > 1 ? touchGamepadSize/4 : touchGamepadSize/2;
4734
- for (let i=0; i<touchGamepadButtonCount; i++)
4735
- {
4736
- const j = mod(i-1, 4);
4737
- let button = touchGamepadButtonCount > 2 ?
4738
- j : min(j, touchGamepadButtonCount-1);
4739
- // fix button locations (swap 2 and 3 to match gamepad layout)
4740
- button = button === 3 ? 2 : button === 2 ? 3 : button;
4741
- const pos = buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
4742
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
4743
- context.beginPath();
4744
- context.arc(pos.x, pos.y, buttonSize, 0,9);
4745
- context.fill();
4746
- context.stroke();
5002
+ // set canvas back to normal
5003
+ context.restore();
4747
5004
  }
4748
-
4749
- // set canvas back to normal
4750
- context.restore();
4751
5005
  }
4752
5006
 
4753
- ///////////////////////////////////////////////////////////////////////////////
4754
- // Pointer Lock
4755
-
4756
- /** Request to lock the pointer, does not work on touch devices
4757
- * @memberof Input */
4758
- function pointerLockRequest()
5007
+ // center position for right tocuh pad face buttons
5008
+ function touchGamepadButtonCenter()
4759
5009
  {
4760
- if (!isTouchDevice)
4761
- mainCanvas.requestPointerLock && mainCanvas.requestPointerLock();
4762
- }
4763
-
4764
- /** Request to unlock the pointer
4765
- * @memberof Input */
4766
- function pointerLockExit() { document.exitPointerLock && document.exitPointerLock(); }
4767
-
4768
- /** Check if pointer is locked (true if locked)
4769
- * @return {boolean}
4770
- * @memberof Input */
4771
- function pointerLockIsActive() { return document.pointerLockElement === mainCanvas; }
5010
+ const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
5011
+ if (touchGamepadButtonCount === 2)
5012
+ center.x += touchGamepadSize/2;
5013
+ return center;
5014
+ }
4772
5015
  /**
4773
5016
  * LittleJS Audio System
4774
5017
  * - <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX Sound Effects</a> - ZzFX Sound Effect Generator
@@ -5508,7 +5751,7 @@ function tileCollisionTest(pos, size=vec2(), object, solidOnly=true)
5508
5751
  }
5509
5752
  }
5510
5753
 
5511
- /** Return the exact position of the boudnary of first tile hit, undefined if nothing was hit.
5754
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
5512
5755
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
5513
5756
  * @param {Vector2} posStart
5514
5757
  * @param {Vector2} posEnd
@@ -5672,8 +5915,7 @@ class CanvasLayer extends EngineObject
5672
5915
  /** Destroy this canvas layer */
5673
5916
  destroy()
5674
5917
  {
5675
- if (this.destroyed)
5676
- return;
5918
+ if (this.destroyed) return;
5677
5919
 
5678
5920
  this.textureInfo.destroyWebGLTexture();
5679
5921
  super.destroy();
@@ -6082,7 +6324,7 @@ class TileCollisionLayer extends TileLayer
6082
6324
  return false;
6083
6325
  }
6084
6326
 
6085
- /** Return the exact position of the boudnary of first tile hit, undefined if nothing was hit.
6327
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
6086
6328
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
6087
6329
  * @param {Vector2} posStart
6088
6330
  * @param {Vector2} posEnd
@@ -6225,9 +6467,9 @@ class ParticleEmitter extends EngineObject
6225
6467
  /** @property {Color} - Color at start of life 2, randomized between start colors */
6226
6468
  this.colorStartB = colorStartB.copy();
6227
6469
  /** @property {Color} - Color at end of life 1, randomized between end colors */
6228
- this.colorEndA = colorEndA.copy();
6470
+ this.colorEndA = colorEndA.copy();
6229
6471
  /** @property {Color} - Color at end of life 2, randomized between end colors */
6230
- this.colorEndB = colorEndB.copy();
6472
+ this.colorEndB = colorEndB.copy();
6231
6473
  /** @property {boolean} - Should color be randomized linearly or across each component */
6232
6474
  this.randomColorLinear = randomColorLinear;
6233
6475
 
@@ -6311,8 +6553,10 @@ class ParticleEmitter extends EngineObject
6311
6553
  if (debugParticles)
6312
6554
  {
6313
6555
  // show emitter bounds
6314
- const emitSize = typeof this.emitSize === 'number' ? vec2(this.emitSize) : this.emitSize;
6315
- debugRect(this.pos, emitSize, '#0f0', 0, this.angle);
6556
+ if (typeof this.emitSize === 'number')
6557
+ debugCircle(this.pos, this.emitSize/2, '#0f0');
6558
+ else
6559
+ debugRect(this.pos, this.emitSize, '#0f0', 0, this.angle);
6316
6560
  }
6317
6561
  }
6318
6562
 
@@ -6322,8 +6566,8 @@ class ParticleEmitter extends EngineObject
6322
6566
  {
6323
6567
  // spawn a particle
6324
6568
  let pos = typeof this.emitSize === 'number' ? // check if number was used
6325
- randInCircle(this.emitSize/2) // circle emitter
6326
- : vec2(rand(-.5,.5), rand(-.5,.5)) // box emitter
6569
+ randInCircle(this.emitSize/2) // circle emitter
6570
+ : vec2(rand(-.5,.5), rand(-.5,.5)) // box emitter
6327
6571
  .multiply(this.emitSize).rotate(this.angle)
6328
6572
  let angle = rand(this.particleConeAngle, -this.particleConeAngle);
6329
6573
  if (!this.localSpace)
@@ -8027,11 +8271,14 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8027
8271
  /**
8028
8272
  * LittleJS User Interface Plugin
8029
8273
  * - call new UISystemPlugin() to setup the UI system
8274
+ * - Gamepad and keyboard navigation support
8030
8275
  * - Nested Menus
8031
8276
  * - Text
8032
8277
  * - Buttons
8033
8278
  * - Checkboxes
8034
8279
  * - Images
8280
+ * - Scrollbars
8281
+ * - Video
8035
8282
  * @namespace UISystem
8036
8283
  */
8037
8284
 
@@ -8042,6 +8289,20 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8042
8289
  * @memberof UISystem */
8043
8290
  let uiSystem;
8044
8291
 
8292
+ /** Enable UI system debug drawing
8293
+ * 0=off, 1=normal, 2=show invisible
8294
+ * @type {number}
8295
+ * @default
8296
+ * @memberof UISystem */
8297
+ let uiDebug = 0;
8298
+
8299
+ /** Enable UI system debug drawing
8300
+ * 0=off, 1=normal, 2=show invisible
8301
+ * @param {number|boolean} enable
8302
+ * @memberof UISystem */
8303
+ function uiSetDebug(debugMode)
8304
+ { uiDebug = typeof debugMode === 'boolean' ? (debugMode ? 1 : 0) : debugMode; }
8305
+
8045
8306
  ///////////////////////////////////////////////////////////////////////////////
8046
8307
  /**
8047
8308
  * UI System Global Object
@@ -8080,7 +8341,7 @@ class UISystemPlugin
8080
8341
  /** @property {number} - Default rounded rect corner radius for UI elements */
8081
8342
  this.defaultCornerRadius = 0;
8082
8343
  /** @property {number} - Default scale to use for fitting text to object */
8083
- this.defaultTextScale = .8;
8344
+ this.defaultTextFitScale = .8;
8084
8345
  /** @property {string} - Default font for UI elements */
8085
8346
  this.defaultFont = fontDefault;
8086
8347
  /** @property {Sound} - Default sound when interactive UI element is pressed */
@@ -8095,6 +8356,21 @@ class UISystemPlugin
8095
8356
  this.defaultShadowBlur = 5;
8096
8357
  /** @property {Vector2} - Offset of shadow blur */
8097
8358
  this.defaultShadowOffset = vec2(5);
8359
+ /** @property {number} - If set ui coords will be renormalized to this canvas height */
8360
+ this.nativeHeight = 0;
8361
+
8362
+ // navigation properties
8363
+ /** @property {UIObject} - Object currently selected by navigation (gamepad or keyboard) */
8364
+ this.navigationObject = undefined;
8365
+ /** @property {Timer} - Cooldown timer for navigation inputs */
8366
+ this.navigationTimer = new Timer(undefined, true);
8367
+ /** @property {number} - Time between navigation inputs in seconds */
8368
+ this.navigationDelay = .2;
8369
+ /** @property {boolean} - should the navigation be horizontal, vertical, or both? */
8370
+ this.navigationDirection = 1;
8371
+ /** @property {boolean} - True if user last used navigation instead of mouse */
8372
+ this.navigationMode = false;
8373
+
8098
8374
  // system state
8099
8375
  /** @property {Array<UIObject>} - List of all UI elements */
8100
8376
  this.uiObjects = [];
@@ -8106,50 +8382,118 @@ class UISystemPlugin
8106
8382
  this.hoverObject = undefined;
8107
8383
  /** @property {UIObject} - Hover object at start of update */
8108
8384
  this.lastHoverObject = undefined;
8109
- /** @property {number} - If set ui coords will be renormalized to this canvas height */
8110
- this.nativeHeight = 0;
8385
+ /** @property {UIObject} - Current confirm menu being shown */
8386
+ this.confirmDialog = undefined;
8111
8387
 
8112
8388
  engineAddPlugin(uiUpdate, uiRender);
8113
8389
 
8390
+ // set object position in parent space
8391
+ function updateTransforms(o)
8392
+ {
8393
+ if (!o.parent) return;
8394
+ o.pos.x = o.localPos.x + o.parent.pos.x;
8395
+ o.pos.y = o.localPos.y + o.parent.pos.y;
8396
+ }
8397
+
8114
8398
  // setup recursive update and render
8115
8399
  // update in reverse order to detect mouse enter/leave
8116
8400
  function uiUpdate()
8117
8401
  {
8118
- function updateInvisibleObject(o)
8402
+ if (uiSystem.activeObject && !uiSystem.activeObject.visible)
8403
+ uiSystem.activeObject = undefined;
8404
+
8405
+ // reset hover object at start of update
8406
+ uiSystem.lastHoverObject = uiSystem.hoverObject;
8407
+ uiSystem.hoverObject = undefined;
8408
+
8409
+ if (mouseWasPressed(0))
8119
8410
  {
8120
- // update invisible objects
8121
- for (const c of o.children)
8122
- updateInvisibleObject(c);
8123
- o.updateInvisible();
8411
+ uiSystem.navigationMode = false;
8412
+ uiSystem.navigationObject = undefined;
8124
8413
  }
8125
- function updateObject(o)
8414
+
8415
+ // navigation with gamepad/keyboard
8416
+ const navigableObjects = uiSystem.getNavigableObjects();
8417
+ if (!navigableObjects.length)
8418
+ uiSystem.navigationObject = undefined;
8419
+ else
8126
8420
  {
8127
- if (o.visible)
8421
+ // unselect object if it is no longer navigable
8422
+ if (!navigableObjects.includes(uiSystem.navigationObject))
8423
+ uiSystem.navigationObject = undefined;
8424
+
8425
+ if (!isTouchDevice)
8426
+ if (uiSystem.navigationMode && !uiSystem.navigationObject)
8128
8427
  {
8129
- // set position in parent space
8130
- if (o.parent)
8131
- o.pos = o.localPos.add(o.parent.pos);
8132
- // update in reverse order to detect mouse enter/leave
8133
- for (let i=o.children.length; i--;)
8134
- updateObject(o.children[i]);
8135
- o.update();
8428
+ // select first auto focus object
8429
+ uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
8136
8430
  }
8137
- else
8138
- updateInvisibleObject(o);
8431
+
8432
+ // navigate with dpad or left stick
8433
+ if (!uiSystem.navigationTimer.active())
8434
+ {
8435
+ // navigate through list with gamepad or keyboard
8436
+ const direction = sign(uiSystem.getNavigationDirection());
8437
+ if (direction)
8438
+ {
8439
+ let newNavigationObject;
8440
+ if (!uiSystem.navigationObject)
8441
+ {
8442
+ // use auto select object
8443
+ newNavigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
8444
+
8445
+ if (!newNavigationObject)
8446
+ {
8447
+ // try first or last object
8448
+ const newIndex = direction > 0 ? 0 : navigableObjects.length-1;
8449
+ newNavigationObject = navigableObjects[newIndex];
8450
+ }
8451
+ }
8452
+ else
8453
+ {
8454
+ const currentIndex = navigableObjects.indexOf(uiSystem.navigationObject);
8455
+ const newIndex = mod(currentIndex + direction, navigableObjects.length);
8456
+ newNavigationObject = navigableObjects[newIndex];
8457
+ }
8458
+
8459
+ if (uiSystem.navigationObject !== newNavigationObject)
8460
+ {
8461
+ uiSystem.navigationMode = true;
8462
+ uiSystem.hoverObject = undefined;
8463
+ uiSystem.navigationObject = newNavigationObject;
8464
+ uiSystem.navigationTimer.set(uiSystem.navigationDelay);
8465
+ newNavigationObject.soundPress &&
8466
+ newNavigationObject.soundPress.play();
8467
+ }
8468
+ }
8469
+ }
8470
+
8471
+ // activate the navigation object when pressed
8472
+ if (uiSystem.navigationObject)
8473
+ if (uiSystem.getNavigationWasPressed())
8474
+ uiSystem.navigationObject.navigatePressed();
8139
8475
  }
8140
- // reset hover object at start of update
8141
- uiSystem.lastHoverObject = uiSystem.hoverObject;
8142
- uiSystem.hoverObject = undefined;
8143
8476
 
8144
8477
  // update in reverse order so topmost objects get priority
8145
8478
  for (let i = uiSystem.uiObjects.length; i--;)
8146
8479
  {
8147
8480
  const o = uiSystem.uiObjects[i];
8148
- o.parent || updateObject(o)
8481
+ o.parent || updateObject(o);
8149
8482
  }
8150
8483
 
8151
8484
  // remove destroyed objects
8152
8485
  uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed);
8486
+
8487
+ function updateObject(o)
8488
+ {
8489
+ if (!o.visible) return;
8490
+
8491
+ // update in reverse order to detect mouse enter/leave
8492
+ updateTransforms(o);
8493
+ for (let i=o.children.length; i--;)
8494
+ updateObject(o.children[i]);
8495
+ o.update();
8496
+ }
8153
8497
  }
8154
8498
  function uiRender()
8155
8499
  {
@@ -8166,15 +8510,29 @@ class UISystemPlugin
8166
8510
 
8167
8511
  function renderObject(o)
8168
8512
  {
8169
- if (!o.visible)
8170
- return;
8171
- if (o.parent)
8172
- o.pos = o.localPos.add(o.parent.pos);
8513
+ if (!o.visible) return;
8514
+
8515
+ // render object and children
8516
+ updateTransforms(o);
8173
8517
  o.render();
8174
8518
  for (const c of o.children)
8175
8519
  renderObject(c);
8176
8520
  }
8177
8521
  uiSystem.uiObjects.forEach(o=> o.parent || renderObject(o));
8522
+
8523
+ if (uiDebug > 0)
8524
+ {
8525
+ // debug render all objects
8526
+ function renderDebug(o, visible=true)
8527
+ {
8528
+ visible &&= !!o.visible;
8529
+ updateTransforms(o);
8530
+ o.renderDebug(visible);
8531
+ for (const c of o.children)
8532
+ renderDebug(c, visible);
8533
+ }
8534
+ uiSystem.uiObjects.forEach(o=> o.parent || renderDebug(o));
8535
+ }
8178
8536
  context.restore();
8179
8537
  }
8180
8538
  }
@@ -8227,8 +8585,8 @@ class UISystemPlugin
8227
8585
  else
8228
8586
  context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
8229
8587
  context.fill();
8230
- context.shadowColor = '#0000'
8231
- if (lineWidth)
8588
+ context.shadowColor = '#0000';
8589
+ if (lineWidth && lineColor.a > 0)
8232
8590
  {
8233
8591
  context.strokeStyle = lineColor.toString();
8234
8592
  context.lineWidth = lineWidth;
@@ -8263,10 +8621,24 @@ class UISystemPlugin
8263
8621
  * @param {TileInfo} tileInfo
8264
8622
  * @param {Color} [color=uiSystem.defaultColor]
8265
8623
  * @param {number} [angle]
8266
- * @param {boolean} [mirror] */
8267
- drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false)
8624
+ * @param {boolean} [mirror]
8625
+ * @param {Color} [shadowColor]
8626
+ * @param {number} [shadowBlur]
8627
+ * @param {Color} [shadowOffset] */
8628
+ drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
8268
8629
  {
8269
- drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, uiSystem.uiContext);
8630
+ const context = uiSystem.uiContext;
8631
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
8632
+ if (shadowColor.a > 0)
8633
+ {
8634
+ // setup shadow
8635
+ context.shadowColor = shadowColor.toString();
8636
+ context.shadowBlur = shadowBlur;
8637
+ context.shadowOffsetX = shadowOffset.x;
8638
+ context.shadowOffsetY = shadowOffset.y;
8639
+ }
8640
+ drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, context);
8641
+ context.shadowColor = '#0000';
8270
8642
  }
8271
8643
 
8272
8644
  /** Draw text to the UI context
@@ -8281,12 +8653,27 @@ class UISystemPlugin
8281
8653
  * @param {string} [fontStyle]
8282
8654
  * @param {boolean} [applyMaxWidth=true]
8283
8655
  * @param {Vector2} [textShadow]
8284
- */
8285
- drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined)
8656
+ * @param {Color} [shadowColor]
8657
+ * @param {number} [shadowBlur]
8658
+ * @param {Color} [shadowOffset] */
8659
+ 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())
8286
8660
  {
8287
- if (textShadow)
8288
- drawTextScreen(text, pos.add(textShadow), size.y, BLACK, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
8289
- drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
8661
+ const context = uiSystem.uiContext;
8662
+ if (shadowColor.a > 0)
8663
+ {
8664
+ if (textShadow)
8665
+ drawTextScreen(text, pos.add(textShadow), size.y, shadowColor, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
8666
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
8667
+ {
8668
+ // setup shadow
8669
+ context.shadowColor = shadowColor.toString();
8670
+ context.shadowBlur = shadowBlur;
8671
+ context.shadowOffsetX = shadowOffset.x;
8672
+ context.shadowOffsetY = shadowOffset.y;
8673
+ }
8674
+ }
8675
+ drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
8676
+ context.shadowColor = '#0000';
8290
8677
  }
8291
8678
 
8292
8679
  /**
@@ -8300,7 +8687,7 @@ class UISystemPlugin
8300
8687
  * @param {DragAndDropCallback} [onDrop] - when a file is dropped
8301
8688
  * @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window
8302
8689
  * @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window
8303
- * @param {DragAndDropCallback} [onDragOver] - continously when dragging over */
8690
+ * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
8304
8691
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
8305
8692
  {
8306
8693
  function setCallback(callback, listenerType)
@@ -8316,8 +8703,7 @@ class UISystemPlugin
8316
8703
 
8317
8704
  /** Convert a screen space position to native UI position
8318
8705
  * @param {Vector2} pos
8319
- * @return {Vector2}
8320
- */
8706
+ * @return {Vector2} */
8321
8707
  screenToNative(pos)
8322
8708
  {
8323
8709
  if (!uiSystem.nativeHeight)
@@ -8340,6 +8726,157 @@ class UISystemPlugin
8340
8726
  for (const o of this.uiObjects)
8341
8727
  o.parent || o.destroy();
8342
8728
  this.uiObjects = this.uiObjects.filter(o=>!o.destroyed);
8729
+ this.activeObject = undefined;
8730
+ this.hoverObject = undefined;
8731
+ this.lastHoverObject = undefined;
8732
+ }
8733
+
8734
+ /** Get all navigable UI objects sorted by navigationIndex
8735
+ * @return {Array<UIObject>} */
8736
+ getNavigableObjects()
8737
+ {
8738
+ function getNavigableRecursive(o)
8739
+ {
8740
+ if (!o.visible || o.disabled)
8741
+ return; // skip children if parent is invisible or disabled
8742
+
8743
+ if (o.isInteractive() && o.navigationIndex !== undefined)
8744
+ objects.push(o);
8745
+ for (let i=o.children.length; i--;)
8746
+ getNavigableRecursive(o.children[i]);
8747
+ }
8748
+
8749
+ // get all the valid navigable objects recursively
8750
+ let objects = [];
8751
+ for (let i = uiSystem.uiObjects.length; i--;)
8752
+ {
8753
+ const o = uiSystem.uiObjects[i];
8754
+ if (uiSystem.confirmDialog && o !== uiSystem.confirmDialog)
8755
+ continue;
8756
+ o.parent || getNavigableRecursive(o);
8757
+ }
8758
+
8759
+ // sort by navigationIndex (lower numbers first)
8760
+ objects.sort((a, b)=> a.navigationIndex - b.navigationIndex);
8761
+ return objects;
8762
+ }
8763
+
8764
+ /** Get navigation direction from gamepad or keyboard
8765
+ * @return {number} */
8766
+ getNavigationDirection()
8767
+ {
8768
+ const vertical = uiSystem.navigationDirection === 1;
8769
+ const both = uiSystem.navigationDirection === 2;
8770
+ if (isUsingGamepad)
8771
+ {
8772
+ const stick = gamepadStick(0, gamepadPrimary);
8773
+ const dpad = gamepadDpad(gamepadPrimary);
8774
+ if (both)
8775
+ return -(stick.y || dpad.y) || (stick.x || dpad.x);
8776
+ return vertical ? -(stick.y || dpad.y) : (stick.x || dpad.x);
8777
+ }
8778
+ const up = 'ArrowUp', down = 'ArrowDown', left = 'ArrowLeft', right = 'ArrowRight';
8779
+ if (both)
8780
+ {
8781
+ return keyIsDown(up) || keyIsDown(left) ? -1 :
8782
+ keyIsDown(down) || keyIsDown(right) ? 1 : 0;
8783
+ }
8784
+ const back = vertical ? up : left;
8785
+ const forward = vertical ? down : right;
8786
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
8787
+ }
8788
+
8789
+ /** Get other axis navigation direction from gamepad or keyboard
8790
+ * @return {Vector2} */
8791
+ getNavigationOtherDirection()
8792
+ {
8793
+ if (uiSystem.navigationDirection === 2)
8794
+ return 0; // other direction disabled
8795
+
8796
+ const vertical = uiSystem.navigationDirection === 1;
8797
+ if (isUsingGamepad)
8798
+ {
8799
+ const stick = gamepadStick(0, gamepadPrimary);
8800
+ const dpad = gamepadDpad(gamepadPrimary);
8801
+ return !vertical ? (stick.y || dpad.y) : (stick.x || dpad.x);
8802
+ }
8803
+ const back = !vertical ? 'ArrowUp' : 'ArrowLeft';
8804
+ const forward = !vertical ? 'ArrowDown' : 'ArrowRight';
8805
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
8806
+ }
8807
+
8808
+ /** Get if navigation button was pressed from gamepad or keyboard
8809
+ * @return {boolean} */
8810
+ getNavigationWasPressed()
8811
+ {
8812
+ return isUsingGamepad ? gamepadWasPressed(0, gamepadPrimary) :
8813
+ keyWasPressed('Space') || keyWasPressed('Enter');
8814
+ }
8815
+
8816
+ /** Show a confirmation dialog with Yes/No buttons
8817
+ * Centers the dialog on the screen with darkened background
8818
+ * @param {string} [text] - The message to display
8819
+ * @param {Function} [yesCallback] - Called when Yes is clicked
8820
+ * @param {Function} [noCallback] - Called when No is clicked
8821
+ * @param {Vector2} [size] - Size of the confirmation dialog
8822
+ * @param {string} [exitKey] - Key that can exit the menu
8823
+ * @return {UIObject} The confirmation menu object
8824
+ */
8825
+ showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
8826
+ {
8827
+ ASSERT(!uiSystem.confirmDialog);
8828
+
8829
+ const savedNavigationDirection = uiSystem.navigationDirection;
8830
+
8831
+ // allow both axies for navigation
8832
+ uiSystem.navigationDirection = 2;
8833
+
8834
+ // confirm menu
8835
+ const confirmMenu = new UIObject(vec2(), size);
8836
+ uiSystem.confirmDialog = confirmMenu;
8837
+ confirmMenu.onRender = ()=>
8838
+ {
8839
+ confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
8840
+ const backgroundColor = hsl(0,0,0,.7);
8841
+ uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
8842
+ }
8843
+ confirmMenu.onUpdate = ()=>
8844
+ {
8845
+ if (keyWasPressed(exitKey))
8846
+ closeMenu();
8847
+ }
8848
+ confirmMenu.isMouseOverlapping = ()=> true; // always hover
8849
+
8850
+ // title text
8851
+ const gap = 50;
8852
+ const textTitle = new UIText(vec2(0,-50), vec2(size.x-gap,70), text);
8853
+ confirmMenu.addChild(textTitle);
8854
+
8855
+ // yes button
8856
+ const buttonYes = new UIButton(vec2(-80,50), vec2(120,70), 'Yes');
8857
+ buttonYes.textHeight = 40;
8858
+ buttonYes.navigationIndex = 1;
8859
+ buttonYes.hoverColor = hsl(0,1,.5);
8860
+ buttonYes.onClick = ()=> { closeMenu(); yesCallback && yesCallback(); };
8861
+ confirmMenu.addChild(buttonYes);
8862
+
8863
+ // no button
8864
+ const buttonNo = new UIButton(vec2(80,50), vec2(120,70), 'No');
8865
+ buttonNo.textHeight = 40;
8866
+ buttonNo.navigationIndex = 2;
8867
+ buttonNo.navigationAutoSelect = true;
8868
+ buttonNo.onClick = ()=> { closeMenu(); noCallback && noCallback(); };
8869
+ confirmMenu.addChild(buttonNo);
8870
+
8871
+ // close menu and return to normal navigation
8872
+ function closeMenu()
8873
+ {
8874
+ ASSERT(uiSystem.confirmDialog === confirmMenu);
8875
+ confirmMenu.destroy();
8876
+ uiSystem.confirmDialog = undefined;
8877
+ uiSystem.navigationDirection = savedNavigationDirection;
8878
+ inputClear();
8879
+ }
8343
8880
  }
8344
8881
  }
8345
8882
 
@@ -8395,7 +8932,13 @@ class UIObject
8395
8932
  /** @property {number} - Override for text height */
8396
8933
  this.textHeight = undefined;
8397
8934
  /** @property {number} - Scale text to fit in the object */
8398
- this.textScale = uiSystem.defaultTextScale;
8935
+ this.textFitScale = uiSystem.defaultTextFitScale;
8936
+ /** @property {Vector2} - How much to offset the text shadow or undefined */
8937
+ this.textShadow = undefined;
8938
+ /** @property {number} - Color for text line drawing */
8939
+ this.textLineColor = uiSystem.defaultLineColor.copy();
8940
+ /** @property {number} - Width for text line drawing */
8941
+ this.textLineWidth = 0;
8399
8942
  /** @property {boolean} - Should this object be drawn */
8400
8943
  this.visible = true;
8401
8944
  /** @property {Array<UIObject>} - A list of this object's children */
@@ -8421,16 +8964,17 @@ class UIObject
8421
8964
  /** @property {number} - Size of shadow blur */
8422
8965
  this.shadowBlur = uiSystem.defaultShadowBlur;
8423
8966
  /** @property {Vector2} - Offset of shadow blur */
8424
- this.shadowOffset = uiSystem.defaultShadowOffset.copy();
8425
- uiSystem.uiObjects.push(this);
8967
+ this.shadowOffset = uiSystem.defaultShadowOffset?.copy();
8968
+ /** @property {number} - Optional navigation order index, lower values are selected first */
8969
+ this.navigationIndex = undefined;
8970
+ /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
8971
+ this.navigationAutoSelect = false;
8426
8972
 
8427
- /** @property {Vector2} - How much to offset the text shadow or undefined */
8428
- this.textShadow = undefined;
8973
+ uiSystem.uiObjects.push(this);
8429
8974
  }
8430
8975
 
8431
8976
  /** Add a child UIObject to this object
8432
- * @param {UIObject} child
8433
- */
8977
+ * @param {UIObject} child */
8434
8978
  addChild(child)
8435
8979
  {
8436
8980
  ASSERT(!child.parent && !this.children.includes(child));
@@ -8439,8 +8983,7 @@ class UIObject
8439
8983
  }
8440
8984
 
8441
8985
  /** Remove a child UIObject from this object
8442
- * @param {UIObject} child
8443
- */
8986
+ * @param {UIObject} child */
8444
8987
  removeChild(child)
8445
8988
  {
8446
8989
  ASSERT(child.parent === this && this.children.includes(child));
@@ -8448,7 +8991,6 @@ class UIObject
8448
8991
  child.parent = undefined;
8449
8992
  }
8450
8993
 
8451
-
8452
8994
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
8453
8995
  destroy()
8454
8996
  {
@@ -8464,9 +9006,9 @@ class UIObject
8464
9006
  child.destroy();
8465
9007
  }
8466
9008
  }
9009
+
8467
9010
  /** Check if the mouse is overlapping a box in screen space
8468
- * @return {boolean} - True if overlapping
8469
- */
9011
+ * @return {boolean} - True if overlapping */
8470
9012
  isMouseOverlapping()
8471
9013
  {
8472
9014
  if (!mouseInWindow) return false;
@@ -8483,11 +9025,16 @@ class UIObject
8483
9025
  // call the custom update callback
8484
9026
  this.onUpdate();
8485
9027
 
9028
+ // unset active if disabled
9029
+ if (this.disabled && this == uiSystem.activeObject)
9030
+ uiSystem.activeObject = undefined;
9031
+
8486
9032
  const wasHover = uiSystem.lastHoverObject === this;
8487
9033
  const isActive = this.isActiveObject();
8488
9034
  const mouseDown = mouseIsDown(0);
8489
9035
  const mousePress = this.dragActivate ? mouseDown : mouseWasPressed(0);
8490
9036
  if (this.canBeHover)
9037
+ if (!uiSystem.navigationMode) // no mouse hover in navigation mode
8491
9038
  if (mousePress || isActive || (!mouseDown && !isTouchDevice))
8492
9039
  if (!uiSystem.hoverObject && this.isMouseOverlapping())
8493
9040
  uiSystem.hoverObject = this;
@@ -8501,8 +9048,7 @@ class UIObject
8501
9048
  {
8502
9049
  if (!this.dragActivate || (!wasHover || mouseWasPressed(0)))
8503
9050
  this.onPress();
8504
- if (this.soundPress)
8505
- this.soundPress.play();
9051
+ this.soundPress && this.soundPress.play();
8506
9052
  if (uiSystem.activeObject && !isActive)
8507
9053
  uiSystem.activeObject.onRelease();
8508
9054
  uiSystem.activeObject = this;
@@ -8511,10 +9057,10 @@ class UIObject
8511
9057
  if (!mouseDown && this.isActiveObject() && this.interactive)
8512
9058
  {
8513
9059
  this.onClick();
8514
- if (this.soundClick)
8515
- this.soundClick.play();
9060
+ this.soundClick && this.soundClick.play();
8516
9061
  }
8517
9062
  }
9063
+
8518
9064
  // clear mouse was pressed state even when disabled
8519
9065
  mousePress && inputClearKey(0,0,0,1,0);
8520
9066
  }
@@ -8522,8 +9068,7 @@ class UIObject
8522
9068
  if (!mouseDown || (this.dragActivate && !this.isHoverObject()))
8523
9069
  {
8524
9070
  this.onRelease();
8525
- if (this.soundRelease)
8526
- this.soundRelease.play();
9071
+ this.soundRelease && this.soundRelease.play();
8527
9072
  uiSystem.activeObject = undefined;
8528
9073
  }
8529
9074
 
@@ -8535,29 +9080,40 @@ class UIObject
8535
9080
  /** Render the object, called automatically by plugin once each frame */
8536
9081
  render()
8537
9082
  {
8538
- if (!this.size.x || !this.size.y) return;
9083
+ // call the custom render callback
9084
+ this.onRender();
8539
9085
 
8540
- const lineColor = this.interactive && this.isActiveObject() && !this.disabled ? this.color : this.lineColor;
8541
- const color = this.disabled ? this.disabledColor : this.interactive ? this.isActiveObject() ? this.activeColor || this.color : this.isHoverObject() ? this.hoverColor : this.color : this.color;
8542
- uiSystem.drawRect(this.pos, this.size, color, this.lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
8543
- }
9086
+ if (!this.size.x || !this.size.y) return;
8544
9087
 
8545
- /** Special update when object is not visible */
8546
- updateInvisible()
8547
- {
8548
- // reset input state when not visible
8549
- if (this.isActiveObject())
8550
- uiSystem.activeObject = undefined;
9088
+ const isNavigationObject = this.isNavigationObject();
9089
+ const lineColor = isNavigationObject ? this.color :
9090
+ this.interactive && this.isActiveObject() && !this.disabled ?
9091
+ this.color : this.lineColor;
9092
+ const color = isNavigationObject ? this.hoverColor :
9093
+ this.disabled ? this.disabledColor :
9094
+ this.interactive ?
9095
+ this.isHoverObject() ? this.hoverColor :
9096
+ this.isActiveObject() ? this.activeColor || this.color :
9097
+ this.color : this.color;
9098
+ const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
9099
+
9100
+ uiSystem.drawRect(this.pos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
8551
9101
  }
8552
9102
 
8553
9103
  /** Get the size for text with overrides and scale
8554
- * @return {Vector2}
8555
- */
9104
+ * @return {Vector2} */
8556
9105
  getTextSize()
8557
9106
  {
8558
9107
  return vec2(
8559
- this.textWidth || this.textScale * this.size.x,
8560
- this.textHeight || this.textScale * this.size.y);
9108
+ this.textWidth || this.textFitScale * this.size.x,
9109
+ this.textHeight || this.textFitScale * this.size.y);
9110
+ }
9111
+
9112
+ /** Called when the navigation button is pressed on this object */
9113
+ navigatePressed()
9114
+ {
9115
+ this.onClick();
9116
+ this.soundClick && this.soundClick.play();
8561
9117
  }
8562
9118
 
8563
9119
  /** @return {boolean} - Is the mouse hovering over this element */
@@ -8566,9 +9122,51 @@ class UIObject
8566
9122
  /** @return {boolean} - Is the mouse held onto this element */
8567
9123
  isActiveObject() { return uiSystem.activeObject === this; }
8568
9124
 
8569
- /** Called each frame when object updates */
9125
+ /** @return {boolean} - Is the gamepad or keyboard navigation object */
9126
+ isNavigationObject() { return uiSystem.navigationObject === this; }
9127
+
9128
+ /** @return {boolean} - Can it be interacted with */
9129
+ isInteractive() { return this.interactive && this.visible && !this.disabled;}
9130
+
9131
+ /** Returns string containing info about this object for debugging
9132
+ * @return {string} */
9133
+ toString()
9134
+ {
9135
+ if (!debug) return;
9136
+
9137
+ let text = 'type = ' + this.constructor.name;
9138
+ if (this.text)
9139
+ text += '\ntext = ' + this.text;
9140
+ if (this.pos.x || this.pos.y)
9141
+ text += '\npos = ' + this.pos;
9142
+ if (this.localPos.x || this.localPos.y)
9143
+ text += '\localPos = ' + this.localPos;
9144
+ if (this.size.x || this.size.y)
9145
+ text += '\nsize = ' + this.size;
9146
+ if (this.color)
9147
+ text += '\ncolor = ' + this.color;
9148
+ return text;
9149
+ }
9150
+
9151
+ /** Called if uiDebug is enabled
9152
+ * @param {boolean} visible */
9153
+ renderDebug(visible=true)
9154
+ {
9155
+ // apply color based on state
9156
+ const color =
9157
+ !visible ? GREEN :
9158
+ this.isHoverObject() ? YELLOW :
9159
+ this.disabled ? PURPLE :
9160
+ this.interactive ? RED : BLUE;
9161
+ uiSystem.drawRect(this.pos, this.size, CLEAR_BLACK, 4, color);
9162
+ }
9163
+
9164
+ /** Called each frame before object updates */
8570
9165
  onUpdate() {}
8571
9166
 
9167
+ /** Called each frame before object renders */
9168
+ onRender() {}
9169
+
8572
9170
  /** Called when the mouse enters the object */
8573
9171
  onEnter() {}
8574
9172
 
@@ -8616,16 +9214,25 @@ class UIText extends UIObject
8616
9214
  this.align = align;
8617
9215
  this.font = font;
8618
9216
 
8619
- // make text not outlined by default
8620
- this.lineWidth = 0;
8621
9217
  // text can not be a hover object by default
8622
9218
  this.canBeHover = false;
9219
+
9220
+ // no background by default
9221
+ this.color = CLEAR_BLACK;
9222
+ this.shadowColor = CLEAR_BLACK;
9223
+ this.gradientColor = undefined;
9224
+ this.lineWidth = 0;
9225
+
9226
+ // use max fit scale by default
9227
+ this.textFitScale = 1;
8623
9228
  }
8624
9229
  render()
8625
9230
  {
8626
- // only render the text
9231
+ super.render();
9232
+
9233
+ // render the text
8627
9234
  const textSize = this.getTextSize();
8628
- uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.lineWidth, this.lineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
9235
+ uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow, this.shadowColor, this.shadowBlur, this.shadowOffset);
8629
9236
  }
8630
9237
  }
8631
9238
 
@@ -8661,10 +9268,13 @@ class UITile extends UIObject
8661
9268
  this.mirror = mirror;
8662
9269
  // set properties
8663
9270
  this.color = color.copy();
9271
+
9272
+ // no shadow by default
9273
+ this.shadowColor = CLEAR_BLACK;
8664
9274
  }
8665
9275
  render()
8666
9276
  {
8667
- uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror);
9277
+ uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
8668
9278
  }
8669
9279
  }
8670
9280
 
@@ -8689,6 +9299,9 @@ class UIButton extends UIObject
8689
9299
  ASSERT(isString(text), 'ui button must be a string');
8690
9300
  ASSERT(isColor(color), 'ui button color must be a color');
8691
9301
 
9302
+ /** @property {Vector2} - Text offset for the button */
9303
+ this.textOffset = vec2();
9304
+
8692
9305
  // set properties
8693
9306
  this.text = text;
8694
9307
  this.color = color.copy();
@@ -8700,8 +9313,8 @@ class UIButton extends UIObject
8700
9313
 
8701
9314
  // draw the text scaled to fit
8702
9315
  const textSize = this.getTextSize();
8703
- uiSystem.drawText(this.text, this.pos, textSize,
8704
- this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
9316
+ uiSystem.drawText(this.text, this.pos.add(this.textOffset), textSize,
9317
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
8705
9318
  }
8706
9319
  }
8707
9320
 
@@ -8755,7 +9368,7 @@ class UICheckbox extends UIObject
8755
9368
  const textSize = this.getTextSize();
8756
9369
  const pos = this.pos.add(vec2(this.size.x,0));
8757
9370
  uiSystem.drawText(this.text, pos, textSize,
8758
- this.textColor, 0, undefined, 'left', this.font, this.fontStyle, false, this.textShadow);
9371
+ this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
8759
9372
  }
8760
9373
  }
8761
9374
 
@@ -8797,7 +9410,11 @@ class UIScrollbar extends UIObject
8797
9410
  update()
8798
9411
  {
8799
9412
  super.update();
8800
- if (this.isActiveObject() && this.interactive)
9413
+ if (!this.interactive)
9414
+ return;
9415
+
9416
+ const oldValue = this.value;
9417
+ if (this.isActiveObject())
8801
9418
  {
8802
9419
  // handle horizontal or vertical scrollbar
8803
9420
  const isHorizontal = this.size.x > this.size.y;
@@ -8809,14 +9426,19 @@ class UIScrollbar extends UIObject
8809
9426
  const handleWidth = barSize - handleSize;
8810
9427
  const p1 = centerPos - handleWidth/2;
8811
9428
  const p2 = centerPos + handleWidth/2;
8812
- const oldValue = this.value;
8813
-
8814
9429
  const p = uiSystem.screenToNative(mousePosScreen);
8815
9430
  this.value = isHorizontal ?
8816
9431
  percent(p.x, p1, p2) :
8817
9432
  percent(p.y, p2, p1);
8818
- this.value === oldValue || this.onChange();
8819
9433
  }
9434
+ else if (this.isNavigationObject())
9435
+ {
9436
+ // gamepad/keyboard navigation adjustment
9437
+ const direction = uiSystem.getNavigationOtherDirection();
9438
+ if (!uiSystem.navigationTimer.active())
9439
+ this.value = clamp(this.value + direction*.01);
9440
+ }
9441
+ this.value === oldValue || this.onChange();
8820
9442
  }
8821
9443
  render()
8822
9444
  {
@@ -8841,7 +9463,14 @@ class UIScrollbar extends UIObject
8841
9463
  // draw the text scaled to fit on the scrollbar
8842
9464
  const textSize = this.getTextSize();
8843
9465
  uiSystem.drawText(this.text, this.pos, textSize,
8844
- this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
9466
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
9467
+ }
9468
+ navigatePressed()
9469
+ {
9470
+ // toggle value between 0 and 1
9471
+ this.value = this.value ? 0 : 1;
9472
+ this.onRelease();
9473
+ super.navigatePressed();
8845
9474
  }
8846
9475
  }
8847
9476
 
@@ -8875,7 +9504,7 @@ class UIVideo extends UIObject
8875
9504
  this.color = BLACK; // default to black background
8876
9505
  this.cornerRadius = 0; // default to no corner radius
8877
9506
 
8878
- /** @property {float} - The video volume */
9507
+ /** @property {number} - The video volume */
8879
9508
  this.volume = volume;
8880
9509
 
8881
9510
  // create video element
@@ -8908,7 +9537,7 @@ class UIVideo extends UIObject
8908
9537
 
8909
9538
  /** Check if video is currently loading
8910
9539
  * @return {boolean} */
8911
- isLoadng()
9540
+ isLoading()
8912
9541
  { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
8913
9542
 
8914
9543
  /** Check if video is currently paused
@@ -8918,7 +9547,7 @@ class UIVideo extends UIObject
8918
9547
  /** Check if video is currently playing
8919
9548
  * @return {boolean} */
8920
9549
  isPlaying()
8921
- { return !this.isPaused() && !this.hasEnded() && !this.isLoadng(); }
9550
+ { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); }
8922
9551
 
8923
9552
  /** Check if video has ended playing
8924
9553
  * @return {boolean} */
@@ -8967,7 +9596,7 @@ class UIVideo extends UIObject
8967
9596
  {
8968
9597
  super.render();
8969
9598
 
8970
- if (this.isLoadng())
9599
+ if (this.isLoading())
8971
9600
  return;
8972
9601
  const context = uiSystem.uiContext;
8973
9602
  const s = this.size;
@@ -10751,7 +11380,7 @@ class Box2dPlugin
10751
11380
  * @param {Vector2} v */
10752
11381
  vec2dTo(v)
10753
11382
  {
10754
- ASSERT(v instanceof Vector2);
11383
+ ASSERT(isVector2(v));
10755
11384
  return new box2d.instance.b2Vec2(v.x, v.y);
10756
11385
  }
10757
11386