1// The mobile control surface: a thumbstick that appears wherever your thumb 2// lands, in a band that belongs to the controls alone. 3// 4// What this replaces, and why. The old pad was five 58px buttons pinned over 5// the page â so a thumb had to FIND them, the gaps between quadrants swallowed 6// presses, and a press that missed landed on whatever the page had underneath 7// (the high-score table, a player link) and navigated out of the run. Testers 8// hit all three. 9// 10// A floating stick has no target to miss: touch anywhere in the band and the 11// stick is under your thumb, wherever that was. Drag to steer, release to 12// stop. Players who want the old pad keep it â the preference lives in 13// localStorage and both modes drive the SAME handler, so a cabinet never 14// learns which one is in use. 15// 16// Directions are emitted as transitions â release the old, press the new â 17// which is what makes one module serve both kinds of cabinet: a hold game 18// (SWARM strafing) reads the press and the release, and a tap game (CHOMPER 19// turning) ignores the release and just takes the new heading. 20(() => { 21 const PREF = 'webcade-controls'; // 'stick' | 'dpad' 22 const DEAD = 16; // px of slack before a drag is a direction 23 const SNAP4 = ['right', 'down', 'left', 'up']; 24 25 const read = () => { try { return localStorage.getItem(PREF) || 'stick'; } catch (_) { return 'stick'; } }; 26 const write = v => { try { localStorage.setItem(PREF, v); } catch (_) {} }; 27 28 const canBuzz = typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function'; 29 const buzz = () => { if (canBuzz) { try { navigator.vibrate(8); } catch (_) {} } }; 30 31 let cfg = null; // { dir(d,on), fireFn(on), mode: 'full'|'lr'|'none', fire: label } 32 let zone = null, base = null, knob = null; 33 let touchId = null, ox = 0, oy = 0, held = null; 34 35 // Which direction a drag means. Four-way cabinets snap to the nearest 36 // quadrant; left/right cabinets ignore the vertical entirely, so a thumb 37 // that drifts up while strafing does not stop the ship. 38 function directionOf(dx, dy) { 39 if (cfg.mode === 'lr') { 40 if (Math.abs(dx) < DEAD) return null; 41 return dx > 0 ? 'right' : 'left'; 42 } 43 // Left, right and down, but never up. For a cabinet that jumps with a 44 // button: with up on the stick as well, a thumb drifting up-left while 45 // backing away snaps to 'up' and jumps, and a fighter who jumps when you 46 // meant to walk is worse than one you cannot launch from the stick at all. 47 // Half the travel is left and half is right, split down the middle, with 48 // only enough slack to survive a resting thumb's jitter. No quadrants, no 49 // dead zone to push past: a fighter is nearly always holding a direction, 50 // and the moment of nothing-happening while a drag crossed a 16px deadband 51 // was most of what made this feel unresponsive. 52 if (cfg.mode === 'lr50') { 53 if (Math.abs(dx) < 4) return null; 54 return dx > 0 ? 'right' : 'left'; 55 } 56 // lr50, plus a bottom. Left and right still split down the middle with 57 // almost no deadband, because a fighter is nearly always holding one of 58 // them and a drag that does nothing for its first 16px is what made the 59 // old quadrant snap feel dead. Push clearly DOWN, though, and it is down: 60 // a platform fighter needs it for dropping through, and with the pad 61 // hidden behind the stick there was nowhere else for it to live. 62 if (cfg.mode === 'lr50d') { 63 if (dy > 20 && dy > Math.abs(dx)) return 'down'; 64 if (Math.abs(dx) < 4) return null; 65 return dx > 0 ? 'right' : 'left'; 66 } 67 if (cfg.mode === 'lrd') { 68 const d = directionOf4(dx, dy); 69 return d === 'up' ? null : d; 70 } 71 // An isometric pyramid is not laid out on the compass the stick reads. 72 // ZIGGURAT's four hops go up-right, down-right, down-left and up-left on 73 // screen, and the cabinet calls them up, right, down and left â the classic 74 // Q*bert scheme, forty-five degrees off the world it is drawn in. On a 75 // keyboard that is a convention players learn. Under a thumb it is simply 76 // wrong: push right and Q*bert goes DOWN-right, push down and he goes 77 // down-LEFT, which reads as inverted because half of it is. 78 // 79 // So the snap is turned to match the pyramid. The thumb points at the cube 80 // it wants and the name that reaches the cabinet is whichever one hops 81 // there. 82 if (cfg.diag) return directionOfDiag(dx, dy); 83 return directionOf4(dx, dy); 84 } 85 86 // Indexed from the down-right diagonal, going clockwise on screen. 87 const DIAG = ['right', 'down', 'left', 'up']; 88 function directionOfDiag(dx, dy) { 89 if (Math.hypot(dx, dy) < DEAD) return null; 90 const a = Math.atan2(dy, dx); // -PI..PI, y down 91 const i = Math.round((a - Math.PI / 4) / (Math.PI / 2)); 92 return DIAG[((i % 4) + 4) % 4]; 93 } 94 95 function directionOf4(dx, dy) { 96 if (Math.hypot(dx, dy) < DEAD) return null; 97 const a = Math.atan2(dy, dx); // -PI..PI, y down 98 const i = Math.round(a / (Math.PI / 2)); // -2..2 99 return SNAP4[((i % 4) + 4) % 4]; 100 } 101 102 // One direction at a time, as transitions. Anything the cabinet does not 103 // want (a 'down' on an lr game) simply never gets emitted. 104 function steer(next) { 105 if (next === held) return; 106 if (held) cfg.dir(held, false); 107 held = next; 108 if (next) { cfg.dir(next, true); buzz(); } 109 } 110 111 // Two axes at once, for a cabinet that needs both. 112 // 113 // steer() holds exactly one direction: it releases the old before pressing 114 // the new, which is right for a maze or a fighter and wrong for a ship. DRIFT 115 // turns with left/right and thrusts with up, and turning WHILE thrusting is 116 // the whole game â on one held direction you can do one or the other. Worse, 117 // the quadrant snap gave the entire lower half of the stick to 'down', which 118 // DRIFT ignores, so a thumb drifting low did not merely do nothing: it 119 // released the rotation the player was holding. That is the "not registering" 120 // â the stick was actively cancelling them. 121 // 122 // Horizontal and vertical are tracked separately here, so up-and-left is 123 // simply both. Down is never emitted: no cabinet in this mode uses it, and 124 // an unused direction that still cancels its opposite is the bug above. 125 let heldH = null, heldV = null; 126 function steerAxes(h, v) { 127 if (h !== heldH) { 128 if (heldH) cfg.dir(heldH, false); 129 heldH = h; 130 if (h) { cfg.dir(h, true); buzz(); } 131 } 132 if (v !== heldV) { 133 if (heldV) cfg.dir(heldV, false); 134 heldV = v; 135 if (v) { cfg.dir(v, true); buzz(); } 136 } 137 } 138 // A small horizontal deadband and a larger vertical one. Rotation should 139 // start almost at once, but thrust must not fire from a thumb that merely 140 // slid a little high while turning. 141 const DEAD_H = 6, DEAD_V = 16; 142 function axesOf(dx, dy) { 143 return [ 144 Math.abs(dx) < DEAD_H ? null : (dx > 0 ? 'right' : 'left'), 145 dy < -DEAD_V ? 'up' : null, 146 ]; 147 } 148 function clearAll() { 149 steer(null); 150 steerAxes(null, null); 151 } 152 153 // Where to DRAW, on a page that may have been turned. 154 // 155 // toContent already turns the direction math, but the stick also has to be 156 // put somewhere, and that is a different problem in the same disguise. 157 // Touches arrive in viewport coordinates; style.left/top are in the 158 // element's own frame, which under Phantom orient.js has rotated ninety 159 // degrees. Subtracting a rotated bounding rect from an unrotated point is 160 // not a coordinate conversion, and the result was a stick that appeared 161 // somewhere the thumb was not. 162 // 163 // main is rotate(90deg) about its own origin, so a visual point (x, y) sits 164 // at (y, right - x) in the element's frame. Derived rather than guessed: 165 // the local corner (zx, zy+zh) is what lands at the visual top-left, which 166 // is why the vertical term counts back from the rect's RIGHT edge. 167 function place(el, x, y) { 168 const r = zone.getBoundingClientRect(); 169 if (document.body.classList.contains('phantom-landscape')) { 170 el.style.left = (y - r.top) + 'px'; 171 el.style.top = (r.right - x) + 'px'; 172 return; 173 } 174 el.style.left = (x - r.left) + 'px'; 175 el.style.top = (y - r.top) + 'px'; 176 } 177 178 function onStart(e) { 179 if (touchId !== null) return; 180 const t = e.changedTouches ? e.changedTouches[0] : e; 181 touchId = e.changedTouches ? t.identifier : 'mouse'; 182 ox = t.clientX; oy = t.clientY; 183 place(base, ox, oy); place(knob, ox, oy); 184 zone.classList.add('live'); 185 e.preventDefault(); 186 } 187 188 function currentTouch(e) { 189 if (!e.changedTouches) return e; 190 for (const t of e.changedTouches) if (t.identifier === touchId) return t; 191 return null; 192 } 193 194 // On a page whose content has been turned ninety degrees (orient.js does this 195 // in wallet browsers that will not rotate), a drag is still measured in 196 // SCREEN pixels while the player is pushing against a picture that is not. 197 // Their "right" is a downward drag on the glass. Undo the rotation here, so 198 // everything below thinks in the frame the player is actually looking at. 199 // screen (sx, sy) comes from content (cx, cy) as sx = W - cy, sy = cx 200 // so dcx = dsy and dcy = -dsx 201 function toContent(dx, dy) { 202 if (!document.body.classList.contains('phantom-landscape')) return [dx, dy]; 203 return [dy, -dx]; 204 } 205 206 function onMove(e) { 207 if (touchId === null) return; 208 const t = currentTouch(e); 209 if (!t) return; 210 const rawX = t.clientX - ox, rawY = t.clientY - oy; 211 const [dx, dy] = toContent(rawX, rawY);
212 // the knob rides the thumb but stays inside its ring 213 const max = 42, d = Math.hypot(rawX, rawY); 214 const k = d > max ? max / d : 1; 215 place(knob, ox + rawX * k, oy + rawY * k); 216 if (cfg && cfg.axes) { const [h, v] = axesOf(dx, dy); steerAxes(h, v); } 217 else steer(directionOf(dx, dy)); 218 e.preventDefault(); 219 } 220 221 function onEnd(e) { 222 if (touchId === null) return; 223 if (e.changedTouches && !currentTouch(e)) return; 224 touchId = null; 225 clearAll(); 226 zone.classList.remove('live'); 227 e.preventDefault(); 228 } 229 230 // ------------------------------------------------------------ mounting 231 232 function build() { 233 if (zone) { reparent(); return; } 234 zone = document.createElement('div'); 235 zone.id = 'stick-zone'; 236 base = document.createElement('div'); base.className = 'stick-base'; 237 knob = document.createElement('div'); knob.className = 'stick-knob'; 238 zone.append(base, knob); 239 // The arcade cabinets all have the same control band; CRYPTO SMASH has its 240 // own, and in Phantom's turned frame that band is inside the rotated 241 // element â appending to <body> there would leave the stick sideways and 242 // in the wrong half of the screen. 243 const deck = (cfg && cfg.host && document.querySelector(cfg.host)) 244 || document.getElementById('control-deck-live') || document.body; 245 deck.appendChild(zone); 246 247 zone.addEventListener('touchstart', onStart, { passive: false }); 248 zone.addEventListener('touchmove', onMove, { passive: false }); 249 zone.addEventListener('touchend', onEnd, { passive: false }); 250 zone.addEventListener('touchcancel', onEnd, { passive: false }); 251 } 252 253 // A second cabinet on the same page load asks for a different host, and the 254 // zone is built once â so move it rather than leaving it in the first one. 255 function reparent() { 256 if (!zone || !cfg || !cfg.host) return; 257 const want = document.querySelector(cfg.host); 258 if (want && zone.parentNode !== want) want.appendChild(zone); 259 } 260 261 // Apply the current preference. Called on mount and whenever the player 262 // flips the toggle mid-session. 263 function apply() { 264 const stick = read() === 'stick' && cfg && cfg.mode !== 'none'; 265 document.body.classList.toggle('ctrl-stick', !!stick); 266 document.body.classList.toggle('ctrl-dpad', !stick); 267 // A cabinet with no FIRE has no reason to keep the right of the band 268 // empty â the stick takes the whole width, so any thumb finds it. 269 document.body.classList.toggle('no-fire', !(cfg && cfg.fire)); 270 if (stick) build(); 271 if (zone) zone.classList.toggle('hidden', !stick); 272 // a mode change mid-hold must not leave a key stuck down 273 if (!stick) clearAll(); 274 } 275 276 window.ARCADE_TOUCH = { 277 // cfg.dir / cfg.fireFn are the cabinet's own handlers â the same ones the 278 // d-pad drives, so nothing downstream knows which surface was used. 279 use(next) { cfg = next; apply(); }, 280 get mode() { return read(); }, 281 set mode(v) { write(v === 'dpad' ? 'dpad' : 'stick'); apply(); }, 282 toggle() { this.mode = read() === 'stick' ? 'dpad' : 'stick'; return read(); }, 283 // called when a run ends, so nothing stays held into the next one 284 release() { clearAll(); touchId = null; if (zone) zone.classList.remove('live'); }, 285 }; 286})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.