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