diff --git a/assets/css/style.css b/assets/css/style.css index ec4f41c..45d796a 100644 --- a/assets/css/style.css +++ b/assets/css/style.css @@ -161,3 +161,48 @@ html, body { } .pad__log { text-align: center; color: var(--muted); min-height: 1.2em; } + +/* ---- Analog driving pad (profile:"analog") ---- */ +.pad__analog { width: 100%; display: flex; flex-direction: column; gap: 1.1rem; } +.pad__analog[hidden] { display: none; } + +.steer { position: relative; display: flex; flex-direction: column; gap: 0.5rem; } +.steer__tilt { + align-self: flex-end; + background: transparent; color: var(--muted); + border: 1px solid #232744; border-radius: 999px; padding: 0.35em 0.9em; + font-size: 0.85rem; font-weight: 600; +} +.steer__tilt[aria-pressed="true"] { color: var(--text); border-color: var(--accent); background: rgba(109,123,255,0.14); } +/* The drag surface. touch-action:none so steering never scrolls the page. */ +.steer__track { + position: relative; width: 100%; height: 86px; + border-radius: 20px; background: var(--panel); + border: 1px solid #1c2040; touch-action: none; overflow: hidden; +} +/* Centre detent so "straight" is visible at a glance. */ +.steer__track::before { + content: ""; position: absolute; top: 12%; bottom: 12%; left: 50%; + width: 2px; transform: translateX(-50%); background: #232744; +} +.steer__knob { + position: absolute; top: 50%; left: 50%; + width: 62px; height: 62px; transform: translate(-50%, -50%); + border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2)); + box-shadow: 0 6px 16px rgba(0,0,0,0.4); pointer-events: none; +} +.steer__hint { text-align: center; color: var(--muted); font-size: 0.8rem; } +.steer.is-tilt .steer__hint::after, +.pad__analog.is-tilt .steer__hint { color: var(--accent); } +.pad__analog.is-tilt .steer__track { opacity: 0.6; } + +.pedals { display: grid; grid-template-columns: 1.2fr 1.2fr 1fr; gap: 0.7rem; } +.pedal { + height: 104px; border: none; border-radius: 20px; + font-size: 1.15rem; font-weight: 800; letter-spacing: 0.02em; color: white; + touch-action: none; +} +.pedal--gas { background: linear-gradient(135deg, #16a34a, #38e8a0); } +.pedal--brake { background: linear-gradient(135deg, #d31027, #ea384d); } +.pedal--drift { background: linear-gradient(135deg, #f5af19, #f12711); font-size: 1rem; } +.pedal.is-active { filter: brightness(1.25); transform: translateY(1px); } diff --git a/assets/js/app.js b/assets/js/app.js index 72f4dbb..665fea6 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -126,9 +126,11 @@ for (const btn of padDpad.querySelectorAll("[data-intent]")) { // appended. `hold` buttons emit `` on press and `:release` on release // (e.g. pinball flippers). function renderControls(ctl) { - const profile = ctl && ctl.profile === "buttons" ? "buttons" : "dpad"; + const profile = + ctl && (ctl.profile === "buttons" || ctl.profile === "analog") ? ctl.profile : "dpad"; const buttons = (ctl && ctl.buttons) || []; padDpad.hidden = profile !== "dpad"; + setAnalog(profile === "analog"); // steering track + gas/brake pedals for driving games padActions.innerHTML = ""; for (const b of buttons) padActions.appendChild(makeAction(b)); padActions.appendChild(makeAction({ id: "back", label: "Back", sys: true })); @@ -157,6 +159,173 @@ function makeAction(b) { return el; } +// ---- Analog driving pad ---------------------------------------------------- +// For driving games (profile:"analog"). A steering track the left thumb drags +// (-1..1, self-centering) plus gas/brake/drift pedals for the right thumb, sent +// as a continuous frame via session.sendAnalog(). An optional tilt mode steers +// from the phone's gyroscope instead of the track. The declared aux buttons +// (power-up, reset, …) still render in padActions alongside. +const padControls = document.querySelector(".pad__controls"); +let analog = null; // lazily-built DOM + wiring +const drive = { steer: 0, gas: false, brake: false, drift: false }; +let driveDirty = false; // a frame is pending; coalesced to one send per rAF +let driveActive = false; // analog profile is showing → run the send loop +let steerPointer = null; // active steering pointer id (null = not steering) +let tiltOn = false; +let tiltCenter = null; // gyro reading captured as "straight" when tilt enables + +function setAnalog(on) { + if (on) ensureAnalog(); + if (analog) analog.root.hidden = !on; + if (on && !driveActive) { + driveActive = true; + requestAnimationFrame(driveLoop); + } else if (!on && driveActive) { + driveActive = false; + resetDrive(); + disableTilt(); + session.sendAnalog(0, 0, 0, false); // release the car when leaving the pad + } +} + +function resetDrive() { + drive.steer = 0; drive.gas = false; drive.brake = false; drive.drift = false; + steerPointer = null; + if (analog) { + setKnob(0); + for (const p of analog.pedals) p.el.classList.remove("is-active"); + } +} + +// One coalesced frame per animation tick while the car has focus — enough for +// smooth steering without flooding the data channel. +function driveLoop() { + if (!driveActive) return; + if (driveDirty) { + const throttle = drive.gas ? 1 : drive.brake ? -1 : 0; + session.sendAnalog(drive.steer, throttle, drive.brake ? 1 : 0, drive.drift); + driveDirty = false; + } + requestAnimationFrame(driveLoop); +} +function markDrive() { driveDirty = true; } + +function setKnob(steer) { + drive.steer = Math.max(-1, Math.min(1, steer)); + if (analog) analog.knob.style.left = `${(drive.steer * 0.5 + 0.5) * 100}%`; +} + +function steerFromEvent(e) { + const rect = analog.track.getBoundingClientRect(); + const rel = rect.width ? (e.clientX - rect.left) / rect.width : 0.5; + setKnob(rel * 2 - 1); + markDrive(); +} + +function ensureAnalog() { + if (analog) return; + const root = document.createElement("div"); + root.className = "pad__analog"; + root.innerHTML = ` +
+ +
+
Drag to steer
+
+
+ + + +
`; + // Insert the driving surface above the aux action buttons. + padControls.insertBefore(root, padActions); + + const track = root.querySelector(".steer__track"); + const knob = root.querySelector(".steer__knob"); + const tilt = root.querySelector(".steer__tilt"); + + // Steering: drag anywhere on the track. One pointer owns steering at a time so + // a second thumb on the pedals doesn't hijack it. + track.addEventListener("pointerdown", (e) => { + if (tiltOn) return; // gyro owns steering + e.preventDefault(); + steerPointer = e.pointerId; + try { track.setPointerCapture(e.pointerId); } catch { /* older browser */ } + steerFromEvent(e); + }); + track.addEventListener("pointermove", (e) => { + if (steerPointer === e.pointerId) steerFromEvent(e); + }); + const endSteer = (e) => { + if (steerPointer !== e.pointerId) return; + steerPointer = null; + if (!tiltOn) { setKnob(0); markDrive(); } // self-center on release + }; + track.addEventListener("pointerup", endSteer); + track.addEventListener("pointercancel", endSteer); + + // Pedals: hold to engage. Capture the pointer so sliding off the button still + // releases cleanly on lift. + const pedals = []; + for (const el of root.querySelectorAll(".pedal")) { + const key = el.dataset.pedal; + pedals.push({ el, key }); + el.addEventListener("pointerdown", (e) => { + e.preventDefault(); + try { el.setPointerCapture(e.pointerId); } catch { /* older browser */ } + drive[key] = true; el.classList.add("is-active"); markDrive(); + }); + const off = () => { if (drive[key]) { drive[key] = false; el.classList.remove("is-active"); markDrive(); } }; + el.addEventListener("pointerup", off); + el.addEventListener("pointercancel", off); + } + + tilt.addEventListener("click", () => toggleTilt(tilt)); + + analog = { root, track, knob, tilt, pedals }; +} + +// ---- Tilt (gyro) steering -------------------------------------------------- +function onTilt(e) { + if (e.gamma === null || e.gamma === undefined) return; + if (tiltCenter === null) tiltCenter = e.gamma; // first reading = "straight" + const SENS = 35; // degrees of tilt for full lock + setKnob((e.gamma - tiltCenter) / SENS); + markDrive(); +} + +async function toggleTilt(btn) { + if (tiltOn) { disableTilt(); return; } + // iOS 13+ gates motion sensors behind an explicit permission prompt. + try { + const DOE = window.DeviceOrientationEvent; + if (DOE && typeof DOE.requestPermission === "function") { + const res = await DOE.requestPermission(); + if (res !== "granted") { padLog.textContent = "Tilt needs motion access"; return; } + } + } catch { padLog.textContent = "Tilt unavailable"; return; } + tiltCenter = null; + tiltOn = true; + steerPointer = null; + window.addEventListener("deviceorientation", onTilt); + btn.textContent = "Tilt: on"; + btn.setAttribute("aria-pressed", "true"); + if (analog) analog.root.classList.add("is-tilt"); +} + +function disableTilt() { + if (!tiltOn) return; + tiltOn = false; + tiltCenter = null; + window.removeEventListener("deviceorientation", onTilt); + setKnob(0); markDrive(); + if (analog) { + analog.root.classList.remove("is-tilt"); + analog.tilt.textContent = "Tilt: off"; + analog.tilt.setAttribute("aria-pressed", "false"); + } +} + leaveBtn.addEventListener("click", () => { joinStatus.textContent = ""; session.disconnect(); diff --git a/assets/js/session.js b/assets/js/session.js index 024f9a7..7202fb3 100644 --- a/assets/js/session.js +++ b/assets/js/session.js @@ -114,6 +114,21 @@ export class ControllerSession extends EventTarget { this.dispatchEvent(new CustomEvent("sent", { detail: { intent } })); } + /** + * Send a continuous analog driving frame to the screen (steering games). Shares + * the intents channel using a compact JSON envelope `{t:"a",s,g,b,h}` the TV + * distinguishes from plain intent strings. Values are rounded to 2 decimals to + * keep frames small since these fire many times a second while steering. + * steer/throttle: -1..1 brake: 0..1 handbrake: bool + */ + sendAnalog(steer, throttle, brake, handbrake) { + if (!(this.connected && this._dc && this._dc.readyState === "open")) return; + const r = (v) => Math.round((Number(v) || 0) * 100) / 100; + this._dc.send(JSON.stringify({ + t: "a", s: r(steer), g: r(throttle), b: r(brake), h: handbrake ? 1 : 0, + })); + } + disconnect() { this._intentional = true; // an explicit Leave — forget the room, don't rejoin this._teardown();