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 Debug System
@@ -914,7 +936,7 @@ function debugOverlap(posA, sizeA, posB, sizeB, color, time, screenSpace=false)
914
936
  }
915
937
 
916
938
  /** Draw a debug axis aligned bounding box in world space
917
- * @param {string} text
939
+ * @param {string|number} text
918
940
  * @param {Vector2} pos
919
941
  * @param {number} [size]
920
942
  * @param {Color|string} [color]
@@ -1072,34 +1094,46 @@ function debugRender()
1072
1094
  debugTakeScreenshot = 0;
1073
1095
  }
1074
1096
 
1075
- if (debugGamepads && gamepadsEnable && navigator.getGamepads)
1097
+ if (debugGamepads && gamepadsEnable)
1076
1098
  {
1077
- // gamepad debug display
1078
- const gamepads = navigator.getGamepads();
1079
- for (let i = gamepads.length; i--;)
1099
+ // draw gamepads
1100
+ const maxGamepads = 8;
1101
+ let gamepadConnectedCount = 0;
1102
+ for (let i = 0; i < maxGamepads; i++)
1103
+ gamepadConnected(i) && gamepadConnectedCount++;
1104
+
1105
+ for (let i = 0; i < maxGamepads; i++)
1080
1106
  {
1081
- const gamepad = gamepads[i];
1082
- if (gamepad)
1107
+ if (!gamepadConnected(i))
1108
+ continue;
1109
+
1110
+ const stickScale = 1;
1111
+ const buttonScale = .2;
1112
+ const cornerPos = cameraPos.add(vec2(-stickScale*2, ((gamepadConnectedCount-1)/2-i)*stickScale*3));
1113
+ debugText(i, cornerPos.add(vec2(-stickScale, stickScale)), 1);
1114
+ if (i === gamepadPrimary)
1115
+ debugText('Main', cornerPos.add(vec2(-stickScale*2, 0)),1, '#0f0');
1116
+
1117
+ // read analog sticks
1118
+ const stickCount = gamepadStickData[i].length;
1119
+ for (let j = 0; j < stickCount; j++)
1083
1120
  {
1084
- const stickScale = 1;
1085
- const buttonScale = .2;
1086
- const centerPos = cameraPos;
1087
- const sticks = gamepadStickData[i];
1088
- for (let j = sticks.length; j--;)
1089
- {
1090
- const drawPos = centerPos.add(vec2(j*stickScale*2, i*stickScale*3));
1091
- const stickPos = drawPos.add(sticks[j].scale(stickScale));
1092
- debugCircle(drawPos, stickScale*2, '#fff7',0,true);
1093
- debugLine(drawPos, stickPos, '#f00');
1094
- debugPoint(stickPos, '#f00');
1095
- }
1096
- for (let j = gamepad.buttons.length; j--;)
1097
- {
1098
- const drawPos = centerPos.add(vec2(j*buttonScale*2, i*stickScale*3-stickScale-buttonScale));
1099
- const pressed = gamepad.buttons[j].pressed;
1100
- debugCircle(drawPos, buttonScale*2, pressed ? '#f00' : '#fff7', 0, true);
1101
- debugText(''+j, drawPos, .2);
1102
- }
1121
+ const stick = gamepadStick(j, i);
1122
+ const drawPos = cornerPos.add(vec2(j*stickScale*2, 0));
1123
+ const stickPos = drawPos.add(stick.scale(stickScale));
1124
+ debugCircle(drawPos, stickScale*2, '#fff7',0,true);
1125
+ debugLine(drawPos, stickPos, '#f00');
1126
+ debugText(j, drawPos, .3);
1127
+ debugPoint(stickPos, '#f00');
1128
+ }
1129
+
1130
+ const buttonCount = inputData[i+1].length;
1131
+ for (let j = 0; j < buttonCount; j++)
1132
+ {
1133
+ const drawPos = cornerPos.add(vec2(j*buttonScale*2, -stickScale-buttonScale*2));
1134
+ const pressed = gamepadIsDown(j, i);
1135
+ debugCircle(drawPos, buttonScale*2, pressed ? '#f00' : '#fff7', 0, true);
1136
+ debugText(j, drawPos, .3);
1103
1137
  }
1104
1138
  }
1105
1139
  }
@@ -1281,14 +1315,18 @@ function debugRender()
1281
1315
  mousePressed && overlayContext.fillText('Mouse: ' + mousePressed, x, y += h);
1282
1316
  keysPressed && overlayContext.fillText('Keys: ' + keysPressed, x, y += h);
1283
1317
 
1284
- let buttonsPressed = '';
1285
- if (inputData[1])
1286
- for (const i in inputData[1])
1318
+ // show gamepad buttons
1319
+ for (let i = 1; i < inputData.length; i++)
1287
1320
  {
1288
- if (keyIsDown(i, 1))
1289
- buttonsPressed += i + ' ' ;
1321
+ let buttonsPressed = '';
1322
+ if (inputData[i])
1323
+ for (const j in inputData[i])
1324
+ {
1325
+ if (keyIsDown(j, i))
1326
+ buttonsPressed += j + ' ' ;
1327
+ }
1328
+ buttonsPressed && overlayContext.fillText(`Gamepad ${i-1}: ` + buttonsPressed, x, y += h);
1290
1329
  }
1291
- buttonsPressed && overlayContext.fillText('Gamepad: ' + buttonsPressed, x, y += h);
1292
1330
  }
1293
1331
  else
1294
1332
  {
@@ -2044,9 +2082,15 @@ class Vector2
2044
2082
  {
2045
2083
  this.x = x;
2046
2084
  this.y = y;
2085
+ ASSERT_VECTOR2_VALID(this);
2047
2086
  return this;
2048
2087
  }
2049
2088
 
2089
+ /** Sets this vector from another vector and returns self
2090
+ * @param {Vector2} v - other vector
2091
+ * @return {Vector2} */
2092
+ setFrom(v) { return this.set(v.x, v.y); }
2093
+
2050
2094
  /** Returns a new vector that is a copy of this
2051
2095
  * @return {Vector2} */
2052
2096
  copy() { return new Vector2(this.x, this.y); }
@@ -2109,7 +2153,7 @@ class Vector2
2109
2153
  clampLength(length=1)
2110
2154
  {
2111
2155
  const l = this.length();
2112
- return l > length ? this.scale(length/l) : this;
2156
+ return l > length ? this.scale(length/l) : this.copy();
2113
2157
  }
2114
2158
 
2115
2159
  /** Returns the dot product of this and the vector passed in
@@ -2196,6 +2240,10 @@ class Vector2
2196
2240
  * @return {number} */
2197
2241
  area() { return abs(this.x * this.y); }
2198
2242
 
2243
+ /** Returns true if this vector is (0,0)
2244
+ * @return {boolean} */
2245
+ isZero() { return !this.x && !this.y; }
2246
+
2199
2247
  /** Returns a new vector that is p percent between this and the vector passed in
2200
2248
  * @param {Vector2} v - other vector
2201
2249
  * @param {number} percent
@@ -2306,9 +2354,15 @@ class Color
2306
2354
  this.g = g;
2307
2355
  this.b = b;
2308
2356
  this.a = a;
2357
+ ASSERT_COLOR_VALID(this);
2309
2358
  return this;
2310
2359
  }
2311
2360
 
2361
+ /** Sets this color from another color and returns self
2362
+ * @param {Color} c - other color
2363
+ * @return {Color} */
2364
+ setFrom(c) { return this.set(c.r, c.g, c.b, c.a); }
2365
+
2312
2366
  /** Returns a new color that is a copy of this
2313
2367
  * @return {Color} */
2314
2368
  copy() { return new Color(this.r, this.g, this.b, this.a); }
@@ -2684,6 +2738,20 @@ let canvasClearColor = CLEAR_BLACK;
2684
2738
  * @memberof Settings */
2685
2739
  let canvasMaxSize = vec2(1920, 1080);
2686
2740
 
2741
+ /** Minimum aspect ratio of the canvas (width/height), unused if 0
2742
+ * Can be used with canvasMaxAspect to limit aspect ratio
2743
+ * @type {number}
2744
+ * @default
2745
+ * @memberof Settings */
2746
+ let canvasMinAspect = 0;
2747
+
2748
+ /** Maximum aspect ratio of the canvas (width/height), unused if 0
2749
+ * Can be used with canvasMinAspect to limit aspect ratio
2750
+ * @type {number}
2751
+ * @default
2752
+ * @memberof Settings */
2753
+ let canvasMaxAspect = 0;
2754
+
2687
2755
  /** Fixed size of the canvas, if enabled canvas size never changes
2688
2756
  * - you may also need to set mainCanvasSize if using screen space coords in startup
2689
2757
  * @type {Vector2}
@@ -2860,17 +2928,17 @@ let touchGamepadEnable = false;
2860
2928
  * @memberof Settings */
2861
2929
  let touchGamepadCenterButton = true;
2862
2930
 
2863
- /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2864
- * @type {boolean}
2931
+ /** Number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
2932
+ * @type {number}
2865
2933
  * @default
2866
2934
  * @memberof Settings */
2867
- let touchGamepadAnalog = true;
2935
+ let touchGamepadButtonCount = 4;
2868
2936
 
2869
- /** Number of buttons on touch gamepad
2870
- * @type {number}
2937
+ /** True if touch gamepad should be analog stick or false to use if 8 way dpad
2938
+ * @type {boolean}
2871
2939
  * @default
2872
2940
  * @memberof Settings */
2873
- let touchGamepadButtonCount = 4;
2941
+ let touchGamepadAnalog = true;
2874
2942
 
2875
2943
  /** Size of virtual gamepad for touch devices in pixels
2876
2944
  * @type {number}
@@ -2980,6 +3048,16 @@ function setCanvasClearColor(color) { canvasClearColor = color.copy(); }
2980
3048
  * @memberof Settings */
2981
3049
  function setCanvasMaxSize(size) { canvasMaxSize = size.copy(); }
2982
3050
 
3051
+ /** Set minimum aspect ratio of the canvas (width/height), unused if 0
3052
+ * @param {number} aspect
3053
+ * @memberof Settings */
3054
+ function setCanvasMinAspect(aspect) { canvasMinAspect = aspect; }
3055
+
3056
+ /** Set maximum aspect ratio of the canvas (width/height), unused if 0
3057
+ * @param {number} aspect
3058
+ * @memberof Settings */
3059
+ function setCanvasMaxAspect(aspect) { canvasMaxAspect = aspect; }
3060
+
2983
3061
  /** Set fixed size of the canvas
2984
3062
  * @param {Vector2} size
2985
3063
  * @memberof Settings */
@@ -3136,6 +3214,11 @@ function setTouchGamepadEnable(enable) { touchGamepadEnable = enable; }
3136
3214
  * @memberof Settings */
3137
3215
  function setTouchGamepadCenterButton(enable) { touchGamepadCenterButton = enable; }
3138
3216
 
3217
+ /** Set number of buttons on touch gamepad (0-4), if 1 also acts as right analog stick
3218
+ * @param {number} count
3219
+ * @memberof Settings */
3220
+ function setTouchGamepadButtonCount(count) { touchGamepadButtonCount = count; }
3221
+
3139
3222
  /** Set if touch gamepad should be analog stick or 8 way dpad
3140
3223
  * @param {boolean} analog
3141
3224
  * @memberof Settings */
@@ -3344,7 +3427,7 @@ class EngineObject
3344
3427
  child.updateTransforms();
3345
3428
  }
3346
3429
 
3347
- /** Update the object physics, called automatically by engine once each frame */
3430
+ /** Update the object physics, called automatically by engine once each frame. Can be overridden to stop or change how physics works for an object. */
3348
3431
  updatePhysics()
3349
3432
  {
3350
3433
  // child objects do not have physics
@@ -3377,7 +3460,7 @@ class EngineObject
3377
3460
  if (!enablePhysicsSolver || !this.mass) // don't do collision for static objects
3378
3461
  return;
3379
3462
 
3380
- const wasMovingDown = this.velocity.y < 0;
3463
+ const wasFalling = this.velocity.y < 0 && gravity.y < 0 || this.velocity.y > 0 && gravity.y > 0;
3381
3464
  if (this.groundObject)
3382
3465
  {
3383
3466
  // apply friction in local space of ground object
@@ -3433,10 +3516,10 @@ class EngineObject
3433
3516
  {
3434
3517
  // push outside object collision
3435
3518
  this.pos.y = o.pos.y + (sizeBoth.y/2 + epsilon) * sign(oldPos.y - o.pos.y);
3436
- if ((o.groundObject && wasMovingDown) || !o.mass)
3519
+ if ((o.groundObject && wasFalling) || !o.mass)
3437
3520
  {
3438
3521
  // set ground object if landed on something
3439
- if (wasMovingDown)
3522
+ if (wasFalling)
3440
3523
  this.groundObject = o;
3441
3524
 
3442
3525
  // bounce if other object is fixed or grounded
@@ -3523,12 +3606,15 @@ class EngineObject
3523
3606
  const restitution = max(this.restitution, hitLayer.restitution);
3524
3607
  this.velocity.y *= -restitution;
3525
3608
 
3526
- if (wasMovingDown)
3609
+ if (wasFalling)
3527
3610
  {
3528
- // adjust position to slightly above nearest tile boundary
3611
+ // adjust position to slightly away from nearest tile
3529
3612
  // this prevents gap between object and ground
3530
3613
  const epsilon = .0001;
3531
- this.pos.y = (oldPos.y-this.size.y/2|0)+this.size.y/2+epsilon;
3614
+ const offset = this.size.y/2 + epsilon;
3615
+ this.pos.y = gravity.y < 0 ?
3616
+ floor(oldPos.y-this.size.y/2) + offset :
3617
+ ceil( oldPos.y+this.size.y/2) - offset;
3532
3618
 
3533
3619
  // set ground object for tile collision
3534
3620
  this.groundObject = hitLayer;
@@ -3697,21 +3783,20 @@ class EngineObject
3697
3783
  * @return {string} */
3698
3784
  toString()
3699
3785
  {
3700
- if (debug)
3701
- {
3702
- let text = 'type = ' + this.constructor.name;
3703
- if (this.pos.x || this.pos.y)
3704
- text += '\npos = ' + this.pos;
3705
- if (this.velocity.x || this.velocity.y)
3706
- text += '\nvelocity = ' + this.velocity;
3707
- if (this.size.x || this.size.y)
3708
- text += '\nsize = ' + this.size;
3709
- if (this.angle)
3710
- text += '\nangle = ' + this.angle.toFixed(3);
3711
- if (this.color)
3712
- text += '\ncolor = ' + this.color;
3713
- return text;
3714
- }
3786
+ if (!debug) return;
3787
+
3788
+ let text = 'type = ' + this.constructor.name;
3789
+ if (this.pos.x || this.pos.y)
3790
+ text += '\npos = ' + this.pos;
3791
+ if (this.velocity.x || this.velocity.y)
3792
+ text += '\nvelocity = ' + this.velocity;
3793
+ if (this.size.x || this.size.y)
3794
+ text += '\nsize = ' + this.size;
3795
+ if (this.angle)
3796
+ text += '\nangle = ' + this.angle.toFixed(3);
3797
+ if (this.color)
3798
+ text += '\ncolor = ' + this.color;
3799
+ return text;
3715
3800
  }
3716
3801
 
3717
3802
  /** Render debug info for this object */
@@ -3880,7 +3965,7 @@ class TileInfo
3880
3965
  this.padding = padding;
3881
3966
  /** @property {TextureInfo} - The texture info for this tile */
3882
3967
  this.textureInfo = textureInfos[this.textureIndex];
3883
- /** @property {float} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3968
+ /** @property {number} - Shrinks tile by this many pixels to prevent neighbors bleeding */
3884
3969
  this.bleedScale = bleedScale;
3885
3970
  }
3886
3971
 
@@ -4367,7 +4452,7 @@ function drawCanvas2D(pos, size, angle=0, mirror=false, drawFunction, screenSpac
4367
4452
 
4368
4453
  /** Draw text on main canvas in world space
4369
4454
  * Automatically splits new lines into rows
4370
- * @param {string} text
4455
+ * @param {string|number} text
4371
4456
  * @param {Vector2} pos
4372
4457
  * @param {number} [size]
4373
4458
  * @param {Color} [color=(1,1,1,1)]
@@ -4394,7 +4479,7 @@ function drawText(text, pos, size=1, color, lineWidth=0, lineColor, textAlign, f
4394
4479
 
4395
4480
  /** Draw text on overlay canvas in world space
4396
4481
  * Automatically splits new lines into rows
4397
- * @param {string} text
4482
+ * @param {string|number} text
4398
4483
  * @param {Vector2} pos
4399
4484
  * @param {number} [size]
4400
4485
  * @param {Color} [color=(1,1,1,1)]
@@ -4413,7 +4498,7 @@ function drawTextOverlay(text, pos, size=1, color, lineWidth=0, lineColor, textA
4413
4498
 
4414
4499
  /** Draw text on overlay canvas in screen space
4415
4500
  * Automatically splits new lines into rows
4416
- * @param {string} text
4501
+ * @param {string|number} text
4417
4502
  * @param {Vector2} pos
4418
4503
  * @param {number} [size]
4419
4504
  * @param {Color} [color=(1,1,1,1)]
@@ -4578,11 +4663,28 @@ function worldToScreenTransform(worldPos, worldSize, worldAngle=0)
4578
4663
  ];
4579
4664
  }
4580
4665
 
4581
- /** Get the camera's visible area in world space
4666
+ /** Get the size of the camera window in world space
4582
4667
  * @return {Vector2}
4583
4668
  * @memberof Draw */
4584
4669
  function getCameraSize() { return mainCanvasSize.scale(1/cameraScale); }
4585
4670
 
4671
+ /** Check if a point or circle is on screen
4672
+ * If size is a Vector2, uses the largest dimension as diameter
4673
+ * This can be used to cull offscreen objects from render or update
4674
+ * @param {Vector2} pos - world space position
4675
+ * @param {Vector2|number} size - world space size or diameter
4676
+ * @return {boolean}
4677
+ * @memberof Draw */
4678
+ function isOnScreen(pos, size=0)
4679
+ {
4680
+ pos = worldToScreen(pos);
4681
+ if (size instanceof Vector2)
4682
+ size = max(size.x, size.y); // use largest dimension
4683
+ size *= cameraScale/2;
4684
+ return pos.x + size > 0 && pos.x - size < mainCanvasSize.x &&
4685
+ pos.y + size > 0 && pos.y - size < mainCanvasSize.y;
4686
+ }
4687
+
4586
4688
  /** Enable normal or additive blend mode
4587
4689
  * @param {boolean} [additive]
4588
4690
  * @param {CanvasRenderingContext2D|OffscreenCanvasRenderingContext2D} [context]
@@ -4746,7 +4848,7 @@ class FontImage
4746
4848
  }
4747
4849
 
4748
4850
  /** Draw text in world space using the image font
4749
- * @param {string} text
4851
+ * @param {string|number} text
4750
4852
  * @param {Vector2} pos
4751
4853
  * @param {number} [scale=.25]
4752
4854
  * @param {boolean} [center]
@@ -4758,7 +4860,7 @@ class FontImage
4758
4860
  }
4759
4861
 
4760
4862
  /** Draw text on overlay canvas in world space using the image font
4761
- * @param {string} text
4863
+ * @param {string|number} text
4762
4864
  * @param {Vector2} pos
4763
4865
  * @param {number} [scale]
4764
4866
  * @param {boolean} [center]
@@ -4767,7 +4869,7 @@ class FontImage
4767
4869
  { this.drawText(text, pos, scale, center, overlayContext); }
4768
4870
 
4769
4871
  /** Draw text on overlay canvas in screen space using the image font
4770
- * @param {string} text
4872
+ * @param {string|number} text
4771
4873
  * @param {Vector2} pos
4772
4874
  * @param {number} [scale]
4773
4875
  * @param {boolean} [center]
@@ -4811,6 +4913,85 @@ class FontImage
4811
4913
  * @namespace Input
4812
4914
  */
4813
4915
 
4916
+ /** Mouse pos in world space
4917
+ * @type {Vector2}
4918
+ * @memberof Input */
4919
+ let mousePos = vec2();
4920
+
4921
+ /** Mouse pos in screen space
4922
+ * @type {Vector2}
4923
+ * @memberof Input */
4924
+ let mousePosScreen = vec2();
4925
+
4926
+ /** Mouse movement delta in world space
4927
+ * @type {Vector2}
4928
+ * @memberof Input */
4929
+ let mouseDelta = vec2();
4930
+
4931
+ /** Mouse movement delta in screen space
4932
+ * @type {Vector2}
4933
+ * @memberof Input */
4934
+ let mouseDeltaScreen = vec2();
4935
+
4936
+ /** Mouse wheel delta this frame
4937
+ * @type {number}
4938
+ * @memberof Input */
4939
+ let mouseWheel = 0;
4940
+
4941
+ /** True if mouse was inside the document window, set to false when mouse leaves
4942
+ * @type {boolean}
4943
+ * @memberof Input */
4944
+ let mouseInWindow = true;
4945
+
4946
+ /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4947
+ * @type {boolean}
4948
+ * @memberof Input */
4949
+ let isUsingGamepad = false;
4950
+
4951
+ /** Prevents input continuing to the default browser handling (true by default)
4952
+ * @type {boolean}
4953
+ * @memberof Input */
4954
+ let inputPreventDefault = true;
4955
+
4956
+ /** Primary gamepad index, automatically set to first gamepad with input
4957
+ * @type {number}
4958
+ * @memberof Input */
4959
+ let gamepadPrimary = 0;
4960
+
4961
+ /** Prevents input continuing to the default browser handling
4962
+ * This is useful to disable for html menus so the browser can handle input normally
4963
+ * @param {boolean} preventDefault
4964
+ * @memberof Input */
4965
+ function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
4966
+
4967
+ /** Clears an input key state
4968
+ * @param {string|number} key
4969
+ * @param {number} [device]
4970
+ * @param {boolean} [clearDown=true]
4971
+ * @param {boolean} [clearPressed=true]
4972
+ * @param {boolean} [clearReleased=true]
4973
+ * @memberof Input */
4974
+ function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4975
+ {
4976
+ if (!inputData[device])
4977
+ return;
4978
+ inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4979
+ }
4980
+
4981
+ /** Clears all input
4982
+ * @memberof Input */
4983
+ function inputClear()
4984
+ {
4985
+ inputData.length = 0;
4986
+ inputData[0] = [];
4987
+ touchGamepadButtons.length = 0;
4988
+ touchGamepadSticks.length = 0;
4989
+ gamepadStickData.length = 0;
4990
+ gamepadDpadData.length = 0;
4991
+ }
4992
+
4993
+ ///////////////////////////////////////////////////////////////////////////////
4994
+
4814
4995
  /** Returns true if device key is down
4815
4996
  * @param {string|number} key
4816
4997
  * @param {number} [device]
@@ -4818,9 +4999,9 @@ class FontImage
4818
4999
  * @memberof Input */
4819
5000
  function keyIsDown(key, device=0)
4820
5001
  {
4821
- ASSERT(key !== undefined, 'key is undefined');
5002
+ ASSERT(isString(key), 'key must be a number or string');
4822
5003
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4823
- return inputData[device] && !!(inputData[device][key] & 1);
5004
+ return !!(inputData[device]?.[key] & 1);
4824
5005
  }
4825
5006
 
4826
5007
  /** Returns true if device key was pressed this frame
@@ -4830,9 +5011,9 @@ function keyIsDown(key, device=0)
4830
5011
  * @memberof Input */
4831
5012
  function keyWasPressed(key, device=0)
4832
5013
  {
4833
- ASSERT(key !== undefined, 'key is undefined');
5014
+ ASSERT(isString(key), 'key must be a number or string');
4834
5015
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4835
- return inputData[device] && !!(inputData[device][key] & 2);
5016
+ return !!(inputData[device]?.[key] & 2);
4836
5017
  }
4837
5018
 
4838
5019
  /** Returns true if device key was released this frame
@@ -4842,173 +5023,193 @@ function keyWasPressed(key, device=0)
4842
5023
  * @memberof Input */
4843
5024
  function keyWasReleased(key, device=0)
4844
5025
  {
4845
- ASSERT(key !== undefined, 'key is undefined');
5026
+ ASSERT(isString(key), 'key must be a number or string');
4846
5027
  ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
4847
- return inputData[device] && !!(inputData[device][key] & 4);
5028
+ return !!(inputData[device]?.[key] & 4);
4848
5029
  }
4849
5030
 
4850
5031
  /** Returns input vector from arrow keys or WASD if enabled
5032
+ * @param {string} [up]
5033
+ * @param {string} [down]
5034
+ * @param {string} [left]
5035
+ * @param {string} [right]
4851
5036
  * @return {Vector2}
4852
5037
  * @memberof Input */
4853
5038
  function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
4854
5039
  {
5040
+ ASSERT(isString(up), 'up key must be a string');
5041
+ ASSERT(isString(down), 'down key must be a string');
5042
+ ASSERT(isString(left), 'left key must be a string');
5043
+ ASSERT(isString(right), 'right key must be a string');
4855
5044
  const k = (key)=> keyIsDown(key) ? 1 : 0;
4856
5045
  return vec2(k(right) - k(left), k(up) - k(down));
4857
5046
  }
4858
5047
 
4859
- /** Clears all input
4860
- * @memberof Input */
4861
- function inputClear() { inputData = [[]]; touchGamepadButtons = []; }
4862
-
4863
- /** Clears an input key state
4864
- * @param {string|number} key
4865
- * @param {number} [device]
4866
- * @param {boolean} [clearDown=true]
4867
- * @param {boolean} [clearPressed=true]
4868
- * @param {boolean} [clearReleased=true]
4869
- * @memberof Input */
4870
- function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
4871
- {
4872
- if (!inputData[device])
4873
- return;
4874
- inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
4875
- }
4876
-
4877
5048
  /** Returns true if mouse button is down
4878
5049
  * @function
4879
5050
  * @param {number} button
4880
5051
  * @return {boolean}
4881
5052
  * @memberof Input */
4882
- function mouseIsDown(button) { return keyIsDown(button); }
5053
+ function mouseIsDown(button)
5054
+ {
5055
+ ASSERT(isNumber(button), 'mouse button must be a number');
5056
+ return keyIsDown(button);
5057
+ }
4883
5058
 
4884
5059
  /** Returns true if mouse button was pressed
4885
5060
  * @function
4886
5061
  * @param {number} button
4887
5062
  * @return {boolean}
4888
5063
  * @memberof Input */
4889
- function mouseWasPressed(button) { return keyWasPressed(button); }
5064
+ function mouseWasPressed(button)
5065
+ {
5066
+ ASSERT(isNumber(button), 'mouse button must be a number');
5067
+ return keyWasPressed(button);
5068
+ }
4890
5069
 
4891
5070
  /** Returns true if mouse button was released
4892
5071
  * @function
4893
5072
  * @param {number} button
4894
5073
  * @return {boolean}
4895
5074
  * @memberof Input */
4896
- function mouseWasReleased(button) { return keyWasReleased(button); }
4897
-
4898
- /** Mouse pos in world space
4899
- * @type {Vector2}
4900
- * @memberof Input */
4901
- let mousePos = vec2();
4902
-
4903
- /** Mouse pos in screen space
4904
- * @type {Vector2}
4905
- * @memberof Input */
4906
- let mousePosScreen = vec2();
4907
-
4908
- /** Mouse movement delta in world space
4909
- * @type {Vector2}
4910
- * @memberof Input */
4911
- let mouseDelta = vec2();
4912
-
4913
- /** Mouse movement delta in screen space
4914
- * @type {Vector2}
4915
- * @memberof Input */
4916
- let mouseDeltaScreen = vec2();
4917
-
4918
- /** Mouse wheel delta this frame
4919
- * @type {number}
4920
- * @memberof Input */
4921
- let mouseWheel = 0;
4922
-
4923
- /** True if mouse was inside the document window, set to false when mouse leaves
4924
- * @type {boolean}
4925
- * @memberof Input */
4926
- let mouseInWindow = true;
4927
-
4928
- /** Returns true if user is using gamepad (has more recently pressed a gamepad button)
4929
- * @type {boolean}
4930
- * @memberof Input */
4931
- let isUsingGamepad = false;
4932
-
4933
- /** Prevents input continuing to the default browser handling (true by default)
4934
- * @type {boolean}
4935
- * @memberof Input */
4936
- let inputPreventDefault = true;
4937
-
4938
- /** Prevents input continuing to the default browser handling
4939
- * This is useful to disable for html menus so the browser can handle input normally
4940
- * @param {boolean} preventDefault
4941
- * @memberof Input */
4942
- function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
5075
+ function mouseWasReleased(button)
5076
+ {
5077
+ ASSERT(isNumber(button), 'mouse button must be a number');
5078
+ return keyWasReleased(button);
5079
+ }
4943
5080
 
4944
5081
  /** Returns true if gamepad button is down
4945
5082
  * @param {number} button
4946
5083
  * @param {number} [gamepad]
4947
5084
  * @return {boolean}
4948
5085
  * @memberof Input */
4949
- function gamepadIsDown(button, gamepad=0)
4950
- { return keyIsDown(button, gamepad+1); }
5086
+ function gamepadIsDown(button, gamepad=gamepadPrimary)
5087
+ {
5088
+ ASSERT(isNumber(button), 'button must be a number');
5089
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5090
+ return keyIsDown(button, gamepad+1);
5091
+ }
4951
5092
 
4952
5093
  /** Returns true if gamepad button was pressed
4953
5094
  * @param {number} button
4954
5095
  * @param {number} [gamepad]
4955
5096
  * @return {boolean}
4956
5097
  * @memberof Input */
4957
- function gamepadWasPressed(button, gamepad=0)
4958
- { return keyWasPressed(button, gamepad+1); }
5098
+ function gamepadWasPressed(button, gamepad=gamepadPrimary)
5099
+ {
5100
+ ASSERT(isNumber(button), 'button must be a number');
5101
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5102
+ return keyWasPressed(button, gamepad+1);
5103
+ }
4959
5104
 
4960
5105
  /** Returns true if gamepad button was released
4961
5106
  * @param {number} button
4962
5107
  * @param {number} [gamepad]
4963
5108
  * @return {boolean}
4964
5109
  * @memberof Input */
4965
- function gamepadWasReleased(button, gamepad=0)
4966
- { return keyWasReleased(button, gamepad+1); }
5110
+ function gamepadWasReleased(button, gamepad=gamepadPrimary)
5111
+ {
5112
+ ASSERT(isNumber(button), 'button must be a number');
5113
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5114
+ return keyWasReleased(button, gamepad+1);
5115
+ }
4967
5116
 
4968
5117
  /** Returns gamepad stick value
4969
5118
  * @param {number} stick
4970
5119
  * @param {number} [gamepad]
4971
5120
  * @return {Vector2}
4972
5121
  * @memberof Input */
4973
- function gamepadStick(stick, gamepad=0)
4974
- { return gamepadStickData[gamepad] ? gamepadStickData[gamepad][stick] || vec2() : vec2(); }
4975
-
4976
- ///////////////////////////////////////////////////////////////////////////////
4977
- // Input system functions called automatically by engine
4978
-
4979
- // input is stored as a bit field for each key: 1 = isDown, 2 = wasPressed, 4 = wasReleased
4980
- // mouse and keyboard are stored together in device 0, gamepads are in devices > 0
4981
- let inputData = [[]];
4982
-
4983
- function inputUpdate()
5122
+ function gamepadStick(stick, gamepad=gamepadPrimary)
4984
5123
  {
4985
- if (headlessMode) return;
4986
-
4987
- // clear input when lost focus (prevent stuck keys)
4988
- if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
4989
- inputClear();
5124
+ ASSERT(isNumber(stick), 'stick must be a number');
5125
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5126
+ return gamepadStickData[gamepad]?.[stick] ?? vec2();
5127
+ }
4990
5128
 
4991
- // update mouse world space position and delta
4992
- mousePos = screenToWorld(mousePosScreen);
4993
- mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5129
+ /** Returns gamepad dpad value
5130
+ * @param {number} [gamepad]
5131
+ * @return {Vector2}
5132
+ * @memberof Input */
5133
+ function gamepadDpad(gamepad=gamepadPrimary)
5134
+ {
5135
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5136
+ return gamepadDpadData[gamepad] ?? vec2();
5137
+ }
4994
5138
 
4995
- // update gamepads if enabled
4996
- gamepadsUpdate();
5139
+ /** Returns true if passed in gamepad is connected
5140
+ * @param {number} [gamepad]
5141
+ * @return {boolean}
5142
+ * @memberof Input */
5143
+ function gamepadConnected(gamepad=gamepadPrimary)
5144
+ {
5145
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5146
+ return !!inputData[gamepad+1];
4997
5147
  }
4998
5148
 
4999
- function inputUpdatePost()
5149
+ /** Returns how many control sticks the passed in gamepad has
5150
+ * @param {number} [gamepad]
5151
+ * @return {number}
5152
+ * @memberof Input */
5153
+ function gamepadStickCount(gamepad=gamepadPrimary)
5000
5154
  {
5001
- if (headlessMode) return;
5155
+ ASSERT(isNumber(gamepad), 'gamepad must be a number');
5156
+ return gamepadStickData[gamepad]?.length ?? 0;
5157
+ }
5002
5158
 
5003
- // clear input to prepare for next frame
5004
- for (const deviceInputData of inputData)
5005
- for (const i in deviceInputData)
5006
- deviceInputData[i] &= 1;
5007
- mouseWheel = 0;
5008
- mouseDelta = vec2();
5009
- mouseDeltaScreen = vec2();
5159
+ /** True if a touch device has been detected
5160
+ * @memberof Input */
5161
+ const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
5162
+
5163
+ ///////////////////////////////////////////////////////////////////////////////
5164
+
5165
+ /** Pulse the vibration hardware if it exists
5166
+ * @param {number|Array} [pattern] - single value in ms or vibration interval array
5167
+ * @memberof Input */
5168
+ function vibrate(pattern=100)
5169
+ {
5170
+ ASSERT(isNumber(pattern) || isArray(pattern), 'pattern must be a number or array');
5171
+ vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern);
5010
5172
  }
5011
5173
 
5174
+ /** Cancel any ongoing vibration
5175
+ * @memberof Input */
5176
+ function vibrateStop() { vibrate(0); }
5177
+
5178
+ ///////////////////////////////////////////////////////////////////////////////
5179
+ // Pointer Lock
5180
+
5181
+ /** Request to lock the pointer, does not work on touch devices
5182
+ * @memberof Input */
5183
+ function pointerLockRequest()
5184
+ { !isTouchDevice && mainCanvas.requestPointerLock?.(); }
5185
+
5186
+ /** Request to unlock the pointer
5187
+ * @memberof Input */
5188
+ function pointerLockExit()
5189
+ { document.exitPointerLock?.(); }
5190
+
5191
+ /** Check if pointer is locked (true if locked)
5192
+ * @return {boolean}
5193
+ * @memberof Input */
5194
+ function pointerLockIsActive()
5195
+ { return document.pointerLockElement === mainCanvas; }
5196
+
5197
+ ///////////////////////////////////////////////////////////////////////////////
5198
+ // Input variables used by engine
5199
+
5200
+ // input uses bit field for each key: 1=isDown, 2=wasPressed, 4=wasReleased
5201
+ // mouse and keyboard stored in device 0, gamepads stored in devices > 0
5202
+ const inputData = [[]];
5203
+
5204
+ // gamepad internal variables
5205
+ const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
5206
+
5207
+ // touch gamepad internal variables
5208
+ const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
5209
+
5210
+ ///////////////////////////////////////////////////////////////////////////////
5211
+ // Input system functions used by engine
5212
+
5012
5213
  function inputInit()
5013
5214
  {
5014
5215
  if (headlessMode) return;
@@ -5037,6 +5238,18 @@ function inputInit()
5037
5238
  if (inputWASDEmulateDirection)
5038
5239
  inputData[0][remapKey(e.code)] = 3;
5039
5240
  }
5241
+
5242
+ // prevent arrow key from moving the page
5243
+ const preventDefaultKeys =
5244
+ [
5245
+ 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // scrolling
5246
+ 'Space', // page down scroll
5247
+ 'Tab', // focus navigation
5248
+ 'Backspace', // browser back
5249
+ ];
5250
+ if (preventDefaultKeys.includes(e.code))
5251
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
5252
+ e.preventDefault();
5040
5253
  }
5041
5254
  function onKeyUp(e)
5042
5255
  {
@@ -5068,7 +5281,9 @@ function inputInit()
5068
5281
  const mousePosScreenLast = mousePosScreen;
5069
5282
  mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
5070
5283
  mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5071
- inputPreventDefault && e.button && e.preventDefault();
5284
+
5285
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
5286
+ e.preventDefault();
5072
5287
  }
5073
5288
  function onMouseUp(e)
5074
5289
  {
@@ -5092,344 +5307,388 @@ function inputInit()
5092
5307
  function onMouseWheel(e) { mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY); }
5093
5308
  function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
5094
5309
  function onBlur() { inputClear(); } // reset input when focus is lost
5095
- }
5096
5310
 
5097
- // convert a mouse or touch event position to screen space
5098
- function mouseEventToScreen(mousePos)
5099
- {
5100
- const rect = mainCanvas.getBoundingClientRect();
5101
- const px = percent(mousePos.x, rect.left, rect.right);
5102
- const py = percent(mousePos.y, rect.top, rect.bottom);
5103
- return vec2(px*mainCanvas.width, py*mainCanvas.height);
5104
- }
5311
+ // enable touch input mouse passthrough
5312
+ function touchInputInit()
5313
+ {
5314
+ // add non passive touch event listeners
5315
+ document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
5316
+ document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
5317
+ document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
5105
5318
 
5106
- ///////////////////////////////////////////////////////////////////////////////
5107
- // Gamepad input
5319
+ // handle all touch events the same way
5320
+ let wasTouching;
5321
+ function handleTouch(e)
5322
+ {
5323
+ if (!touchInputEnable)
5324
+ return;
5108
5325
 
5109
- // gamepad internal variables
5110
- const gamepadStickData = [];
5326
+ // route touch to gamepad
5327
+ if (touchGamepadEnable)
5328
+ handleTouchGamepad(e);
5111
5329
 
5112
- // gamepads are updated by engine every frame automatically
5113
- function gamepadsUpdate()
5114
- {
5115
- const applyDeadZones = (v)=>
5116
- {
5117
- const min=.3, max=.8;
5118
- const deadZone = (v)=>
5119
- v > min ? percent(v, min, max) :
5120
- v < -min ? -percent(-v, min, max) : 0;
5121
- return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5122
- }
5330
+ // fix stalled audio requiring user interaction
5331
+ if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5332
+ audioContext.resume();
5123
5333
 
5124
- // update touch gamepad if enabled
5125
- if (touchGamepadEnable && isTouchDevice)
5126
- {
5127
- if (!touchGamepadTimer.isSet())
5128
- return;
5334
+ // check if touching and pass to mouse events
5335
+ const touching = e.touches.length;
5336
+ const button = 0; // all touches are left mouse button
5337
+ if (touching)
5338
+ {
5339
+ // set event pos and pass it along
5340
+ const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5341
+ const mousePosScreenLast = mousePosScreen;
5342
+ mousePosScreen = mouseEventToScreen(pos);
5343
+ if (wasTouching)
5344
+ {
5345
+ mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5346
+ isUsingGamepad = touchGamepadEnable;
5347
+ }
5348
+ else
5349
+ inputData[0][button] = 3;
5350
+ }
5351
+ else if (wasTouching)
5352
+ inputData[0][button] = inputData[0][button] & 2 | 4;
5129
5353
 
5130
- // read virtual analog stick
5131
- const sticks = gamepadStickData[0] || (gamepadStickData[0] = []);
5132
- sticks[0] = vec2();
5133
- if (touchGamepadAnalog)
5134
- sticks[0] = applyDeadZones(touchGamepadStick);
5135
- else if (touchGamepadStick.lengthSquared() > .3)
5136
- {
5137
- // convert to 8 way dpad
5138
- sticks[0].x = round(touchGamepadStick.x);
5139
- sticks[0].y = -round(touchGamepadStick.y);
5140
- sticks[0] = sticks[0].clampLength();
5354
+ // set was touching
5355
+ wasTouching = touching;
5356
+
5357
+ // prevent default handling like copy, magnifier lens, and scrolling
5358
+ if (inputPreventDefault && document.hasFocus() && e.cancelable)
5359
+ e.preventDefault();
5360
+
5361
+ // must return true so the document will get focus
5362
+ return true;
5141
5363
  }
5142
5364
 
5143
- // read virtual gamepad buttons
5144
- const data = inputData[1] || (inputData[1] = []);
5145
- for (let i=10; i--;)
5365
+ // special handling for virtual gamepad mode
5366
+ function handleTouchGamepad(e)
5146
5367
  {
5147
- const wasDown = gamepadIsDown(i,0);
5148
- data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5368
+ // clear touch gamepad input
5369
+ touchGamepadSticks.length = 0;
5370
+ touchGamepadSticks[0] = vec2();
5371
+ touchGamepadSticks[1] = vec2();
5372
+ touchGamepadButtons.length = 0;
5373
+ isUsingGamepad = true;
5374
+
5375
+ const touching = e.touches.length;
5376
+ if (touching)
5377
+ {
5378
+ touchGamepadTimer.set();
5379
+ if (touchGamepadCenterButton && !wasTouching && paused)
5380
+ {
5381
+ // touch anywhere to press start when paused
5382
+ touchGamepadButtons[9] = 1;
5383
+ return;
5384
+ }
5385
+ }
5386
+
5387
+ // don't process touch gamepad if paused
5388
+ if (paused)
5389
+ return;
5390
+
5391
+ // get center of left and right sides
5392
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5393
+ const buttonCenter = touchGamepadButtonCenter();
5394
+ const startCenter = mainCanvasSize.scale(.5);
5395
+
5396
+ // check each touch point
5397
+ for (const touch of e.touches)
5398
+ {
5399
+ const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
5400
+ if (stickCenter.distance(touchPos) < touchGamepadSize)
5401
+ {
5402
+ // virtual analog stick
5403
+ const delta = touchPos.subtract(stickCenter);
5404
+ touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
5405
+ }
5406
+ else if (buttonCenter.distance(touchPos) < touchGamepadSize)
5407
+ {
5408
+ if (touchGamepadButtonCount === 1)
5409
+ {
5410
+ // virtual right analog stick
5411
+ const delta = touchPos.subtract(buttonCenter);
5412
+ touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
5413
+ }
5414
+ // virtual face buttons
5415
+ let button = buttonCenter.subtract(touchPos).direction();
5416
+ button = mod(button+2, 4);
5417
+ if (touchGamepadButtonCount === 1)
5418
+ button = 0;
5419
+ else if (touchGamepadButtonCount === 2)
5420
+ {
5421
+ const delta = buttonCenter.subtract(touchPos);
5422
+ button = -delta.x < delta.y ? 1 : 0;
5423
+ }
5424
+ // fix button locations (swap 2 and 3 to match gamepad layout)
5425
+ button = button === 3 ? 2 : button === 2 ? 3 : button;
5426
+ if (button < touchGamepadButtonCount)
5427
+ touchGamepadButtons[button] = 1;
5428
+ }
5429
+ else if (touchGamepadCenterButton &&
5430
+ startCenter.distance(touchPos) < touchGamepadSize)
5431
+ {
5432
+ // virtual start button in center
5433
+ touchGamepadButtons[9] = 1;
5434
+ }
5435
+ }
5149
5436
  }
5437
+ }
5150
5438
 
5151
- // disable normal gamepads when touch gamepad is active
5152
- return;
5439
+ // convert a mouse or touch event position to screen space
5440
+ function mouseEventToScreen(mousePos)
5441
+ {
5442
+ const rect = mainCanvas.getBoundingClientRect();
5443
+ const px = percent(mousePos.x, rect.left, rect.right);
5444
+ const py = percent(mousePos.y, rect.top, rect.bottom);
5445
+ return vec2(px*mainCanvas.width, py*mainCanvas.height);
5153
5446
  }
5447
+ }
5154
5448
 
5155
- // return if gamepads are disabled or not supported
5156
- if (!gamepadsEnable || !navigator || !navigator.getGamepads)
5157
- return;
5449
+ function inputUpdate()
5450
+ {
5451
+ if (headlessMode) return;
5158
5452
 
5159
- // only poll gamepads when focused or in debug mode
5160
- if (!debug && !document.hasFocus())
5161
- return;
5453
+ // clear input when lost focus (prevent stuck keys)
5454
+ if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
5455
+ inputClear();
5162
5456
 
5163
- // poll gamepads
5164
- const gamepads = navigator.getGamepads();
5165
- for (let i = gamepads.length; i--;)
5457
+ // update mouse world space position and delta
5458
+ mousePos = screenToWorld(mousePosScreen);
5459
+ mouseDelta = screenToWorldDelta(mouseDeltaScreen);
5460
+
5461
+ // update gamepads if enabled
5462
+ gamepadsUpdate();
5463
+
5464
+ // gamepads are updated by engine every frame automatically
5465
+ function gamepadsUpdate()
5166
5466
  {
5167
- // get or create gamepad data
5168
- const gamepad = gamepads[i];
5169
- const data = inputData[i+1] || (inputData[i+1] = []);
5170
- const sticks = gamepadStickData[i] || (gamepadStickData[i] = []);
5467
+ const applyDeadZones = (v)=>
5468
+ {
5469
+ const min=.3, max=.8;
5470
+ const deadZone = (v)=>
5471
+ v > min ? percent(v, min, max) :
5472
+ v < -min ? -percent(-v, min, max) : 0;
5473
+ return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
5474
+ }
5475
+
5476
+ // update touch gamepad if enabled
5477
+ if (touchGamepadEnable && isTouchDevice)
5478
+ {
5479
+ if (!touchGamepadTimer.isSet())
5480
+ return;
5481
+
5482
+ // read virtual analog stick
5483
+ gamepadPrimary = 0; // touch gamepad uses index 0
5484
+ const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
5485
+ const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
5486
+ sticks[0] = vec2();
5487
+ dpad.set();
5488
+ const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5489
+ if (touchGamepadAnalog)
5490
+ sticks[0] = applyDeadZones(leftTouchStick);
5491
+ else if (leftTouchStick.lengthSquared() > .3)
5492
+ {
5493
+ // convert to 8 way dpad
5494
+ const x = clamp(round(leftTouchStick.x), -1, 1);
5495
+ const y = clamp(round(leftTouchStick.y), -1, 1);
5496
+ dpad.set(x, -y);
5497
+ sticks[0] = dpad.clampLength(); // clamp to circle
5498
+ }
5499
+ if (touchGamepadButtonCount === 1)
5500
+ {
5501
+ const rightTouchStick = touchGamepadSticks[1] ?? vec2();
5502
+ sticks[1] = applyDeadZones(rightTouchStick);
5503
+ }
5171
5504
 
5172
- if (gamepad)
5505
+ // read virtual gamepad buttons
5506
+ const data = inputData[1] ?? (inputData[1] = []);
5507
+ for (let i=10; i--;)
5508
+ {
5509
+ const wasDown = gamepadIsDown(i,0);
5510
+ data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5511
+ }
5512
+
5513
+ // disable normal gamepads when touch gamepad is active
5514
+ return;
5515
+ }
5516
+
5517
+ // return if gamepads are disabled or not supported
5518
+ if (!gamepadsEnable || !navigator || !navigator.getGamepads)
5519
+ return;
5520
+
5521
+ // only poll gamepads when focused or in debug mode
5522
+ if (!debug && !document.hasFocus())
5523
+ return;
5524
+
5525
+ // poll gamepads
5526
+ const maxGamepads = 8;
5527
+ const gamepads = navigator.getGamepads();
5528
+ const gamepadCount = min(maxGamepads, gamepads.length)
5529
+ for (let i=0; i<gamepadCount; ++i)
5173
5530
  {
5531
+ // get or create gamepad data
5532
+ const gamepad = gamepads[i];
5533
+ if (!gamepad)
5534
+ {
5535
+ // clear gamepad data if not connected
5536
+ inputData[i+1] = undefined;
5537
+ gamepadStickData[i] = undefined;
5538
+ gamepadDpadData[i] = undefined;
5539
+ gamepadHadInput[i] = undefined;
5540
+ continue;
5541
+ }
5542
+
5543
+ const data = inputData[i+1] ?? (inputData[i+1] = []);
5544
+ const sticks = gamepadStickData[i] ?? (gamepadStickData[i] = []);
5545
+ const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
5546
+
5174
5547
  // read analog sticks
5175
5548
  for (let j = 0; j < gamepad.axes.length-1; j+=2)
5176
5549
  sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
5177
5550
 
5178
5551
  // read buttons
5552
+ let hadInput = false;
5179
5553
  for (let j = gamepad.buttons.length; j--;)
5180
5554
  {
5181
5555
  const button = gamepad.buttons[j];
5182
5556
  const wasDown = gamepadIsDown(j,i);
5183
5557
  data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
5184
- if (!button.value || button.value > .9) // must be a full press
5185
- if (!i && button.pressed)
5186
- isUsingGamepad = true;
5558
+
5559
+ // check for any input on this gamepad, analog must be full press
5560
+ if (button.pressed)
5561
+ if (!button.value || button.value > .9)
5562
+ hadInput = true;
5563
+ }
5564
+
5565
+ // set new primary gamepad if current is not connected
5566
+ if (hadInput)
5567
+ {
5568
+ gamepadHadInput[i] = true;
5569
+ if (!gamepadHadInput[gamepadPrimary])
5570
+ gamepadPrimary = i;
5571
+ isUsingGamepad ||= (gamepadPrimary === i);
5187
5572
  }
5188
5573
 
5189
- if (gamepadDirectionEmulateStick)
5574
+ if (gamepad.mapping === 'standard')
5190
5575
  {
5191
- // copy dpad to left analog stick when pressed
5192
- const dpad = vec2(
5576
+ // get dpad buttons (standard mapping)
5577
+ dpad.set(
5193
5578
  (gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
5194
5579
  (gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
5195
- if (dpad.lengthSquared())
5196
- sticks[0] = dpad.clampLength();
5580
+ }
5581
+ else if (gamepad.axes && gamepad.axes.length >= 2)
5582
+ {
5583
+ // digital style dpad from axes
5584
+ const x = clamp(round(gamepad.axes[0]), -1, 1);
5585
+ const y = clamp(round(gamepad.axes[1]), -1, 1);
5586
+ dpad.set(x, -y);
5197
5587
  }
5198
5588
 
5199
- // disable touch gamepad if using real gamepad
5200
- touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
5589
+ // copy dpad to left analog stick when pressed
5590
+ if (gamepadDirectionEmulateStick && !dpad.isZero())
5591
+ sticks[0] = dpad.clampLength();
5201
5592
  }
5593
+
5594
+ // disable touch gamepad if using real gamepad
5595
+ touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
5202
5596
  }
5203
5597
  }
5204
5598
 
5205
- ///////////////////////////////////////////////////////////////////////////////
5206
-
5207
- /** Pulse the vibration hardware if it exists
5208
- * @param {number|Array} [pattern] - single value in ms or vibration interval array
5209
- * @memberof Input */
5210
- function vibrate(pattern=100)
5211
- { vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern); }
5212
-
5213
- /** Cancel any ongoing vibration
5214
- * @memberof Input */
5215
- function vibrateStop() { vibrate(0); }
5216
-
5217
- ///////////////////////////////////////////////////////////////////////////////
5218
- // Touch input & virtual on screen gamepad
5219
-
5220
- /** True if a touch device has been detected
5221
- * @memberof Input */
5222
- const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
5223
-
5224
- // touch gamepad internal variables
5225
- let touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadStick = vec2();
5226
-
5227
- function touchGamepadButtonCenter()
5599
+ function inputUpdatePost()
5228
5600
  {
5229
- // draw right face buttons
5230
- const center = vec2(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5231
- if (touchGamepadButtonCount <= 2)
5232
- center.x += touchGamepadSize/2;
5233
- return center;
5601
+ if (headlessMode) return;
5602
+
5603
+ // clear input to prepare for next frame
5604
+ for (const deviceInputData of inputData)
5605
+ for (const i in deviceInputData)
5606
+ deviceInputData[i] &= 1;
5607
+ mouseWheel = 0;
5608
+ mouseDelta = vec2();
5609
+ mouseDeltaScreen = vec2();
5234
5610
  }
5235
5611
 
5236
- // enable touch input mouse passthrough
5237
- function touchInputInit()
5612
+ function inputRender()
5238
5613
  {
5239
- // add non passive touch event listeners
5240
- document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
5241
- document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
5242
- document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
5614
+ touchGamepadRender();
5243
5615
 
5244
- // handle all touch events the same way
5245
- let wasTouching;
5246
- function handleTouch(e)
5616
+ function touchGamepadRender()
5247
5617
  {
5248
- if (!touchInputEnable)
5618
+ if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5619
+ if (!touchGamepadEnable || !touchGamepadTimer.isSet())
5249
5620
  return;
5250
5621
 
5251
- // route touch to gamepad
5252
- if (touchGamepadEnable)
5253
- handleTouchGamepad(e);
5622
+ // fade off when not touching or paused
5623
+ const alpha = percent(touchGamepadTimer.get(), 4, 3);
5624
+ if (!alpha || paused)
5625
+ return;
5254
5626
 
5255
- // fix stalled audio requiring user interaction
5256
- if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
5257
- audioContext.resume();
5627
+ // setup the canvas
5628
+ const context = overlayContext;
5629
+ context.save();
5630
+ context.globalAlpha = alpha*touchGamepadAlpha;
5631
+ context.strokeStyle = '#fff';
5632
+ context.lineWidth = 3;
5258
5633
 
5259
- // check if touching and pass to mouse events
5260
- const touching = e.touches.length;
5261
- const button = 0; // all touches are left mouse button
5262
- if (touching)
5634
+ // draw left analog stick
5635
+ const leftTouchStick = touchGamepadSticks[0] ?? vec2();
5636
+ context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
5637
+ context.beginPath();
5638
+ const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5639
+ if (touchGamepadAnalog)
5263
5640
  {
5264
- // set event pos and pass it along
5265
- const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
5266
- const mousePosScreenLast = mousePosScreen;
5267
- mousePosScreen = mouseEventToScreen(pos);
5268
- if (wasTouching)
5269
- {
5270
- mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
5271
- isUsingGamepad = touchGamepadEnable;
5272
- }
5273
- else
5274
- inputData[0][button] = 3;
5641
+ // draw circle shaped gamepad
5642
+ context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
5275
5643
  }
5276
- else if (wasTouching)
5277
- inputData[0][button] = inputData[0][button] & 2 | 4;
5278
-
5279
- // set was touching
5280
- wasTouching = touching;
5281
-
5282
- // prevent default handling like copy, magnifier lens, and scrolling
5283
- if (inputPreventDefault && document.hasFocus() && e.cancelable)
5284
- e.preventDefault();
5285
-
5286
- // must return true so the document will get focus
5287
- return true;
5288
- }
5289
-
5290
- // special handling for virtual gamepad mode
5291
- function handleTouchGamepad(e)
5292
- {
5293
- // clear touch gamepad input
5294
- touchGamepadStick = vec2();
5295
- touchGamepadButtons = [];
5296
- isUsingGamepad = true;
5297
-
5298
- const touching = e.touches.length;
5299
- if (touching)
5644
+ else
5300
5645
  {
5301
- touchGamepadTimer.set();
5302
- if (touchGamepadCenterButton && !wasTouching && paused)
5646
+ // draw cross shaped gamepad
5647
+ for (let i=10; --i;)
5303
5648
  {
5304
- // touch anywhere to press start when paused
5305
- touchGamepadButtons[9] = 1;
5306
- return;
5649
+ const angle = i*PI/4;
5650
+ context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
5651
+ i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
5307
5652
  }
5308
5653
  }
5654
+ context.fill();
5655
+ context.stroke();
5309
5656
 
5310
- // get center of left and right sides
5311
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5312
- const buttonCenter = touchGamepadButtonCenter();
5313
- const startCenter = mainCanvasSize.scale(.5);
5314
-
5315
- // check each touch point
5316
- for (const touch of e.touches)
5657
+ // draw right face buttons
5317
5658
  {
5318
- const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
5319
- if (stickCenter.distance(touchPos) < touchGamepadSize)
5320
- {
5321
- // virtual analog stick
5322
- touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize).clampLength();
5323
- }
5324
- else if (buttonCenter.distance(touchPos) < touchGamepadSize)
5659
+ const buttonCenter = touchGamepadButtonCenter();
5660
+ const buttonSize = touchGamepadButtonCount > 1 ?
5661
+ touchGamepadSize/4 : touchGamepadSize/2;
5662
+ for (let i=0; i<touchGamepadButtonCount; i++)
5325
5663
  {
5326
- // virtual face buttons
5327
- let button = buttonCenter.subtract(touchPos).direction();
5328
- button = mod(button+2, 4);
5329
- if (touchGamepadButtonCount === 1)
5330
- button = 0;
5331
- else if (touchGamepadButtonCount === 2)
5332
- {
5333
- const delta = buttonCenter.subtract(touchPos);
5334
- button = -delta.x < delta.y ? 1 : 0;
5335
- }
5664
+ const j = mod(i-1, 4);
5665
+ let button = touchGamepadButtonCount > 2 ?
5666
+ j : min(j, touchGamepadButtonCount-1);
5336
5667
  // fix button locations (swap 2 and 3 to match gamepad layout)
5337
5668
  button = button === 3 ? 2 : button === 2 ? 3 : button;
5338
- if (button < touchGamepadButtonCount)
5339
- touchGamepadButtons[button] = 1;
5340
- }
5341
- else if (touchGamepadCenterButton && !wasTouching &&
5342
- startCenter.distance(touchPos) < touchGamepadSize)
5343
- {
5344
- // virtual start button in center
5345
- touchGamepadButtons[9] = 1;
5669
+ const pos = touchGamepadButtonCount < 2 ? buttonCenter :
5670
+ buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
5671
+ context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
5672
+ context.beginPath();
5673
+ context.arc(pos.x, pos.y, buttonSize, 0,9);
5674
+ context.fill();
5675
+ context.stroke();
5346
5676
  }
5347
5677
  }
5348
- }
5349
- }
5350
5678
 
5351
- // render the touch gamepad, called automatically by the engine
5352
- function touchGamepadRender()
5353
- {
5354
- if (!touchInputEnable || !isTouchDevice || headlessMode) return;
5355
- if (!touchGamepadEnable || !touchGamepadTimer.isSet())
5356
- return;
5357
-
5358
- // fade off when not touching or paused
5359
- const alpha = percent(touchGamepadTimer.get(), 4, 3);
5360
- if (!alpha || paused)
5361
- return;
5362
-
5363
- // setup the canvas
5364
- const context = overlayContext;
5365
- context.save();
5366
- context.globalAlpha = alpha*touchGamepadAlpha;
5367
- context.strokeStyle = '#fff';
5368
- context.lineWidth = 3;
5369
-
5370
- // draw left analog stick
5371
- context.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000';
5372
- context.beginPath();
5373
- const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
5374
- if (touchGamepadAnalog) // draw circle shaped gamepad
5375
- {
5376
- context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
5377
- context.fill();
5378
- context.stroke();
5379
- }
5380
- else // draw cross shaped gamepad
5381
- {
5382
- for (let i=10; i--;)
5383
- {
5384
- const angle = i*PI/4;
5385
- context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
5386
- i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
5387
- i===1 && context.fill();
5388
- }
5389
- context.stroke();
5390
- }
5391
-
5392
- // draw right face buttons
5393
- const buttonCenter = touchGamepadButtonCenter();
5394
- const buttonSize = touchGamepadButtonCount > 1 ? touchGamepadSize/4 : touchGamepadSize/2;
5395
- for (let i=0; i<touchGamepadButtonCount; i++)
5396
- {
5397
- const j = mod(i-1, 4);
5398
- let button = touchGamepadButtonCount > 2 ?
5399
- j : min(j, touchGamepadButtonCount-1);
5400
- // fix button locations (swap 2 and 3 to match gamepad layout)
5401
- button = button === 3 ? 2 : button === 2 ? 3 : button;
5402
- const pos = buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
5403
- context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
5404
- context.beginPath();
5405
- context.arc(pos.x, pos.y, buttonSize, 0,9);
5406
- context.fill();
5407
- context.stroke();
5679
+ // set canvas back to normal
5680
+ context.restore();
5408
5681
  }
5409
-
5410
- // set canvas back to normal
5411
- context.restore();
5412
5682
  }
5413
5683
 
5414
- ///////////////////////////////////////////////////////////////////////////////
5415
- // Pointer Lock
5416
-
5417
- /** Request to lock the pointer, does not work on touch devices
5418
- * @memberof Input */
5419
- function pointerLockRequest()
5684
+ // center position for right tocuh pad face buttons
5685
+ function touchGamepadButtonCenter()
5420
5686
  {
5421
- if (!isTouchDevice)
5422
- mainCanvas.requestPointerLock && mainCanvas.requestPointerLock();
5423
- }
5424
-
5425
- /** Request to unlock the pointer
5426
- * @memberof Input */
5427
- function pointerLockExit() { document.exitPointerLock && document.exitPointerLock(); }
5428
-
5429
- /** Check if pointer is locked (true if locked)
5430
- * @return {boolean}
5431
- * @memberof Input */
5432
- function pointerLockIsActive() { return document.pointerLockElement === mainCanvas; }
5687
+ const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
5688
+ if (touchGamepadButtonCount === 2)
5689
+ center.x += touchGamepadSize/2;
5690
+ return center;
5691
+ }
5433
5692
  /**
5434
5693
  * LittleJS Audio System
5435
5694
  * - <a href=https://killedbyapixel.github.io/ZzFX/>ZzFX Sound Effects</a> - ZzFX Sound Effect Generator
@@ -6169,7 +6428,7 @@ function tileCollisionTest(pos, size=vec2(), object, solidOnly=true)
6169
6428
  }
6170
6429
  }
6171
6430
 
6172
- /** Return the exact position of the boudnary of first tile hit, undefined if nothing was hit.
6431
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
6173
6432
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
6174
6433
  * @param {Vector2} posStart
6175
6434
  * @param {Vector2} posEnd
@@ -6333,8 +6592,7 @@ class CanvasLayer extends EngineObject
6333
6592
  /** Destroy this canvas layer */
6334
6593
  destroy()
6335
6594
  {
6336
- if (this.destroyed)
6337
- return;
6595
+ if (this.destroyed) return;
6338
6596
 
6339
6597
  this.textureInfo.destroyWebGLTexture();
6340
6598
  super.destroy();
@@ -6743,7 +7001,7 @@ class TileCollisionLayer extends TileLayer
6743
7001
  return false;
6744
7002
  }
6745
7003
 
6746
- /** Return the exact position of the boudnary of first tile hit, undefined if nothing was hit.
7004
+ /** Return the exact position of the boundary of first tile hit, undefined if nothing was hit.
6747
7005
  * The point will be inside the colliding tile if it hits (may have a tiny shift)
6748
7006
  * @param {Vector2} posStart
6749
7007
  * @param {Vector2} posEnd
@@ -6886,9 +7144,9 @@ class ParticleEmitter extends EngineObject
6886
7144
  /** @property {Color} - Color at start of life 2, randomized between start colors */
6887
7145
  this.colorStartB = colorStartB.copy();
6888
7146
  /** @property {Color} - Color at end of life 1, randomized between end colors */
6889
- this.colorEndA = colorEndA.copy();
7147
+ this.colorEndA = colorEndA.copy();
6890
7148
  /** @property {Color} - Color at end of life 2, randomized between end colors */
6891
- this.colorEndB = colorEndB.copy();
7149
+ this.colorEndB = colorEndB.copy();
6892
7150
  /** @property {boolean} - Should color be randomized linearly or across each component */
6893
7151
  this.randomColorLinear = randomColorLinear;
6894
7152
 
@@ -6972,8 +7230,10 @@ class ParticleEmitter extends EngineObject
6972
7230
  if (debugParticles)
6973
7231
  {
6974
7232
  // show emitter bounds
6975
- const emitSize = typeof this.emitSize === 'number' ? vec2(this.emitSize) : this.emitSize;
6976
- debugRect(this.pos, emitSize, '#0f0', 0, this.angle);
7233
+ if (typeof this.emitSize === 'number')
7234
+ debugCircle(this.pos, this.emitSize/2, '#0f0');
7235
+ else
7236
+ debugRect(this.pos, this.emitSize, '#0f0', 0, this.angle);
6977
7237
  }
6978
7238
  }
6979
7239
 
@@ -6983,8 +7243,8 @@ class ParticleEmitter extends EngineObject
6983
7243
  {
6984
7244
  // spawn a particle
6985
7245
  let pos = typeof this.emitSize === 'number' ? // check if number was used
6986
- randInCircle(this.emitSize/2) // circle emitter
6987
- : vec2(rand(-.5,.5), rand(-.5,.5)) // box emitter
7246
+ randInCircle(this.emitSize/2) // circle emitter
7247
+ : vec2(rand(-.5,.5), rand(-.5,.5)) // box emitter
6988
7248
  .multiply(this.emitSize).rotate(this.angle)
6989
7249
  let angle = rand(this.particleConeAngle, -this.particleConeAngle);
6990
7250
  if (!this.localSpace)
@@ -8688,11 +8948,14 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8688
8948
  /**
8689
8949
  * LittleJS User Interface Plugin
8690
8950
  * - call new UISystemPlugin() to setup the UI system
8951
+ * - Gamepad and keyboard navigation support
8691
8952
  * - Nested Menus
8692
8953
  * - Text
8693
8954
  * - Buttons
8694
8955
  * - Checkboxes
8695
8956
  * - Images
8957
+ * - Scrollbars
8958
+ * - Video
8696
8959
  * @namespace UISystem
8697
8960
  */
8698
8961
 
@@ -8703,6 +8966,20 @@ function zzfxM(instruments, patterns, sequence, BPM = 125)
8703
8966
  * @memberof UISystem */
8704
8967
  let uiSystem;
8705
8968
 
8969
+ /** Enable UI system debug drawing
8970
+ * 0=off, 1=normal, 2=show invisible
8971
+ * @type {number}
8972
+ * @default
8973
+ * @memberof UISystem */
8974
+ let uiDebug = 0;
8975
+
8976
+ /** Enable UI system debug drawing
8977
+ * 0=off, 1=normal, 2=show invisible
8978
+ * @param {number|boolean} enable
8979
+ * @memberof UISystem */
8980
+ function uiSetDebug(debugMode)
8981
+ { uiDebug = typeof debugMode === 'boolean' ? (debugMode ? 1 : 0) : debugMode; }
8982
+
8706
8983
  ///////////////////////////////////////////////////////////////////////////////
8707
8984
  /**
8708
8985
  * UI System Global Object
@@ -8741,7 +9018,7 @@ class UISystemPlugin
8741
9018
  /** @property {number} - Default rounded rect corner radius for UI elements */
8742
9019
  this.defaultCornerRadius = 0;
8743
9020
  /** @property {number} - Default scale to use for fitting text to object */
8744
- this.defaultTextScale = .8;
9021
+ this.defaultTextFitScale = .8;
8745
9022
  /** @property {string} - Default font for UI elements */
8746
9023
  this.defaultFont = fontDefault;
8747
9024
  /** @property {Sound} - Default sound when interactive UI element is pressed */
@@ -8756,6 +9033,21 @@ class UISystemPlugin
8756
9033
  this.defaultShadowBlur = 5;
8757
9034
  /** @property {Vector2} - Offset of shadow blur */
8758
9035
  this.defaultShadowOffset = vec2(5);
9036
+ /** @property {number} - If set ui coords will be renormalized to this canvas height */
9037
+ this.nativeHeight = 0;
9038
+
9039
+ // navigation properties
9040
+ /** @property {UIObject} - Object currently selected by navigation (gamepad or keyboard) */
9041
+ this.navigationObject = undefined;
9042
+ /** @property {Timer} - Cooldown timer for navigation inputs */
9043
+ this.navigationTimer = new Timer(undefined, true);
9044
+ /** @property {number} - Time between navigation inputs in seconds */
9045
+ this.navigationDelay = .2;
9046
+ /** @property {boolean} - should the navigation be horizontal, vertical, or both? */
9047
+ this.navigationDirection = 1;
9048
+ /** @property {boolean} - True if user last used navigation instead of mouse */
9049
+ this.navigationMode = false;
9050
+
8759
9051
  // system state
8760
9052
  /** @property {Array<UIObject>} - List of all UI elements */
8761
9053
  this.uiObjects = [];
@@ -8767,50 +9059,118 @@ class UISystemPlugin
8767
9059
  this.hoverObject = undefined;
8768
9060
  /** @property {UIObject} - Hover object at start of update */
8769
9061
  this.lastHoverObject = undefined;
8770
- /** @property {number} - If set ui coords will be renormalized to this canvas height */
8771
- this.nativeHeight = 0;
9062
+ /** @property {UIObject} - Current confirm menu being shown */
9063
+ this.confirmDialog = undefined;
8772
9064
 
8773
9065
  engineAddPlugin(uiUpdate, uiRender);
8774
9066
 
9067
+ // set object position in parent space
9068
+ function updateTransforms(o)
9069
+ {
9070
+ if (!o.parent) return;
9071
+ o.pos.x = o.localPos.x + o.parent.pos.x;
9072
+ o.pos.y = o.localPos.y + o.parent.pos.y;
9073
+ }
9074
+
8775
9075
  // setup recursive update and render
8776
9076
  // update in reverse order to detect mouse enter/leave
8777
9077
  function uiUpdate()
8778
9078
  {
8779
- function updateInvisibleObject(o)
9079
+ if (uiSystem.activeObject && !uiSystem.activeObject.visible)
9080
+ uiSystem.activeObject = undefined;
9081
+
9082
+ // reset hover object at start of update
9083
+ uiSystem.lastHoverObject = uiSystem.hoverObject;
9084
+ uiSystem.hoverObject = undefined;
9085
+
9086
+ if (mouseWasPressed(0))
8780
9087
  {
8781
- // update invisible objects
8782
- for (const c of o.children)
8783
- updateInvisibleObject(c);
8784
- o.updateInvisible();
9088
+ uiSystem.navigationMode = false;
9089
+ uiSystem.navigationObject = undefined;
8785
9090
  }
8786
- function updateObject(o)
9091
+
9092
+ // navigation with gamepad/keyboard
9093
+ const navigableObjects = uiSystem.getNavigableObjects();
9094
+ if (!navigableObjects.length)
9095
+ uiSystem.navigationObject = undefined;
9096
+ else
8787
9097
  {
8788
- if (o.visible)
9098
+ // unselect object if it is no longer navigable
9099
+ if (!navigableObjects.includes(uiSystem.navigationObject))
9100
+ uiSystem.navigationObject = undefined;
9101
+
9102
+ if (!isTouchDevice)
9103
+ if (uiSystem.navigationMode && !uiSystem.navigationObject)
8789
9104
  {
8790
- // set position in parent space
8791
- if (o.parent)
8792
- o.pos = o.localPos.add(o.parent.pos);
8793
- // update in reverse order to detect mouse enter/leave
8794
- for (let i=o.children.length; i--;)
8795
- updateObject(o.children[i]);
8796
- o.update();
9105
+ // select first auto focus object
9106
+ uiSystem.navigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
8797
9107
  }
8798
- else
8799
- updateInvisibleObject(o);
9108
+
9109
+ // navigate with dpad or left stick
9110
+ if (!uiSystem.navigationTimer.active())
9111
+ {
9112
+ // navigate through list with gamepad or keyboard
9113
+ const direction = sign(uiSystem.getNavigationDirection());
9114
+ if (direction)
9115
+ {
9116
+ let newNavigationObject;
9117
+ if (!uiSystem.navigationObject)
9118
+ {
9119
+ // use auto select object
9120
+ newNavigationObject = navigableObjects.find(o=>o.navigationAutoSelect);
9121
+
9122
+ if (!newNavigationObject)
9123
+ {
9124
+ // try first or last object
9125
+ const newIndex = direction > 0 ? 0 : navigableObjects.length-1;
9126
+ newNavigationObject = navigableObjects[newIndex];
9127
+ }
9128
+ }
9129
+ else
9130
+ {
9131
+ const currentIndex = navigableObjects.indexOf(uiSystem.navigationObject);
9132
+ const newIndex = mod(currentIndex + direction, navigableObjects.length);
9133
+ newNavigationObject = navigableObjects[newIndex];
9134
+ }
9135
+
9136
+ if (uiSystem.navigationObject !== newNavigationObject)
9137
+ {
9138
+ uiSystem.navigationMode = true;
9139
+ uiSystem.hoverObject = undefined;
9140
+ uiSystem.navigationObject = newNavigationObject;
9141
+ uiSystem.navigationTimer.set(uiSystem.navigationDelay);
9142
+ newNavigationObject.soundPress &&
9143
+ newNavigationObject.soundPress.play();
9144
+ }
9145
+ }
9146
+ }
9147
+
9148
+ // activate the navigation object when pressed
9149
+ if (uiSystem.navigationObject)
9150
+ if (uiSystem.getNavigationWasPressed())
9151
+ uiSystem.navigationObject.navigatePressed();
8800
9152
  }
8801
- // reset hover object at start of update
8802
- uiSystem.lastHoverObject = uiSystem.hoverObject;
8803
- uiSystem.hoverObject = undefined;
8804
9153
 
8805
9154
  // update in reverse order so topmost objects get priority
8806
9155
  for (let i = uiSystem.uiObjects.length; i--;)
8807
9156
  {
8808
9157
  const o = uiSystem.uiObjects[i];
8809
- o.parent || updateObject(o)
9158
+ o.parent || updateObject(o);
8810
9159
  }
8811
9160
 
8812
9161
  // remove destroyed objects
8813
9162
  uiSystem.uiObjects = uiSystem.uiObjects.filter(o=>!o.destroyed);
9163
+
9164
+ function updateObject(o)
9165
+ {
9166
+ if (!o.visible) return;
9167
+
9168
+ // update in reverse order to detect mouse enter/leave
9169
+ updateTransforms(o);
9170
+ for (let i=o.children.length; i--;)
9171
+ updateObject(o.children[i]);
9172
+ o.update();
9173
+ }
8814
9174
  }
8815
9175
  function uiRender()
8816
9176
  {
@@ -8827,15 +9187,29 @@ class UISystemPlugin
8827
9187
 
8828
9188
  function renderObject(o)
8829
9189
  {
8830
- if (!o.visible)
8831
- return;
8832
- if (o.parent)
8833
- o.pos = o.localPos.add(o.parent.pos);
9190
+ if (!o.visible) return;
9191
+
9192
+ // render object and children
9193
+ updateTransforms(o);
8834
9194
  o.render();
8835
9195
  for (const c of o.children)
8836
9196
  renderObject(c);
8837
9197
  }
8838
9198
  uiSystem.uiObjects.forEach(o=> o.parent || renderObject(o));
9199
+
9200
+ if (uiDebug > 0)
9201
+ {
9202
+ // debug render all objects
9203
+ function renderDebug(o, visible=true)
9204
+ {
9205
+ visible &&= !!o.visible;
9206
+ updateTransforms(o);
9207
+ o.renderDebug(visible);
9208
+ for (const c of o.children)
9209
+ renderDebug(c, visible);
9210
+ }
9211
+ uiSystem.uiObjects.forEach(o=> o.parent || renderDebug(o));
9212
+ }
8839
9213
  context.restore();
8840
9214
  }
8841
9215
  }
@@ -8888,8 +9262,8 @@ class UISystemPlugin
8888
9262
  else
8889
9263
  context.rect(pos.x-size.x/2, pos.y-size.y/2, size.x, size.y);
8890
9264
  context.fill();
8891
- context.shadowColor = '#0000'
8892
- if (lineWidth)
9265
+ context.shadowColor = '#0000';
9266
+ if (lineWidth && lineColor.a > 0)
8893
9267
  {
8894
9268
  context.strokeStyle = lineColor.toString();
8895
9269
  context.lineWidth = lineWidth;
@@ -8924,10 +9298,24 @@ class UISystemPlugin
8924
9298
  * @param {TileInfo} tileInfo
8925
9299
  * @param {Color} [color=uiSystem.defaultColor]
8926
9300
  * @param {number} [angle]
8927
- * @param {boolean} [mirror] */
8928
- drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false)
9301
+ * @param {boolean} [mirror]
9302
+ * @param {Color} [shadowColor]
9303
+ * @param {number} [shadowBlur]
9304
+ * @param {Color} [shadowOffset] */
9305
+ drawTile(pos, size, tileInfo, color=uiSystem.defaultColor, angle=0, mirror=false, shadowColor=BLACK, shadowBlur=0, shadowOffset=vec2())
8929
9306
  {
8930
- drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, uiSystem.uiContext);
9307
+ const context = uiSystem.uiContext;
9308
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
9309
+ if (shadowColor.a > 0)
9310
+ {
9311
+ // setup shadow
9312
+ context.shadowColor = shadowColor.toString();
9313
+ context.shadowBlur = shadowBlur;
9314
+ context.shadowOffsetX = shadowOffset.x;
9315
+ context.shadowOffsetY = shadowOffset.y;
9316
+ }
9317
+ drawTile(pos, size, tileInfo, color, angle, mirror, CLEAR_BLACK, false, true, context);
9318
+ context.shadowColor = '#0000';
8931
9319
  }
8932
9320
 
8933
9321
  /** Draw text to the UI context
@@ -8942,12 +9330,27 @@ class UISystemPlugin
8942
9330
  * @param {string} [fontStyle]
8943
9331
  * @param {boolean} [applyMaxWidth=true]
8944
9332
  * @param {Vector2} [textShadow]
8945
- */
8946
- drawText(text, pos, size, color=uiSystem.defaultColor, lineWidth=uiSystem.defaultLineWidth, lineColor=uiSystem.defaultLineColor, align='center', font=uiSystem.defaultFont, fontStyle='', applyMaxWidth=true, textShadow=undefined)
9333
+ * @param {Color} [shadowColor]
9334
+ * @param {number} [shadowBlur]
9335
+ * @param {Color} [shadowOffset] */
9336
+ 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())
8947
9337
  {
8948
- if (textShadow)
8949
- drawTextScreen(text, pos.add(textShadow), size.y, BLACK, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
8950
- drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, uiSystem.uiContext);
9338
+ const context = uiSystem.uiContext;
9339
+ if (shadowColor.a > 0)
9340
+ {
9341
+ if (textShadow)
9342
+ drawTextScreen(text, pos.add(textShadow), size.y, shadowColor, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
9343
+ if (shadowBlur || shadowOffset.x || shadowOffset.y)
9344
+ {
9345
+ // setup shadow
9346
+ context.shadowColor = shadowColor.toString();
9347
+ context.shadowBlur = shadowBlur;
9348
+ context.shadowOffsetX = shadowOffset.x;
9349
+ context.shadowOffsetY = shadowOffset.y;
9350
+ }
9351
+ }
9352
+ drawTextScreen(text, pos, size.y, color, lineWidth, lineColor, align, font, fontStyle, applyMaxWidth ? size.x : undefined, 0, context);
9353
+ context.shadowColor = '#0000';
8951
9354
  }
8952
9355
 
8953
9356
  /**
@@ -8961,7 +9364,7 @@ class UISystemPlugin
8961
9364
  * @param {DragAndDropCallback} [onDrop] - when a file is dropped
8962
9365
  * @param {DragAndDropCallback} [onDragEnter] - when a file is dragged onto the window
8963
9366
  * @param {DragAndDropCallback} [onDragLeave] - when a file is dragged off the window
8964
- * @param {DragAndDropCallback} [onDragOver] - continously when dragging over */
9367
+ * @param {DragAndDropCallback} [onDragOver] - continuously when dragging over */
8965
9368
  setupDragAndDrop(onDrop, onDragEnter, onDragLeave, onDragOver)
8966
9369
  {
8967
9370
  function setCallback(callback, listenerType)
@@ -8977,8 +9380,7 @@ class UISystemPlugin
8977
9380
 
8978
9381
  /** Convert a screen space position to native UI position
8979
9382
  * @param {Vector2} pos
8980
- * @return {Vector2}
8981
- */
9383
+ * @return {Vector2} */
8982
9384
  screenToNative(pos)
8983
9385
  {
8984
9386
  if (!uiSystem.nativeHeight)
@@ -9001,6 +9403,157 @@ class UISystemPlugin
9001
9403
  for (const o of this.uiObjects)
9002
9404
  o.parent || o.destroy();
9003
9405
  this.uiObjects = this.uiObjects.filter(o=>!o.destroyed);
9406
+ this.activeObject = undefined;
9407
+ this.hoverObject = undefined;
9408
+ this.lastHoverObject = undefined;
9409
+ }
9410
+
9411
+ /** Get all navigable UI objects sorted by navigationIndex
9412
+ * @return {Array<UIObject>} */
9413
+ getNavigableObjects()
9414
+ {
9415
+ function getNavigableRecursive(o)
9416
+ {
9417
+ if (!o.visible || o.disabled)
9418
+ return; // skip children if parent is invisible or disabled
9419
+
9420
+ if (o.isInteractive() && o.navigationIndex !== undefined)
9421
+ objects.push(o);
9422
+ for (let i=o.children.length; i--;)
9423
+ getNavigableRecursive(o.children[i]);
9424
+ }
9425
+
9426
+ // get all the valid navigable objects recursively
9427
+ let objects = [];
9428
+ for (let i = uiSystem.uiObjects.length; i--;)
9429
+ {
9430
+ const o = uiSystem.uiObjects[i];
9431
+ if (uiSystem.confirmDialog && o !== uiSystem.confirmDialog)
9432
+ continue;
9433
+ o.parent || getNavigableRecursive(o);
9434
+ }
9435
+
9436
+ // sort by navigationIndex (lower numbers first)
9437
+ objects.sort((a, b)=> a.navigationIndex - b.navigationIndex);
9438
+ return objects;
9439
+ }
9440
+
9441
+ /** Get navigation direction from gamepad or keyboard
9442
+ * @return {number} */
9443
+ getNavigationDirection()
9444
+ {
9445
+ const vertical = uiSystem.navigationDirection === 1;
9446
+ const both = uiSystem.navigationDirection === 2;
9447
+ if (isUsingGamepad)
9448
+ {
9449
+ const stick = gamepadStick(0, gamepadPrimary);
9450
+ const dpad = gamepadDpad(gamepadPrimary);
9451
+ if (both)
9452
+ return -(stick.y || dpad.y) || (stick.x || dpad.x);
9453
+ return vertical ? -(stick.y || dpad.y) : (stick.x || dpad.x);
9454
+ }
9455
+ const up = 'ArrowUp', down = 'ArrowDown', left = 'ArrowLeft', right = 'ArrowRight';
9456
+ if (both)
9457
+ {
9458
+ return keyIsDown(up) || keyIsDown(left) ? -1 :
9459
+ keyIsDown(down) || keyIsDown(right) ? 1 : 0;
9460
+ }
9461
+ const back = vertical ? up : left;
9462
+ const forward = vertical ? down : right;
9463
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
9464
+ }
9465
+
9466
+ /** Get other axis navigation direction from gamepad or keyboard
9467
+ * @return {Vector2} */
9468
+ getNavigationOtherDirection()
9469
+ {
9470
+ if (uiSystem.navigationDirection === 2)
9471
+ return 0; // other direction disabled
9472
+
9473
+ const vertical = uiSystem.navigationDirection === 1;
9474
+ if (isUsingGamepad)
9475
+ {
9476
+ const stick = gamepadStick(0, gamepadPrimary);
9477
+ const dpad = gamepadDpad(gamepadPrimary);
9478
+ return !vertical ? (stick.y || dpad.y) : (stick.x || dpad.x);
9479
+ }
9480
+ const back = !vertical ? 'ArrowUp' : 'ArrowLeft';
9481
+ const forward = !vertical ? 'ArrowDown' : 'ArrowRight';
9482
+ return keyIsDown(back) ? -1 : keyIsDown(forward) ? 1 : 0;
9483
+ }
9484
+
9485
+ /** Get if navigation button was pressed from gamepad or keyboard
9486
+ * @return {boolean} */
9487
+ getNavigationWasPressed()
9488
+ {
9489
+ return isUsingGamepad ? gamepadWasPressed(0, gamepadPrimary) :
9490
+ keyWasPressed('Space') || keyWasPressed('Enter');
9491
+ }
9492
+
9493
+ /** Show a confirmation dialog with Yes/No buttons
9494
+ * Centers the dialog on the screen with darkened background
9495
+ * @param {string} [text] - The message to display
9496
+ * @param {Function} [yesCallback] - Called when Yes is clicked
9497
+ * @param {Function} [noCallback] - Called when No is clicked
9498
+ * @param {Vector2} [size] - Size of the confirmation dialog
9499
+ * @param {string} [exitKey] - Key that can exit the menu
9500
+ * @return {UIObject} The confirmation menu object
9501
+ */
9502
+ showConfirmDialog(text='Are you sure?', yesCallback, noCallback, size=vec2(500,250), exitKey='Escape')
9503
+ {
9504
+ ASSERT(!uiSystem.confirmDialog);
9505
+
9506
+ const savedNavigationDirection = uiSystem.navigationDirection;
9507
+
9508
+ // allow both axies for navigation
9509
+ uiSystem.navigationDirection = 2;
9510
+
9511
+ // confirm menu
9512
+ const confirmMenu = new UIObject(vec2(), size);
9513
+ uiSystem.confirmDialog = confirmMenu;
9514
+ confirmMenu.onRender = ()=>
9515
+ {
9516
+ confirmMenu.pos = uiSystem.screenToNative(mainCanvasSize.scale(.5));
9517
+ const backgroundColor = hsl(0,0,0,.7);
9518
+ uiSystem.drawRect(vec2(), vec2(1e9), backgroundColor);
9519
+ }
9520
+ confirmMenu.onUpdate = ()=>
9521
+ {
9522
+ if (keyWasPressed(exitKey))
9523
+ closeMenu();
9524
+ }
9525
+ confirmMenu.isMouseOverlapping = ()=> true; // always hover
9526
+
9527
+ // title text
9528
+ const gap = 50;
9529
+ const textTitle = new UIText(vec2(0,-50), vec2(size.x-gap,70), text);
9530
+ confirmMenu.addChild(textTitle);
9531
+
9532
+ // yes button
9533
+ const buttonYes = new UIButton(vec2(-80,50), vec2(120,70), 'Yes');
9534
+ buttonYes.textHeight = 40;
9535
+ buttonYes.navigationIndex = 1;
9536
+ buttonYes.hoverColor = hsl(0,1,.5);
9537
+ buttonYes.onClick = ()=> { closeMenu(); yesCallback && yesCallback(); };
9538
+ confirmMenu.addChild(buttonYes);
9539
+
9540
+ // no button
9541
+ const buttonNo = new UIButton(vec2(80,50), vec2(120,70), 'No');
9542
+ buttonNo.textHeight = 40;
9543
+ buttonNo.navigationIndex = 2;
9544
+ buttonNo.navigationAutoSelect = true;
9545
+ buttonNo.onClick = ()=> { closeMenu(); noCallback && noCallback(); };
9546
+ confirmMenu.addChild(buttonNo);
9547
+
9548
+ // close menu and return to normal navigation
9549
+ function closeMenu()
9550
+ {
9551
+ ASSERT(uiSystem.confirmDialog === confirmMenu);
9552
+ confirmMenu.destroy();
9553
+ uiSystem.confirmDialog = undefined;
9554
+ uiSystem.navigationDirection = savedNavigationDirection;
9555
+ inputClear();
9556
+ }
9004
9557
  }
9005
9558
  }
9006
9559
 
@@ -9056,7 +9609,13 @@ class UIObject
9056
9609
  /** @property {number} - Override for text height */
9057
9610
  this.textHeight = undefined;
9058
9611
  /** @property {number} - Scale text to fit in the object */
9059
- this.textScale = uiSystem.defaultTextScale;
9612
+ this.textFitScale = uiSystem.defaultTextFitScale;
9613
+ /** @property {Vector2} - How much to offset the text shadow or undefined */
9614
+ this.textShadow = undefined;
9615
+ /** @property {number} - Color for text line drawing */
9616
+ this.textLineColor = uiSystem.defaultLineColor.copy();
9617
+ /** @property {number} - Width for text line drawing */
9618
+ this.textLineWidth = 0;
9060
9619
  /** @property {boolean} - Should this object be drawn */
9061
9620
  this.visible = true;
9062
9621
  /** @property {Array<UIObject>} - A list of this object's children */
@@ -9082,16 +9641,17 @@ class UIObject
9082
9641
  /** @property {number} - Size of shadow blur */
9083
9642
  this.shadowBlur = uiSystem.defaultShadowBlur;
9084
9643
  /** @property {Vector2} - Offset of shadow blur */
9085
- this.shadowOffset = uiSystem.defaultShadowOffset.copy();
9086
- uiSystem.uiObjects.push(this);
9644
+ this.shadowOffset = uiSystem.defaultShadowOffset?.copy();
9645
+ /** @property {number} - Optional navigation order index, lower values are selected first */
9646
+ this.navigationIndex = undefined;
9647
+ /** @property {boolean} - Should this be auto selected by navigation? Must also have valid navigation index. */
9648
+ this.navigationAutoSelect = false;
9087
9649
 
9088
- /** @property {Vector2} - How much to offset the text shadow or undefined */
9089
- this.textShadow = undefined;
9650
+ uiSystem.uiObjects.push(this);
9090
9651
  }
9091
9652
 
9092
9653
  /** Add a child UIObject to this object
9093
- * @param {UIObject} child
9094
- */
9654
+ * @param {UIObject} child */
9095
9655
  addChild(child)
9096
9656
  {
9097
9657
  ASSERT(!child.parent && !this.children.includes(child));
@@ -9100,8 +9660,7 @@ class UIObject
9100
9660
  }
9101
9661
 
9102
9662
  /** Remove a child UIObject from this object
9103
- * @param {UIObject} child
9104
- */
9663
+ * @param {UIObject} child */
9105
9664
  removeChild(child)
9106
9665
  {
9107
9666
  ASSERT(child.parent === this && this.children.includes(child));
@@ -9109,7 +9668,6 @@ class UIObject
9109
9668
  child.parent = undefined;
9110
9669
  }
9111
9670
 
9112
-
9113
9671
  /** Destroy this object, destroy its children, detach its parent, and mark it for removal */
9114
9672
  destroy()
9115
9673
  {
@@ -9125,9 +9683,9 @@ class UIObject
9125
9683
  child.destroy();
9126
9684
  }
9127
9685
  }
9686
+
9128
9687
  /** Check if the mouse is overlapping a box in screen space
9129
- * @return {boolean} - True if overlapping
9130
- */
9688
+ * @return {boolean} - True if overlapping */
9131
9689
  isMouseOverlapping()
9132
9690
  {
9133
9691
  if (!mouseInWindow) return false;
@@ -9144,11 +9702,16 @@ class UIObject
9144
9702
  // call the custom update callback
9145
9703
  this.onUpdate();
9146
9704
 
9705
+ // unset active if disabled
9706
+ if (this.disabled && this == uiSystem.activeObject)
9707
+ uiSystem.activeObject = undefined;
9708
+
9147
9709
  const wasHover = uiSystem.lastHoverObject === this;
9148
9710
  const isActive = this.isActiveObject();
9149
9711
  const mouseDown = mouseIsDown(0);
9150
9712
  const mousePress = this.dragActivate ? mouseDown : mouseWasPressed(0);
9151
9713
  if (this.canBeHover)
9714
+ if (!uiSystem.navigationMode) // no mouse hover in navigation mode
9152
9715
  if (mousePress || isActive || (!mouseDown && !isTouchDevice))
9153
9716
  if (!uiSystem.hoverObject && this.isMouseOverlapping())
9154
9717
  uiSystem.hoverObject = this;
@@ -9162,8 +9725,7 @@ class UIObject
9162
9725
  {
9163
9726
  if (!this.dragActivate || (!wasHover || mouseWasPressed(0)))
9164
9727
  this.onPress();
9165
- if (this.soundPress)
9166
- this.soundPress.play();
9728
+ this.soundPress && this.soundPress.play();
9167
9729
  if (uiSystem.activeObject && !isActive)
9168
9730
  uiSystem.activeObject.onRelease();
9169
9731
  uiSystem.activeObject = this;
@@ -9172,10 +9734,10 @@ class UIObject
9172
9734
  if (!mouseDown && this.isActiveObject() && this.interactive)
9173
9735
  {
9174
9736
  this.onClick();
9175
- if (this.soundClick)
9176
- this.soundClick.play();
9737
+ this.soundClick && this.soundClick.play();
9177
9738
  }
9178
9739
  }
9740
+
9179
9741
  // clear mouse was pressed state even when disabled
9180
9742
  mousePress && inputClearKey(0,0,0,1,0);
9181
9743
  }
@@ -9183,8 +9745,7 @@ class UIObject
9183
9745
  if (!mouseDown || (this.dragActivate && !this.isHoverObject()))
9184
9746
  {
9185
9747
  this.onRelease();
9186
- if (this.soundRelease)
9187
- this.soundRelease.play();
9748
+ this.soundRelease && this.soundRelease.play();
9188
9749
  uiSystem.activeObject = undefined;
9189
9750
  }
9190
9751
 
@@ -9196,29 +9757,40 @@ class UIObject
9196
9757
  /** Render the object, called automatically by plugin once each frame */
9197
9758
  render()
9198
9759
  {
9199
- if (!this.size.x || !this.size.y) return;
9760
+ // call the custom render callback
9761
+ this.onRender();
9200
9762
 
9201
- const lineColor = this.interactive && this.isActiveObject() && !this.disabled ? this.color : this.lineColor;
9202
- const color = this.disabled ? this.disabledColor : this.interactive ? this.isActiveObject() ? this.activeColor || this.color : this.isHoverObject() ? this.hoverColor : this.color : this.color;
9203
- uiSystem.drawRect(this.pos, this.size, color, this.lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
9204
- }
9763
+ if (!this.size.x || !this.size.y) return;
9205
9764
 
9206
- /** Special update when object is not visible */
9207
- updateInvisible()
9208
- {
9209
- // reset input state when not visible
9210
- if (this.isActiveObject())
9211
- uiSystem.activeObject = undefined;
9765
+ const isNavigationObject = this.isNavigationObject();
9766
+ const lineColor = isNavigationObject ? this.color :
9767
+ this.interactive && this.isActiveObject() && !this.disabled ?
9768
+ this.color : this.lineColor;
9769
+ const color = isNavigationObject ? this.hoverColor :
9770
+ this.disabled ? this.disabledColor :
9771
+ this.interactive ?
9772
+ this.isHoverObject() ? this.hoverColor :
9773
+ this.isActiveObject() ? this.activeColor || this.color :
9774
+ this.color : this.color;
9775
+ const lineWidth = this.lineWidth * (isNavigationObject ? 1.5 : 1);
9776
+
9777
+ uiSystem.drawRect(this.pos, this.size, color, lineWidth, lineColor, this.cornerRadius, this.gradientColor, this.shadowColor, this.shadowBlur, this.shadowOffset);
9212
9778
  }
9213
9779
 
9214
9780
  /** Get the size for text with overrides and scale
9215
- * @return {Vector2}
9216
- */
9781
+ * @return {Vector2} */
9217
9782
  getTextSize()
9218
9783
  {
9219
9784
  return vec2(
9220
- this.textWidth || this.textScale * this.size.x,
9221
- this.textHeight || this.textScale * this.size.y);
9785
+ this.textWidth || this.textFitScale * this.size.x,
9786
+ this.textHeight || this.textFitScale * this.size.y);
9787
+ }
9788
+
9789
+ /** Called when the navigation button is pressed on this object */
9790
+ navigatePressed()
9791
+ {
9792
+ this.onClick();
9793
+ this.soundClick && this.soundClick.play();
9222
9794
  }
9223
9795
 
9224
9796
  /** @return {boolean} - Is the mouse hovering over this element */
@@ -9227,9 +9799,51 @@ class UIObject
9227
9799
  /** @return {boolean} - Is the mouse held onto this element */
9228
9800
  isActiveObject() { return uiSystem.activeObject === this; }
9229
9801
 
9230
- /** Called each frame when object updates */
9802
+ /** @return {boolean} - Is the gamepad or keyboard navigation object */
9803
+ isNavigationObject() { return uiSystem.navigationObject === this; }
9804
+
9805
+ /** @return {boolean} - Can it be interacted with */
9806
+ isInteractive() { return this.interactive && this.visible && !this.disabled;}
9807
+
9808
+ /** Returns string containing info about this object for debugging
9809
+ * @return {string} */
9810
+ toString()
9811
+ {
9812
+ if (!debug) return;
9813
+
9814
+ let text = 'type = ' + this.constructor.name;
9815
+ if (this.text)
9816
+ text += '\ntext = ' + this.text;
9817
+ if (this.pos.x || this.pos.y)
9818
+ text += '\npos = ' + this.pos;
9819
+ if (this.localPos.x || this.localPos.y)
9820
+ text += '\localPos = ' + this.localPos;
9821
+ if (this.size.x || this.size.y)
9822
+ text += '\nsize = ' + this.size;
9823
+ if (this.color)
9824
+ text += '\ncolor = ' + this.color;
9825
+ return text;
9826
+ }
9827
+
9828
+ /** Called if uiDebug is enabled
9829
+ * @param {boolean} visible */
9830
+ renderDebug(visible=true)
9831
+ {
9832
+ // apply color based on state
9833
+ const color =
9834
+ !visible ? GREEN :
9835
+ this.isHoverObject() ? YELLOW :
9836
+ this.disabled ? PURPLE :
9837
+ this.interactive ? RED : BLUE;
9838
+ uiSystem.drawRect(this.pos, this.size, CLEAR_BLACK, 4, color);
9839
+ }
9840
+
9841
+ /** Called each frame before object updates */
9231
9842
  onUpdate() {}
9232
9843
 
9844
+ /** Called each frame before object renders */
9845
+ onRender() {}
9846
+
9233
9847
  /** Called when the mouse enters the object */
9234
9848
  onEnter() {}
9235
9849
 
@@ -9277,16 +9891,25 @@ class UIText extends UIObject
9277
9891
  this.align = align;
9278
9892
  this.font = font;
9279
9893
 
9280
- // make text not outlined by default
9281
- this.lineWidth = 0;
9282
9894
  // text can not be a hover object by default
9283
9895
  this.canBeHover = false;
9896
+
9897
+ // no background by default
9898
+ this.color = CLEAR_BLACK;
9899
+ this.shadowColor = CLEAR_BLACK;
9900
+ this.gradientColor = undefined;
9901
+ this.lineWidth = 0;
9902
+
9903
+ // use max fit scale by default
9904
+ this.textFitScale = 1;
9284
9905
  }
9285
9906
  render()
9286
9907
  {
9287
- // only render the text
9908
+ super.render();
9909
+
9910
+ // render the text
9288
9911
  const textSize = this.getTextSize();
9289
- uiSystem.drawText(this.text, this.pos, textSize, this.textColor, this.lineWidth, this.lineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
9912
+ 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);
9290
9913
  }
9291
9914
  }
9292
9915
 
@@ -9322,10 +9945,13 @@ class UITile extends UIObject
9322
9945
  this.mirror = mirror;
9323
9946
  // set properties
9324
9947
  this.color = color.copy();
9948
+
9949
+ // no shadow by default
9950
+ this.shadowColor = CLEAR_BLACK;
9325
9951
  }
9326
9952
  render()
9327
9953
  {
9328
- uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror);
9954
+ uiSystem.drawTile(this.pos, this.size, this.tileInfo, this.color, this.angle, this.mirror, this.shadowColor, this.shadowBlur, this.shadowOffset);
9329
9955
  }
9330
9956
  }
9331
9957
 
@@ -9350,6 +9976,9 @@ class UIButton extends UIObject
9350
9976
  ASSERT(isString(text), 'ui button must be a string');
9351
9977
  ASSERT(isColor(color), 'ui button color must be a color');
9352
9978
 
9979
+ /** @property {Vector2} - Text offset for the button */
9980
+ this.textOffset = vec2();
9981
+
9353
9982
  // set properties
9354
9983
  this.text = text;
9355
9984
  this.color = color.copy();
@@ -9361,8 +9990,8 @@ class UIButton extends UIObject
9361
9990
 
9362
9991
  // draw the text scaled to fit
9363
9992
  const textSize = this.getTextSize();
9364
- uiSystem.drawText(this.text, this.pos, textSize,
9365
- this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
9993
+ uiSystem.drawText(this.text, this.pos.add(this.textOffset), textSize,
9994
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
9366
9995
  }
9367
9996
  }
9368
9997
 
@@ -9416,7 +10045,7 @@ class UICheckbox extends UIObject
9416
10045
  const textSize = this.getTextSize();
9417
10046
  const pos = this.pos.add(vec2(this.size.x,0));
9418
10047
  uiSystem.drawText(this.text, pos, textSize,
9419
- this.textColor, 0, undefined, 'left', this.font, this.fontStyle, false, this.textShadow);
10048
+ this.textColor, this.textLineWidth, this.textLineColor, 'left', this.font, this.fontStyle, false, this.textShadow);
9420
10049
  }
9421
10050
  }
9422
10051
 
@@ -9458,7 +10087,11 @@ class UIScrollbar extends UIObject
9458
10087
  update()
9459
10088
  {
9460
10089
  super.update();
9461
- if (this.isActiveObject() && this.interactive)
10090
+ if (!this.interactive)
10091
+ return;
10092
+
10093
+ const oldValue = this.value;
10094
+ if (this.isActiveObject())
9462
10095
  {
9463
10096
  // handle horizontal or vertical scrollbar
9464
10097
  const isHorizontal = this.size.x > this.size.y;
@@ -9470,14 +10103,19 @@ class UIScrollbar extends UIObject
9470
10103
  const handleWidth = barSize - handleSize;
9471
10104
  const p1 = centerPos - handleWidth/2;
9472
10105
  const p2 = centerPos + handleWidth/2;
9473
- const oldValue = this.value;
9474
-
9475
10106
  const p = uiSystem.screenToNative(mousePosScreen);
9476
10107
  this.value = isHorizontal ?
9477
10108
  percent(p.x, p1, p2) :
9478
10109
  percent(p.y, p2, p1);
9479
- this.value === oldValue || this.onChange();
9480
10110
  }
10111
+ else if (this.isNavigationObject())
10112
+ {
10113
+ // gamepad/keyboard navigation adjustment
10114
+ const direction = uiSystem.getNavigationOtherDirection();
10115
+ if (!uiSystem.navigationTimer.active())
10116
+ this.value = clamp(this.value + direction*.01);
10117
+ }
10118
+ this.value === oldValue || this.onChange();
9481
10119
  }
9482
10120
  render()
9483
10121
  {
@@ -9502,7 +10140,14 @@ class UIScrollbar extends UIObject
9502
10140
  // draw the text scaled to fit on the scrollbar
9503
10141
  const textSize = this.getTextSize();
9504
10142
  uiSystem.drawText(this.text, this.pos, textSize,
9505
- this.textColor, 0, undefined, this.align, this.font, this.fontStyle, true, this.textShadow);
10143
+ this.textColor, this.textLineWidth, this.textLineColor, this.align, this.font, this.fontStyle, true, this.textShadow);
10144
+ }
10145
+ navigatePressed()
10146
+ {
10147
+ // toggle value between 0 and 1
10148
+ this.value = this.value ? 0 : 1;
10149
+ this.onRelease();
10150
+ super.navigatePressed();
9506
10151
  }
9507
10152
  }
9508
10153
 
@@ -9536,7 +10181,7 @@ class UIVideo extends UIObject
9536
10181
  this.color = BLACK; // default to black background
9537
10182
  this.cornerRadius = 0; // default to no corner radius
9538
10183
 
9539
- /** @property {float} - The video volume */
10184
+ /** @property {number} - The video volume */
9540
10185
  this.volume = volume;
9541
10186
 
9542
10187
  // create video element
@@ -9569,7 +10214,7 @@ class UIVideo extends UIObject
9569
10214
 
9570
10215
  /** Check if video is currently loading
9571
10216
  * @return {boolean} */
9572
- isLoadng()
10217
+ isLoading()
9573
10218
  { return this.video.readyState < this.video.HAVE_CURRENT_DATA; }
9574
10219
 
9575
10220
  /** Check if video is currently paused
@@ -9579,7 +10224,7 @@ class UIVideo extends UIObject
9579
10224
  /** Check if video is currently playing
9580
10225
  * @return {boolean} */
9581
10226
  isPlaying()
9582
- { return !this.isPaused() && !this.hasEnded() && !this.isLoadng(); }
10227
+ { return !this.isPaused() && !this.hasEnded() && !this.isLoading(); }
9583
10228
 
9584
10229
  /** Check if video has ended playing
9585
10230
  * @return {boolean} */
@@ -9628,7 +10273,7 @@ class UIVideo extends UIObject
9628
10273
  {
9629
10274
  super.render();
9630
10275
 
9631
- if (this.isLoadng())
10276
+ if (this.isLoading())
9632
10277
  return;
9633
10278
  const context = uiSystem.uiContext;
9634
10279
  const s = this.size;
@@ -11412,7 +12057,7 @@ class Box2dPlugin
11412
12057
  * @param {Vector2} v */
11413
12058
  vec2dTo(v)
11414
12059
  {
11415
- ASSERT(v instanceof Vector2);
12060
+ ASSERT(isVector2(v));
11416
12061
  return new box2d.instance.b2Vec2(v.x, v.y);
11417
12062
  }
11418
12063
 
@@ -11761,6 +12406,8 @@ export
11761
12406
  canvasColorTiles,
11762
12407
  canvasClearColor,
11763
12408
  canvasMaxSize,
12409
+ canvasMinAspect,
12410
+ canvasMaxAspect,
11764
12411
  canvasFixedSize,
11765
12412
  canvasPixelated,
11766
12413
  overlayCanvasPixelated,
@@ -11804,6 +12451,8 @@ export
11804
12451
  setCanvasColorTiles,
11805
12452
  setCanvasClearColor,
11806
12453
  setCanvasMaxSize,
12454
+ setCanvasMinAspect,
12455
+ setCanvasMaxAspect,
11807
12456
  setCanvasFixedSize,
11808
12457
  setCanvasPixelated,
11809
12458
  setOverlayCanvasPixelated,
@@ -11829,6 +12478,7 @@ export
11829
12478
  setInputWASDEmulateDirection,
11830
12479
  setTouchGamepadEnable,
11831
12480
  setTouchGamepadCenterButton,
12481
+ setTouchGamepadButtonCount,
11832
12482
  setTouchGamepadAnalog,
11833
12483
  setTouchGamepadSize,
11834
12484
  setTouchGamepadAlpha,
@@ -12000,12 +12650,14 @@ export
12000
12650
  mouseInWindow,
12001
12651
  isUsingGamepad,
12002
12652
  inputPreventDefault,
12653
+ gamepadPrimary,
12003
12654
  setInputPreventDefault,
12004
12655
  gamepadIsDown,
12005
12656
  gamepadWasPressed,
12006
12657
  gamepadWasReleased,
12007
12658
  gamepadStick,
12008
- gamepadsUpdate,
12659
+ gamepadDpad,
12660
+ gamepadConnected,
12009
12661
  vibrate,
12010
12662
  vibrateStop,
12011
12663
  isTouchDevice,
@@ -12071,6 +12723,8 @@ export
12071
12723
 
12072
12724
  // UI System
12073
12725
  uiSystem,
12726
+ uiDebug,
12727
+ uiSetDebug,
12074
12728
  UISystemPlugin,
12075
12729
  UIObject,
12076
12730
  UIText,