kgames

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

touch-controls.js (9626B)


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