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));