kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

touch-controls.js (11104B)


      1 // On-screen touch controls for phones / tablets.
      2 //
      3 // Games drive all their input through the keyboard (Phaser's keyboard plugin
      4 // listens on `window`). Phones have no physical keyboard and a Phaser canvas
      5 // can't summon the native one, so this module renders a DOM control bar whose
      6 // buttons dispatch synthetic KeyboardEvents to `window`. Every game's existing
      7 // key handling then works unchanged — no game logic touches this file.
      8 //
      9 // Usage (in a game's index.html, after game-shared.js + game.js):
     10 //   <script src="../../js/shared/touch-controls.js"></script>
     11 //   <script>KGames.initTouchControls({ layout: 'alphabet' });</script>
     12 //
     13 // Layouts: 'alphabet' (A–Z), 'text' (A–Z + Space/Backspace/Enter), 'text-nav'
     14 // (text + an arrow row, for menu-driven games like sound-soup), 'drive'
     15 // (arrows + Music/Siren/Lights/Spray for fire-truck).
     16 //
     17 // Shown only on touch / coarse-pointer devices. Force on/off with the URL
     18 // param ?kbd=1 / ?kbd=0 or window.__FORCE_TOUCH_CONTROLS__ (used by tests).
     19 //
     20 // Keys fire on pointerdown for snappy response. A touch pointerdown does NOT
     21 // grant user activation, so a game must not rely on its "first key press" to
     22 // start audio — use KGames.installAudioUnlock (game-shared.js), which resumes
     23 // the AudioContexts from the pointerup/touchend that follows.
     24 
     25 (function (root) {
     26   'use strict';
     27 
     28   root.KGames = root.KGames || {};
     29 
     30   // ── Visibility gate ─────────────────────────────────────────────────────────
     31 
     32   function shouldShow() {
     33     try {
     34       const params = new URLSearchParams(root.location.search);
     35       if (params.get('kbd') === '1') return true;
     36       if (params.get('kbd') === '0') return false;
     37     } catch (_) {}
     38     if (root.__FORCE_TOUCH_CONTROLS__ === true)  return true;
     39     if (root.__FORCE_TOUCH_CONTROLS__ === false) return false;
     40     const coarse = root.matchMedia && root.matchMedia('(pointer: coarse)').matches;
     41     const touch  = 'ontouchstart' in root || (root.navigator && root.navigator.maxTouchPoints > 0);
     42     return !!(coarse || touch);
     43   }
     44 
     45   // ── Synthetic key dispatch ──────────────────────────────────────────────────
     46 
     47   function dispatchKey(type, d) {
     48     const ev = new KeyboardEvent(type, {
     49       key:       d.key,
     50       code:      d.code,
     51       keyCode:   d.keyCode,
     52       which:     d.keyCode,
     53       bubbles:   true,
     54       cancelable: true,
     55     });
     56     root.dispatchEvent(ev);
     57   }
     58 
     59   // ── Key descriptors ─────────────────────────────────────────────────────────
     60 
     61   function letterKey(ch) {
     62     return { label: ch, key: ch, code: 'Key' + ch, keyCode: ch.charCodeAt(0) };
     63   }
     64 
     65   function alphabetRows() {
     66     const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(letterKey);
     67     return [A.slice(0, 9), A.slice(9, 18), A.slice(18, 26)];
     68   }
     69 
     70   function layoutRows(layout) {
     71     if (layout === 'alphabet') return alphabetRows();
     72 
     73     if (layout === 'text') {
     74       const rows = alphabetRows();
     75       rows.push([
     76         { label: '⌫',     key: 'Backspace', code: 'Backspace', keyCode: 8,  grow: 1.6, kind: 'special', aria: 'Backspace' },
     77         { label: 'space', key: ' ',         code: 'Space',     keyCode: 32, grow: 4,   kind: 'special', aria: 'Space' },
     78         { label: '⏎',     key: 'Enter',     code: 'Enter',     keyCode: 13, grow: 1.6, kind: 'special', aria: 'Enter' },
     79       ]);
     80       return rows;
     81     }
     82 
     83     if (layout === 'text-nav') {
     84       const rows = alphabetRows();
     85       rows.push([
     86         { label: '◀', key: 'ArrowLeft',  code: 'ArrowLeft',  keyCode: 37, grow: 1,   kind: 'special', aria: 'Left' },
     87         { label: '▲', key: 'ArrowUp',    code: 'ArrowUp',    keyCode: 38, grow: 1,   kind: 'special', aria: 'Up' },
     88         { label: '▼', key: 'ArrowDown',  code: 'ArrowDown',  keyCode: 40, grow: 1,   kind: 'special', aria: 'Down' },
     89         { label: '▶', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, grow: 1,   kind: 'special', aria: 'Right' },
     90         { label: '⌫',     key: 'Backspace', code: 'Backspace', keyCode: 8,  grow: 1.2, kind: 'special', aria: 'Backspace' },
     91         { label: 'space', key: ' ',         code: 'Space',     keyCode: 32, grow: 2.4, kind: 'special', aria: 'Space' },
     92         { label: '⏎',     key: 'Enter',     code: 'Enter',     keyCode: 13, grow: 1.2, kind: 'special', aria: 'Enter' },
     93       ]);
     94       return rows;
     95     }
     96 
     97     if (layout === 'drive') {
     98       return [
     99         [
    100           { label: '◀', key: 'ArrowLeft',  code: 'ArrowLeft',  keyCode: 37, grow: 1, kind: 'arrow', hold: true, aria: 'Turn left / aim left' },
    101           { label: '▲', key: 'ArrowUp',    code: 'ArrowUp',    keyCode: 38, grow: 1, kind: 'arrow', hold: true, aria: 'Go straight / aim up' },
    102           { label: '▼', key: 'ArrowDown',  code: 'ArrowDown',  keyCode: 40, grow: 1, kind: 'arrow', hold: true, aria: 'Aim down' },
    103           { label: '▶', key: 'ArrowRight', code: 'ArrowRight', keyCode: 39, grow: 1, kind: 'arrow', hold: true, aria: 'Turn right / aim right' },
    104         ],
    105         [
    106           { label: '🎵 Music',  key: 'm', code: 'KeyM', keyCode: 77, grow: 1, kind: 'special', aria: 'Music on/off' },
    107           { label: '🔊 Siren',  key: 's', code: 'KeyS', keyCode: 83, grow: 1, kind: 'special', aria: 'Siren' },
    108           { label: '💡 Lights', key: 'l', code: 'KeyL', keyCode: 76, grow: 1, kind: 'special', aria: 'Lights' },
    109           { label: '💦 Spray',  key: ' ', code: 'Space', keyCode: 32, grow: 1, kind: 'special', hold: true, aria: 'Spray water' },
    110         ],
    111       ];
    112     }
    113 
    114     return [];
    115   }
    116 
    117   // ── DOM construction ────────────────────────────────────────────────────────
    118 
    119   function makeKey(d) {
    120     const btn = document.createElement('button');
    121     btn.type = 'button';
    122     btn.tabIndex = -1;                       // keep focus off these — game listens on window
    123     btn.className = 'kg-key'
    124       + (d.kind === 'special' ? ' kg-key-special' : '')
    125       + (d.kind === 'arrow'   ? ' kg-key-arrow'   : '');
    126     btn.textContent = d.label;
    127     btn.setAttribute('aria-label', d.aria || d.label);
    128     if (d.grow) btn.style.flexGrow = String(d.grow);
    129 
    130     if (d.hold) {
    131       const down = (e) => {
    132         e.preventDefault();
    133         if (btn._down) return;
    134         btn._down = true;
    135         btn.classList.add('kg-active');
    136         dispatchKey('keydown', d);
    137       };
    138       const up = () => {
    139         if (!btn._down) return;
    140         btn._down = false;
    141         btn.classList.remove('kg-active');
    142         dispatchKey('keyup', d);
    143       };
    144       btn.addEventListener('pointerdown',   down);
    145       btn.addEventListener('pointerup',     up);
    146       btn.addEventListener('pointerleave',  up);
    147       btn.addEventListener('pointercancel', up);
    148     } else {
    149       // Tap: fire on pointerdown for snappy response (a full key press + release).
    150       btn.addEventListener('pointerdown', (e) => {
    151         e.preventDefault();
    152         btn.classList.add('kg-active');
    153         dispatchKey('keydown', d);
    154         dispatchKey('keyup', d);
    155         setTimeout(() => btn.classList.remove('kg-active'), 110);
    156       });
    157     }
    158     return btn;
    159   }
    160 
    161   function injectStyle() {
    162     if (document.getElementById('kg-touch-style')) return;
    163     const css = `
    164 #game-container { flex: 1 1 auto !important; height: auto !important; min-height: 0 !important; }
    165 #kg-touch {
    166   flex: 0 0 auto;
    167   width: 100%;
    168   background: rgba(18, 18, 32, 0.92);
    169   padding: 6px 5px calc(6px + env(safe-area-inset-bottom, 0px));
    170   display: flex;
    171   flex-direction: column;
    172   gap: 5px;
    173   user-select: none;
    174   -webkit-user-select: none;
    175   touch-action: none;
    176   z-index: 40;
    177 }
    178 #kg-touch .kg-row { display: flex; gap: 5px; justify-content: center; }
    179 #kg-touch .kg-key {
    180   flex: 1 1 0;
    181   min-width: 0;
    182   max-width: 72px;
    183   height: clamp(40px, 7.5vh, 60px);
    184   border: none;
    185   border-radius: 9px;
    186   background: #353553;
    187   color: #fff;
    188   font-family: 'Fredoka', sans-serif;
    189   font-weight: 600;
    190   font-size: clamp(17px, 4.4vw, 25px);
    191   line-height: 1;
    192   padding: 0;
    193   cursor: pointer;
    194   -webkit-tap-highlight-color: transparent;
    195   touch-action: none;
    196   transition: background 0.06s, transform 0.06s;
    197 }
    198 #kg-touch .kg-key.kg-key-special { background: #2c3a52; max-width: none; font-size: clamp(14px, 3.6vw, 20px); }
    199 #kg-touch .kg-key.kg-key-arrow   { max-width: none; height: clamp(52px, 11vh, 84px); font-size: clamp(26px, 8vw, 44px); }
    200 #kg-touch .kg-key.kg-active { background: #2EC4B6; transform: translateY(1px); }
    201 `;
    202     const style = document.createElement('style');
    203     style.id = 'kg-touch-style';
    204     style.textContent = css;
    205     document.head.appendChild(style);
    206   }
    207 
    208   function build(layout) {
    209     if (document.getElementById('kg-touch')) return;
    210     injectStyle();
    211 
    212     const bar = document.createElement('div');
    213     bar.id = 'kg-touch';
    214 
    215     layoutRows(layout).forEach((row) => {
    216       const rowEl = document.createElement('div');
    217       rowEl.className = 'kg-row';
    218       row.forEach((d) => rowEl.appendChild(makeKey(d)));
    219       bar.appendChild(rowEl);
    220     });
    221 
    222     // Block context menu (long-press) and scrolling on the bar itself.
    223     bar.addEventListener('contextmenu', (e) => e.preventDefault());
    224 
    225     document.body.appendChild(bar);
    226 
    227     // The bar shrinks #game-container. Phaser's Scale.RESIZE ignores a synthetic
    228     // window 'resize' when the window dimensions are unchanged, so it won't notice
    229     // the smaller parent on its own — call scale.refresh() directly. refresh()
    230     // re-measures the parent and emits the scale 'resize' event the scenes already
    231     // listen to, so the game canvas and layout both follow the keyboard.
    232     const nudge = () => {
    233       const game = root.__KG_GAME__;
    234       if (game && game.scale) { try { game.scale.refresh(); } catch (_) {} }
    235       root.dispatchEvent(new Event('resize'));
    236     };
    237     nudge();
    238     requestAnimationFrame(nudge);
    239     setTimeout(nudge, 250);
    240     setTimeout(nudge, 600);
    241 
    242     // The on-screen keyboard can change height across orientation changes; keep
    243     // the canvas sized to the space above it.
    244     if (root.ResizeObserver) {
    245       try { new ResizeObserver(nudge).observe(bar); } catch (_) {}
    246     }
    247     root.addEventListener('orientationchange', () => setTimeout(nudge, 100));
    248   }
    249 
    250   // ── Public entry ────────────────────────────────────────────────────────────
    251 
    252   function initTouchControls(opts) {
    253     const layout = (opts && opts.layout) || 'alphabet';
    254     if (!shouldShow()) return;
    255     if (document.readyState === 'loading') {
    256       document.addEventListener('DOMContentLoaded', () => build(layout));
    257     } else {
    258       build(layout);
    259     }
    260   }
    261 
    262   root.KGames.initTouchControls = initTouchControls;
    263 
    264 }(window));