Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions assets/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
171 changes: 170 additions & 1 deletion assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,11 @@ for (const btn of padDpad.querySelectorAll("[data-intent]")) {
// appended. `hold` buttons emit `<id>` on press and `<id>: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 }));
Expand Down Expand Up @@ -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 = `
<div class="steer" aria-label="Steering">
<button class="steer__tilt" type="button" aria-pressed="false">Tilt: off</button>
<div class="steer__track"><span class="steer__knob"></span></div>
<div class="steer__hint">Drag to steer</div>
</div>
<div class="pedals" role="group" aria-label="Pedals">
<button class="pedal pedal--gas" data-pedal="gas" type="button">GAS</button>
<button class="pedal pedal--brake" data-pedal="brake" type="button">BRAKE</button>
<button class="pedal pedal--drift" data-pedal="drift" type="button">DRIFT</button>
</div>`;
// 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();
Expand Down
15 changes: 15 additions & 0 deletions assets/js/session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading