PageSourceSearch

https://webcade.fun/touch.js?v=1025436e

js webcade.fun collected 2026-10-08 08:27:19 UTC 12,974 bytes, 286 lines download raw bytes

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.