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
36 changes: 7 additions & 29 deletions games/lofi-wizard.html
Original file line number Diff line number Diff line change
Expand Up @@ -159,13 +159,6 @@ <h1 class="sr-only">Lo-fi Wizard</h1>
<span id="now-playing">♪ radio off</span>
<span class="status" id="status-line">wizard: typing · cat: loafing</span>
</div>
<div class="presets" role="radiogroup" aria-label="Station">
<button type="button" class="preset" role="radio" aria-checked="true" tabindex="0">lo-fi</button>
<button type="button" class="preset" role="radio" aria-checked="false" tabindex="-1">tavern</button>
<button type="button" class="preset" role="radio" aria-checked="false" tabindex="-1">enchanted</button>
<button type="button" class="preset" role="radio" aria-checked="false" tabindex="-1">8-bit quest</button>
<button type="button" class="preset" role="radio" aria-checked="false" tabindex="-1">8-bit dungeon</button>
</div>
<div class="pickers">
<div class="picker">
<span class="pick-label" aria-hidden="true">scene</span>
Expand Down Expand Up @@ -215,6 +208,7 @@ <h1 class="sr-only">Lo-fi Wizard</h1>
</button>
<span aria-hidden="true">music</span>
</div>
<div class="knob-wrap" id="tuner"></div>
<label class="volume-field" for="volume">
<span class="led on" aria-hidden="true" style="visibility:hidden"></span>
<input type="range" id="volume" min="0" max="100" value="60" aria-label="Music volume">
Expand Down Expand Up @@ -970,18 +964,12 @@ <h1 class="sr-only">Lo-fi Wizard</h1>
const music = { get on() { return radio.isOn(); } }; // the scene only reads music.on
const nowPlayingEl = document.getElementById('now-playing');
const musicBtn = document.getElementById('btn-music');
const stationKeys = [...document.querySelectorAll('.radio-left > .presets .preset')];
function stationLabel() { return radio.stations[radio.station()].label; }

radio.onChange(() => {
nowPlayingEl.textContent = radio.isOn() ? `♪ ${stationLabel()}: ${radio.trackName()}` : '♪ radio off';
musicBtn.setAttribute('aria-pressed', String(radio.isOn()));
document.getElementById('led-music').classList.toggle('on', radio.isOn());
stationKeys.forEach((k, j) => {
const on = j === radio.station();
k.setAttribute('aria-checked', String(on));
k.tabIndex = on ? 0 : -1;
});
});

function setMusic(on) {
Expand All @@ -992,22 +980,12 @@ <h1 class="sr-only">Lo-fi Wizard</h1>
A11yUtils.announce(on ? `Music on. ${stationLabel()}: ${radio.trackName()}` : 'Music off', 'polite');
}

// Preset keys behave as a radio group: arrows move between stations
function pickStation(i) {
radio.setStation(i);
if (!radio.isOn()) setMusic(true);
else A11yUtils.announce(`${stationLabel()}: ${radio.trackName()}`, 'polite');
}
stationKeys.forEach((k, i) => {
k.addEventListener('click', () => pickStation(i));
k.addEventListener('keydown', e => {
const dir = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
if (!dir) return;
e.preventDefault();
const next = (i + dir + stationKeys.length) % stationKeys.length;
stationKeys[next].focus();
pickStation(next);
});
// The station dial sets the station itself; this runs after each turn
radio.mountTuner(document.getElementById('tuner'), {
onPick: () => {
if (!radio.isOn()) setMusic(true);
else A11yUtils.announce(`${stationLabel()}: ${radio.trackName()}`, 'polite');
}
});

// ---------- Controls ----------
Expand Down
58 changes: 58 additions & 0 deletions games/radio.css
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,61 @@
width: 100%;
}
}

/* Station dial: a metal knob that turns between detents, with a tick for each station */
.tuner-face {
position: relative;
width: 66px;
height: 66px;
}
.tuner-tick {
position: absolute;
left: 50%;
top: 50%;
width: 4px;
height: 4px;
margin: -2px;
border-radius: 50%;
background: var(--line);
transform: rotate(var(--a)) translateY(-31px);
}
.tuner-tick.on {
background: var(--hi);
box-shadow: 0 0 5px var(--hi);
}
.tuner-dial {
position: absolute;
inset: 7px;
border-radius: 50%;
border: 2px solid var(--line);
background: var(--metal-dial);
box-shadow: 0 4px 0 var(--shadow), inset 0 1px 0 rgba(255, 255, 255, 0.55), inset 0 -2px 3px rgba(0, 0, 0, 0.35);
transform: rotate(var(--a, 0deg));
transition: transform 140ms cubic-bezier(0.3, 1.4, 0.5, 1);
touch-action: none;
cursor: grab;
}
.tuner-dial::before {
content: '';
position: absolute;
left: 50%;
top: 3px;
width: 4px;
height: 13px;
margin-left: -2px;
border-radius: 2px;
background: var(--metal-ink);
}
.tuner-dial:hover { border-color: var(--hi); }
.tuner-dial:active { cursor: grabbing; }
.tuner-dial:focus-visible {
outline: 3px solid var(--hi);
outline-offset: 3px;
}
.tuner-name {
min-width: 7em;
text-align: center;
}
@media (prefers-reduced-motion: reduce) {
.tuner-dial { transition: none; }
}
92 changes: 92 additions & 0 deletions games/radio.js
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,99 @@
}
}

// Rotary station dial. host is an empty element; opts.order lists station ids
// around the dial (default: engine order); opts.onPick(stationIndex) runs after a
// user turn, click or key. The dial is a slider for keyboards and screen readers.
function mountTuner(host, opts) {
opts = opts || {};
const order = (opts.order || STATIONS.map(st => st.id)).map(id => STATIONS.findIndex(st => st.id === id));
const n = order.length, MIN = -120, SPAN = 240, STEP = SPAN / (n - 1);
const angle = k => MIN + k * STEP;

host.textContent = '';
const spacer = document.createElement('span');
spacer.className = 'led';
spacer.setAttribute('aria-hidden', 'true');
spacer.style.visibility = 'hidden';
const face = document.createElement('div');
face.className = 'tuner-face';
const ticks = order.map((_, k) => {
const t = document.createElement('span');
t.className = 'tuner-tick';
t.style.setProperty('--a', angle(k) + 'deg');
face.appendChild(t);
return t;
});
const dial = document.createElement('div');
dial.className = 'tuner-dial';
dial.tabIndex = 0;
dial.setAttribute('role', 'slider');
dial.setAttribute('aria-label', 'Station');
dial.setAttribute('aria-valuemin', '0');
dial.setAttribute('aria-valuemax', String(n - 1));
dial.title = 'Station: drag, click or use arrow keys';
face.appendChild(dial);
const name = document.createElement('span');
name.className = 'tuner-name';
name.setAttribute('aria-hidden', 'true');
host.append(spacer, face, name);

const current = () => Math.max(0, order.indexOf(music.station));
function paint() {
const k = current();
const label = STATIONS[order[k]].label;
dial.style.setProperty('--a', angle(k) + 'deg');
dial.setAttribute('aria-valuenow', String(k));
dial.setAttribute('aria-valuetext', label);
name.textContent = label;
ticks.forEach((t, j) => t.classList.toggle('on', j === k));
}
function pick(k) {
k = Math.max(0, Math.min(n - 1, k));
if (k !== current()) setStation(order[k]);
if (opts.onPick) opts.onPick(order[k]);
}

let dragging = false, moved = false, startX = 0, startY = 0, lastDeg = null;
dial.addEventListener('pointerdown', e => {
dragging = true; moved = false; lastDeg = null;
startX = e.clientX; startY = e.clientY;
dial.setPointerCapture(e.pointerId);
});
dial.addEventListener('pointermove', e => {
if (!dragging) return;
if (Math.hypot(e.clientX - startX, e.clientY - startY) > 5) moved = true;
if (!moved) return;
const r = dial.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2), dy = e.clientY - (r.top + r.height / 2);
if (Math.hypot(dx, dy) < 6) return;
const deg = Math.atan2(dx, -dy) * 180 / Math.PI; // 0 = up, clockwise positive
// The dial has a stop at the bottom: ignore a jump across it
if (lastDeg !== null && Math.abs(deg - lastDeg) > 180) return;
lastDeg = deg;
const k = Math.round((Math.max(MIN, Math.min(MIN + SPAN, deg)) - MIN) / STEP);
if (k !== current()) pick(k);
});
const endDrag = e => {
if (!dragging) return;
dragging = false;
if (e.type === 'pointerup' && !moved) pick((current() + 1) % n); // a click steps to the next station
};
dial.addEventListener('pointerup', endDrag);
dial.addEventListener('pointercancel', endDrag);
dial.addEventListener('keydown', e => {
const k = current();
const next = { ArrowRight: k + 1, ArrowUp: k + 1, ArrowLeft: k - 1, ArrowDown: k - 1, Home: 0, End: n - 1 }[e.key];
if (next !== undefined) { e.preventDefault(); pick(next); }
else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(k); }
});

listeners.push(paint);
paint();
}

window.OWTRadio = {
mountTuner,
stations: STATIONS.map(st => ({ id: st.id, label: st.label })),
setOn,
isOn: () => music.on,
Expand Down
33 changes: 7 additions & 26 deletions games/spellcheck.html
Original file line number Diff line number Diff line change
Expand Up @@ -201,10 +201,6 @@ <h1 class="sr-only">Spellcheck: a wizard typing game</h1>
<span class="stats" id="stats">wave 1 · score 0 · 0 wpm</span>
<span id="now-playing">♪ radio off</span>
</div>
<div class="preset-row">
<span class="row-label" aria-hidden="true">station</span>
<div class="presets" role="radiogroup" aria-label="Music station" id="station-keys"></div>
</div>
<div class="preset-row">
<span class="row-label" aria-hidden="true">words</span>
<div class="presets" role="radiogroup" aria-label="Word set" id="word-keys">
Expand Down Expand Up @@ -267,6 +263,7 @@ <h1 class="sr-only">Spellcheck: a wizard typing game</h1>
</button>
<span aria-hidden="true">music</span>
</div>
<div class="knob-wrap" id="tuner"></div>
<div class="knob-wrap">
<span class="led on" id="led-sfx" aria-hidden="true"></span>
<button type="button" class="knob" id="btn-sfx" aria-pressed="true" aria-label="Sound effects" title="Sound effects on / off">
Expand Down Expand Up @@ -1386,30 +1383,12 @@ <h1 class="sr-only">Spellcheck: a wizard typing game</h1>
const nowPlayingEl = document.getElementById('now-playing');
const musicBtn = document.getElementById('btn-music');
const stationOrder = ['chip-quest', 'chip-dungeon', 'tavern', 'forest', 'lofi'];
const stationBox = document.getElementById('station-keys');
const stationKeys = stationOrder.map((id, i) => {
const st = radio.stations.find(s => s.id === id);
const b = document.createElement('button');
b.type = 'button';
b.className = 'preset';
b.setAttribute('role', 'radio');
b.textContent = st.label;
b.dataset.id = id;
stationBox.appendChild(b);
return b;
});
radio.setStation('chip-quest');
function stationLabel() { return radio.stations[radio.station()].label; }
function syncRadio() {
nowPlayingEl.textContent = radio.isOn() ? `♪ ${stationLabel()}: ${radio.trackName()}` : '♪ radio off';
musicBtn.setAttribute('aria-pressed', String(radio.isOn()));
document.getElementById('led-music').classList.toggle('on', radio.isOn());
const current = radio.stations[radio.station()].id;
stationKeys.forEach(k => {
const on = k.dataset.id === current;
k.setAttribute('aria-checked', String(on));
k.tabIndex = on ? 0 : -1;
});
}
radio.onChange(syncRadio);
syncRadio();
Expand All @@ -1433,10 +1412,12 @@ <h1 class="sr-only">Spellcheck: a wizard typing game</h1>
});
});
}
radioGroup(stationKeys, i => {
radio.setStation(stationKeys[i].dataset.id);
if (!radio.isOn()) setMusic(true);
else A11yUtils.announce(`${stationLabel()}: ${radio.trackName()}`, 'polite');
radio.mountTuner(document.getElementById('tuner'), {
order: stationOrder,
onPick: () => {
if (!radio.isOn()) setMusic(true);
else A11yUtils.announce(`${stationLabel()}: ${radio.trackName()}`, 'polite');
}
});

const paceKeys = [...document.querySelectorAll('#pace-keys .preset')];
Expand Down
Loading