Bundle drum_highway_3d + keys_highway_3d as in-tree core plugins (#693)

* Initial commit — clean relaunch

* Initial commit — clean relaunch

* Remove external game/format terminology from docs and code

Reword references to the external game and its proprietary file formats
in comments, docstrings, UI text and identifiers; no behaviour change.

* Repoint dead slopsmith URLs -> got-feedback

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(midi): consume core midi-input domain + session robustness fixes (#2)

* feat(midi): consume the core midi-input domain instead of private requestMIDIAccess (#881)

Route Web-MIDI device access through window.slopsmith.midiInput
(discover/list/select/open/close) rather than a private
navigator.requestMIDIAccess(). One shared device-access boundary with
piano/drums/onboarding; retires this plugin's private Web-MIDI. The
note-detection 'midi' exact-verdict provider role and the audio-input
source export are preserved (now backed by midi-input source data). Saved
pick ({id,name}) stays compatible — domain sourceId == the old MIDIInput.id.
Live listener is the domain handle's addListener/removeListener; the async
init/resume/pause gate and device-vanish handling are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): stop exporting MIDI into the audio-input domain

Keys MIDI now lives in the dedicated midi-input domain (#881). Continuing to
register pseudonymized 'midi-input-N' sources into audio-input polluted audio
device pickers — notably the onboarding guitar audio-input dropdown showed MIDI
devices under cryptic names. Unregister any leftovers and register none.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): guard async connect races, denied-discovery latch, key consistency

Codex preflight findings on the midi-input consumption:
- Don't latch `_midiReady` when discover() resolves denied/unavailable — only
  on a handled outcome — so reopening retries the permission prompt.
- Add a generation guard to async `_midiConnect`: a slower open() from an
  earlier selection can resolve after a device/None switch and install a stale
  handle/listener. Discard (and close) superseded opens.
- Carry the domain `logicalSourceKey` on the source/selection descriptor and use
  it for select/open/close instead of synthesizing `web-midi::<id>`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): invalidate pending opens on detach; clear phantom selection; manifest

Codex re-review (round 2):
- A detach driven by device removal (sources-changed) didn't advance
  _midiConnectSeq, so a pending _midiConnect open could resume and install a
  handle/listener for a now-gone source. Bump the generation inside _midiDetach
  and capture myGen after it, so any later detach supersedes an in-flight open.
- If mi.open() yields no handle, _midiInput stayed set — a phantom connected
  device the render loop's miss-sweeping would penalize. Clear it on the
  no-handle and catch paths.
- plugin.json: replace the stale audio-input provider role (the removed
  MIDI-into-audio-input export bridge) with the midi-input requester it now uses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): make _midiResume idempotent under multiple live instances

Codex re-review (round 3): with the domain addListener API, a second live
renderer instance (splitscreen/overlapping lifetimes) calling _midiResume() while
already active could register the same listener again and double-deliver a MIDI
note to the focused instance (hit + duplicate misses). Return early when already
active rather than relying on the provider's Set-backed de-dup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): release domain session on teardown; counter-free legacy source cleanup

Codex re-review (round 4):
- [P2] Last-instance teardown only detached the listener and never released the
  midi-input domain session, leaking the requester ref. Add _midiReleaseSession()
  (delegates to _midiDetach: close + null + generation bump) at the destroy site;
  re-mount's _midiInit auto-connects from the saved pick.
- [P3] The legacy audio-input MIDI-source cleanup looped over the module-local
  _aiRegisteredCount, which resets to 0 on an in-page upgrade so the prior build's
  'keys-midi:input-N' entries were never unregistered. Iterate a fixed bound over
  the known sourceId pattern instead (unregister of an absent source is a no-op).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): gate miss-sweep on live handle; don't open without a live renderer

Codex re-review (round 5):
- Miss-sweeping was gated on _midiInput, set as soon as a device is picked, but
  the async mi.open() may still be pending (slow / permission prompt) — notes
  passing during that window banked false misses. Gate on _midiHandle, truthy
  only after a handle is opened and wired.
- A settings-only ensure-init (or a discover resolving after the last instance
  was torn down) could open a midi-input session with no renderer to release it.
  Gate mi.open() on _instances.size > 0; the pick is saved and a later mount
  re-runs auto-connect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): guard open-failure clobber + don't clobber saved device on unplug

Bringing keys-highway-3d in line with the piano/drums hardening:
- The _midiConnect catch cleared _midiInput unconditionally; a stale older open's
  rejection could wipe a newer connect's _midiInput/_midiHandle (and leak the
  handle). Only clear when myGen === _midiConnectSeq.
- The sources-changed recovery called _midiAutoConnect(), whose fallback persists
  a substitute device — overwriting the user's saved pick on a transient
  multi-device unplug. Parameterize _midiAutoConnect(allowFallback); recovery
  passes false (reconnect the saved device only, never a persisted fallback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): keep first-hotplug working; don't tear down a live session on re-init

Codex round-11:
- The unplug-recovery _midiAutoConnect(false) skipped ALL fallbacks, which broke
  first-hotplug (open visualizer with no keyboard, plug one in → never picked →
  no connect). Skip the substitute only when a saved pick exists but is absent
  (preserve it); allow the fallback when nothing was ever picked.
- _midiInit re-ran _midiAutoConnect on every ready re-init (settings/splitscreen),
  tearing down the live handle + releasing held keys. Only re-connect when there's
  no live handle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(midi): consume core midi-input domain + session robustness fixes (#2)

* feat(midi): consume the core midi-input domain instead of private requestMIDIAccess (#881)

Route Web-MIDI device access through window.slopsmith.midiInput
(discover/list/select/open/close) rather than a private
navigator.requestMIDIAccess(). One shared device-access boundary with
piano/drums/keys/onboarding. Saved pick ({id,name}, with legacy id-only
fallback) stays compatible — domain sourceId == the old MIDIInput.id. The
async in-flight init guard, _midiActive teardown gate, device-vanish handling,
and settings device APIs are preserved; the live listener is the domain
handle's addListener/removeListener. Degrades to no-MIDI when the domain
is absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): guard async connect races, denied-discovery latch, key consistency

Codex preflight findings on the midi-input consumption:
- Don't latch `_midiReady` when discover() resolves denied/unavailable — only
  on a handled outcome — so reopening retries the permission prompt.
- Add a generation guard to async `_midiConnect`: a slower open() from an
  earlier selection can resolve after a device/None switch and install a stale
  handle/listener. Discard (and close) superseded opens.
- Carry the domain `logicalSourceKey` on the source/selection descriptor and use
  it for select/open/close instead of synthesizing `web-midi::<id>`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): invalidate pending opens on detach; clear phantom selection; manifest

Codex re-review (round 2):
- A detach driven by device removal (sources-changed) didn't advance
  _midiConnectSeq, so a pending _midiConnect open could resume and install a
  handle/listener for a now-gone source. Bump the generation inside _midiDetach
  and capture myGen after it, so any later detach supersedes an in-flight open.
- If mi.open() yields no handle, _midiInput stayed set — a phantom connected
  device the render loop's miss-sweeping would penalize. Clear it on the
  no-handle and catch paths.
- Declare the midi-input requester capability (degrade-noop) in plugin.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): make _midiResume idempotent under multiple live instances

Proactively mirror the keys-highway-3d round-3 fix (same multi-instance lifecycle
and identical code): a second live renderer instance calling _midiResume() while
already active could double-register the listener and double-deliver a MIDI hit.
Return early when already active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): release the domain session on final teardown

Mirror the keys-highway-3d round-4 fix: last-instance teardown only detached the
listener and never released the midi-input domain session, leaking the requester
ref. Add _midiReleaseSession() (delegates to _midiDetach: close + null +
generation bump) at the destroy site; re-mount's _midiInit auto-connects.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): gate miss-sweep on live handle; don't open without a live renderer

Codex re-review (round 5):
- [P2] drumH3dEnsureMidiInit (settings-only, no renderer) auto-connected and
  opened a midi-input session that destroy()/_midiReleaseSession would never run
  to release — held until reload. Gate mi.open() on _instances.size > 0; the pick
  is saved and a later renderer mount re-runs auto-connect.
- Mirror the keys-highway-3d miss-sweep fix: gate accumulation on _midiHandle
  (live wired session), not _midiInput (set before the async open resolves), so
  notes during a pending open don't bank false misses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): guard open-failure clobber + reconnect saved device on replug

Bringing drum-highway-3d in line with the piano/drums hardening:
- The _midiConnect catch cleared _midiInput unconditionally; a stale older open's
  rejection could wipe a newer connect's _midiInput/_midiHandle. Only clear when
  myGen === _midiConnectSeq.
- The sources-changed handler only refreshed the list (never reconnected), so a
  replug of the saved device didn't reattach. Reconnect on sources-changed via
  _midiAutoConnect(false) — saved device only, no fallback, so a transient unplug
  can't switch to / persist another input.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): keep first-hotplug working; don't tear down a live session on re-init

Same two round-11 fixes as keys-highway-3d (shared structure): the recovery
_midiAutoConnect(false) now allows a fallback when nothing was ever picked
(first-hotplug) while still preserving a saved-but-absent pick; and _midiInit
only re-connects when there's no live handle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): select by logicalSourceKey, not provider-local sourceId (#4)

Codex review of the midi-input migration: resolving a selection with
find(s => s.id === id) treats the provider-local sourceId as globally unique,
so when the core midi-input domain aggregates multiple providers two devices
sharing a sourceId can't be distinguished — the wrong device opens and unplug
detection can miss that the selected key vanished.

Thread the globally-unique logicalSourceKey through save/read, _midiConnect,
auto-connect reconnect, the public keysH3dSetMidiInput entry, and the
capability _aiOpen path, preferring the key and keeping the bare sourceId /
name only as a legacy fallback. Mirrors the already-merged drums/piano
migration (_midiResolveSaved). Self-reviewed against that reference.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(midi): select by logicalSourceKey, not provider-local sourceId (#3)

Codex review of the midi-input migration (same issue as keys-highway-3d):
resolving a selection with find(s => s.id === id) treats the provider-local
sourceId as globally unique, so when the core midi-input domain aggregates
multiple providers two devices sharing a sourceId can't be distinguished —
the wrong device opens and unplug detection can miss the vanished key.

Thread the globally-unique logicalSourceKey through save/read, _midiConnect,
the auto-connect reconnect, and the public drumH3dSetMidiInput entry,
preferring the key with the bare sourceId / name only as a legacy fallback.
Mirrors the already-merged drums/piano migration. Self-reviewed.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: add plugin metadata (category, description, thumbnail) (#5)

* chore: add plugin metadata (category/description/thumbnail)

Refs got-feedback/feedBack#571

* chore: add placeholder thumbnail

Refs got-feedback/feedBack#571

* chore: add plugin metadata (category, description, thumbnail) (#4)

* chore: add plugin metadata (category/description/thumbnail)

Refs got-feedback/feedBack#571

* chore: add placeholder thumbnail

Refs got-feedback/feedBack#571

* fix(viz): register feedBackViz_<id> so the player viz picker lists this plugin (#5)

After the slopsmith->feedBack rename the factory was still registered as
window.slopsmithViz_<id>; the host viz picker only looks up
window.feedBackViz_<id> (no slopsmithViz alias exists) and silently skipped
this plugin. Add the feedBackViz_ global (aliased to the existing
slopsmithViz_ one for back-compat).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(viz): register feedBackViz_<id> so the player viz picker lists this plugin (#6)

After the slopsmith->feedBack rename the factory was still registered as
window.slopsmithViz_<id>; the host viz picker only looks up
window.feedBackViz_<id> (no slopsmithViz alias exists) and silently skipped
this plugin. Add the feedBackViz_ global (aliased to the existing
slopsmithViz_ one for back-compat).

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(plugins): bundle drum_highway_3d + keys_highway_3d as in-tree core plugins

Import both 3D highway plugins from their standalone repos via git subtree
(history preserved), ahead of the visual-parity epic that ports the guitar
highway's polish to them.

- .gitignore: !plugins/drum_highway_3d/ + !plugins/keys_highway_3d/ exceptions
- keys plugin.json: "bundled": true (drum already had it)
- CI: JS test step gains 'plugins/*/tests/*.test.js' (+20 keys tests)
- static/tailwind.min.css regenerated (core build scans plugins/**)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drum_highway_3d): narrow Auto-mode predicate so bundling can't steal guitar arrangements

has_drum_tab is pack-level; first-match-wins Auto order sorts this plugin
before highway_3d. Claim only drum arrangements, or packs nothing more
specific can render (Codex preflight P2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: CHANGELOG — note the deliberate Auto-predicate narrowing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(drum_highway_3d): MIDI lifecycle — no false misses on mid-song connect, promote survivor on destroy

- _missSweepFloor exempts notes that passed before the device wired up
  from the miss sweep (lowered on seek-back; cleared with scoring resets)
- destroy() promotes a surviving instance to _activeInstance so
  splitscreen panel teardown doesn't drop all MIDI routing
  (Codex preflight round 2, both pre-existing in the imported code)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Sin <deathlysin@outlook.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Byron Gamatos
2026-07-02 00:25:31 +02:00
committed by GitHub
co-authored by Claude Fable 5 Sin
parent 744c848636
commit 1e2d5a6162
19 changed files with 6744 additions and 2 deletions
+432
View File
@@ -0,0 +1,432 @@
<div role="group" aria-labelledby="drumh3d-heading">
<h3 id="drumh3d-heading" class="text-sm font-medium text-gray-400 mb-2">3D Drum Highway</h3>
<p class="text-xs text-gray-500 mb-3">
Pick a song with a drum tab (sloppak-spec §5.3), choose <em>3D
Drum Highway</em> from the viz picker. The plugin auto-attaches
to your MIDI controller and routes chart pieces through the kit
you configure below.
</p>
<!-- Palette -->
<div class="mt-3">
<label for="drumh3d-palette" class="text-xs font-medium text-gray-400 mb-1 block">Palette</label>
<select id="drumh3d-palette"
onchange="window.drumH3dSetPalette && window.drumH3dSetPalette(this.value); (window._drumH3dRenderPaletteSwatches || function(){})(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-xs text-gray-300 outline-none">
<option value="default">Default (Classic)</option>
<option value="neon">Neon (saturated, electric)</option>
<option value="pastel">Pastel (soft, low contrast)</option>
</select>
<div class="mt-2 flex items-center gap-1" aria-hidden="true">
<span id="drumh3d-swatch-0" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-1" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-2" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-3" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-4" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-5" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-6" class="inline-block h-6 w-5 rounded border border-gray-700/60"></span>
<span id="drumh3d-swatch-7" class="inline-block h-6 w-5 rounded border border-gray-700/60"
style="background:#ffa030"></span>
</div>
</div>
<!-- Camera angle -->
<div class="mt-4">
<label for="drumh3d-camera" class="text-xs font-medium text-gray-400 mb-1 block">
Camera angle <span id="drumh3d-camera-val" class="text-gray-500 font-mono">0.35</span>
</label>
<input type="range" id="drumh3d-camera"
min="0" max="1" step="0.05" value="0.35"
oninput="window.drumH3dSetCameraAngle && window.drumH3dSetCameraAngle(this.value); document.getElementById('drumh3d-camera-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
<p class="text-xs text-gray-500 mt-1">0 = down the lanes · 1 = top-down</p>
</div>
<!-- MIDI input -->
<div class="mt-6 border-t border-gray-800 pt-4">
<h4 class="text-xs font-medium text-gray-300 mb-2">MIDI input</h4>
<label for="drumh3d-midi-input" class="text-xs font-medium text-gray-400 mb-1 block">Device</label>
<select id="drumh3d-midi-input"
onchange="window.drumH3dSetMidiInput && window.drumH3dSetMidiInput(this.value)"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none">
<option value="">— none —</option>
</select>
<p class="text-xs text-gray-500 mt-1">
Auto-picks the first non-passthrough input on load. Pick "none" to pause hit and miss tracking.
</p>
<label for="drumh3d-synth-vol" class="text-xs font-medium text-gray-400 mb-1 mt-3 block">
Kit volume <span id="drumh3d-synth-vol-val" class="text-gray-500 font-mono">0.70</span>
</label>
<input type="range" id="drumh3d-synth-vol"
min="0" max="1" step="0.01" value="0.7"
oninput="window.drumH3dSetSynthVolume && window.drumH3dSetSynthVolume(this.value); document.getElementById('drumh3d-synth-vol-val').textContent = parseFloat(this.value).toFixed(2)"
class="w-full">
</div>
<!-- Kit configuration -->
<div class="mt-6 border-t border-gray-800 pt-4">
<h4 class="text-xs font-medium text-gray-300 mb-2">My kit</h4>
<p class="text-xs text-gray-500 mb-3">
Add the pieces you actually have, in the order you want them
on the highway (left → right). The default fallback routing
covers most common kit setups; use the fallbacks panel below
to reroute any chart piece to a different lane on your kit,
or set it to "—" to silently drop it.
</p>
<label class="text-xs font-medium text-gray-400 mb-1 block">Kit name</label>
<input type="text" id="drumh3d-kit-name" maxlength="80"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none mb-3"
oninput="window._drumH3dKit && window._drumH3dKit.onNameInput(this.value)">
<label class="text-xs font-medium text-gray-400 mb-1 block">Lanes (left → right on the highway; kick is always a full-width bar)</label>
<div id="drumh3d-kit-lanes" class="space-y-1 mb-2"></div>
<div class="mt-2 mb-3">
<label class="text-xs font-medium text-gray-400 mb-1 block">Add a piece</label>
<select id="drumh3d-kit-add"
onchange="window._drumH3dKit && window._drumH3dKit.onAddPiece(this.value); this.value=''"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-1.5 text-xs text-gray-300 outline-none">
<option value="">— pick a piece to add —</option>
</select>
</div>
<details class="mt-2 mb-3">
<summary class="text-xs text-gray-400 cursor-pointer">Chart fallbacks for pieces I don't have</summary>
<p class="text-xs text-gray-500 mt-2 mb-2">
When a song uses a piece that isn't on your kit, route it
to one of your lanes. Set "—" to silently drop that piece.
</p>
<div id="drumh3d-kit-fallbacks" class="space-y-1"></div>
</details>
<div class="flex flex-wrap items-center gap-2 mt-3">
<button type="button"
onclick="window._drumH3dKit && window._drumH3dKit.copyToClipboard()"
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">📋 Copy kit</button>
<button type="button"
onclick="document.getElementById('drumh3d-kit-import-row').classList.toggle('hidden')"
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">📥 Import kit…</button>
<button type="button"
onclick="window._drumH3dKit && window._drumH3dKit.resetDefault()"
class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs">Reset</button>
<span id="drumh3d-kit-status" class="text-xs text-gray-500 ml-auto"></span>
</div>
<div id="drumh3d-kit-import-row" class="hidden mt-3">
<label class="text-xs font-medium text-gray-400 mb-1 block">Paste a shared kit</label>
<textarea id="drumh3d-kit-import-text" rows="3"
placeholder="Paste a base64 kit string here…"
class="w-full bg-dark-700 border border-gray-800 rounded-lg px-3 py-2 text-[10px] font-mono text-gray-300 outline-none"></textarea>
<button type="button"
onclick="window._drumH3dKit && window._drumH3dKit.importFromTextarea()"
class="mt-2 px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 rounded-lg text-xs">Apply imported kit</button>
</div>
</div>
<script>
(function () {
// Mirror of PALETTES in screen.js — kept in sync for swatch previews.
const PALETTES = {
default: [0xff2828, 0xffd400, 0x2080ff, 0xff8020, 0x30d040, 0xa040ff, 0xff6bd5, 0x6bffe6],
neon: [0xff0030, 0xffe800, 0x0080ff, 0xff8030, 0x40ff50, 0xb050ff, 0xff40d0, 0x40ffd0],
pastel: [0xe89aa0, 0xefdf90, 0x9adfee, 0xefb898, 0xa6e0a8, 0xc4a6e0, 0xe0a6c8, 0xa6e0d8],
};
function toHex(n) { return '#' + n.toString(16).padStart(6, '0'); }
// Palette swatch preview: read the FIRST 8 pieces of the active
// kit's lanes (or pad to 8) for colour reference.
window._drumH3dRenderPaletteSwatches = function (id) {
const pal = PALETTES[id] || PALETTES.default;
const kit = (window.drumH3dGetKit && window.drumH3dGetKit()) || null;
const lanes = (kit && kit.lanes) || [];
// The kick always shows amber — same as the renderer. Other
// lanes follow the piece's palette index from screen.js,
// retrieved via drumH3dGetPiecePaletteIdx() so settings.html
// stays in sync when pieces are added without any copy-paste.
const PALETTE_IDX = (window.drumH3dGetPiecePaletteIdx && window.drumH3dGetPiecePaletteIdx()) || {
kick: -1, // sentinel — render amber
snare: 0, snare_xstick: 0,
hh_closed: 7, hh_open: 7, hh_pedal: 7,
tom_hi: 4, tom_mid: 2, tom_low: 5, tom_floor: 5,
crash_l: 1, crash_r: 1, splash: 1, china: 1,
ride: 3, ride_bell: 3,
};
for (let i = 0; i < 8; i++) {
const el = document.getElementById('drumh3d-swatch-' + i);
if (!el) continue;
const ln = lanes[i];
if (!ln) { el.style.background = '#000'; el.style.opacity = '0.3'; continue; }
el.style.opacity = '1';
const idx = PALETTE_IDX[ln.piece];
if (idx === -1) { el.style.background = '#ffa030'; continue; }
el.style.background = toHex(pal[idx ?? 0]);
}
};
// Read fresh inside renderKit() — they're set by screen.js's IIFE,
// which may not have finished evaluating when this inline script
// first runs if the user opens Settings before the player ever loaded.
// Friendly piece labels — self-contained so renderKit's callbacks
// (which run after our local PIECE_LABELS scope has cleared)
// still work.
const FRIENDLY = {
kick: 'Kick',
snare: 'Snare', snare_xstick: 'Snare (cross-stick)',
hh_closed: 'Hi-hat (closed)', hh_open: 'Hi-hat (open)', hh_pedal: 'Hi-hat (pedal)',
tom_hi: 'Tom — high', tom_mid: 'Tom — mid', tom_low: 'Tom — low', tom_floor: 'Floor tom',
crash_l: 'Crash (left)', crash_r: 'Crash (right)', splash: 'Splash', china: 'China',
ride: 'Ride', ride_bell: 'Ride bell',
};
function friendly(p) { return FRIENDLY[p] || p; }
// Render the current kit's lane list with reorder + remove buttons.
function renderKit() {
const kit = window.drumH3dGetKit ? window.drumH3dGetKit() : null;
if (!kit) {
// screen.js hasn't published its API yet — defer one tick.
// Happens when the settings panel opens before the player
// has loaded a song.
setTimeout(renderKit, 100);
return;
}
const PIECE_CATEGORY = window.drumH3dGetPieceCategory ? window.drumH3dGetPieceCategory() : {};
const ALL_PIECES = window.drumH3dGetAllPieces ? window.drumH3dGetAllPieces() : [];
document.getElementById('drumh3d-kit-name').value = kit.name || '';
// Lanes panel — order matters: up/down buttons reorder, x
// removes (also clears any fallback that pointed at the
// removed piece on the way out).
const lanesEl = document.getElementById('drumh3d-kit-lanes');
lanesEl.innerHTML = '';
const taken = new Set(kit.lanes.map(l => l.piece));
kit.lanes.forEach((ln, i) => {
const row = document.createElement('div');
row.className = 'flex items-center gap-2 px-2 py-1 bg-dark-700/60 rounded';
// Screen readers should hear the user-facing label
// ("Hi-hat (closed)") not the internal piece id ("hh_closed").
const pieceName = friendly(ln.piece);
row.innerHTML =
`<span class="font-mono text-[10px] text-gray-500 w-6 text-center">${i}</span>` +
`<span class="flex-1 text-xs text-gray-300">${pieceName}</span>` +
`<button type="button" data-act="up" data-i="${i}" class="px-1.5 py-0.5 text-xs text-gray-400 hover:text-white" ${i === 0 ? 'disabled' : ''} title="Move up" aria-label="Move ${pieceName} up">▲</button>` +
`<button type="button" data-act="down" data-i="${i}" class="px-1.5 py-0.5 text-xs text-gray-400 hover:text-white" ${i === kit.lanes.length - 1 ? 'disabled' : ''} title="Move down" aria-label="Move ${pieceName} down">▼</button>` +
`<button type="button" data-act="rm" data-i="${i}" class="px-1.5 py-0.5 text-xs text-red-400 hover:text-red-300" title="Remove from kit" aria-label="Remove ${pieceName} from kit">✗</button>`;
lanesEl.appendChild(row);
});
lanesEl.querySelectorAll('button').forEach(btn => {
btn.onclick = () => {
const i = parseInt(btn.dataset.i, 10);
const act = btn.dataset.act;
const next = JSON.parse(JSON.stringify(kit));
if (act === 'up' && i > 0) {
[next.lanes[i - 1], next.lanes[i]] = [next.lanes[i], next.lanes[i - 1]];
} else if (act === 'down' && i < next.lanes.length - 1) {
[next.lanes[i + 1], next.lanes[i]] = [next.lanes[i], next.lanes[i + 1]];
} else if (act === 'rm') {
const removed = next.lanes[i].piece;
next.lanes.splice(i, 1);
// Drop any fallback that targeted the removed piece
// — it's no longer a valid target.
for (const k of Object.keys(next.fallbacks || {})) {
if (next.fallbacks[k] === removed) delete next.fallbacks[k];
}
} else { return; }
// Guard: drumH3dSetKit returns false when validation
// rejects the kit (e.g. empty lanes[] after last removal).
if (!window.drumH3dSetKit(next)) {
setStatus('Kit must have at least one lane', 'err');
}
};
});
// Add-a-piece dropdown — pieces NOT already in lanes, grouped
// by category (kick / drums / cymbals).
const addSel = document.getElementById('drumh3d-kit-add');
addSel.innerHTML = '<option value="">— pick a piece to add —</option>';
const groups = { kick: [], drum: [], cymbal: [] };
for (const p of ALL_PIECES) {
if (taken.has(p)) continue;
const cat = PIECE_CATEGORY[p] || 'drum';
if (groups[cat]) groups[cat].push(p);
}
for (const [cat, label] of [['kick', 'Kick'], ['drum', 'Drums'], ['cymbal', 'Cymbals']]) {
if (!groups[cat].length) continue;
const og = document.createElement('optgroup');
og.label = label;
for (const p of groups[cat]) {
const opt = document.createElement('option');
opt.value = p;
opt.textContent = friendly(p);
og.appendChild(opt);
}
addSel.appendChild(og);
}
// Fallback list — every piece NOT in lanes gets a dropdown
// mapping it to one of the user's pieces (or "—" to drop).
const fbEl = document.getElementById('drumh3d-kit-fallbacks');
fbEl.innerHTML = '';
for (const p of ALL_PIECES) {
if (taken.has(p)) continue;
const row = document.createElement('div');
row.className = 'flex items-center gap-2 px-2 py-1';
const labelTxt = friendly(p);
const current = (kit.fallbacks && kit.fallbacks[p]) || '';
let optsHtml = '<option value="">— drop —</option>';
for (const ln of kit.lanes) {
const sel = ln.piece === current ? ' selected' : '';
optsHtml += `<option value="${ln.piece}"${sel}>${friendly(ln.piece)}</option>`;
}
row.innerHTML =
`<span class="flex-1 text-xs text-gray-400">${labelTxt}</span>` +
`<span class="text-xs text-gray-600">→</span>` +
`<select data-from="${p}" class="bg-dark-700 border border-gray-800 rounded px-2 py-1 text-xs text-gray-300 outline-none">${optsHtml}</select>`;
fbEl.appendChild(row);
}
fbEl.querySelectorAll('select').forEach(s => {
s.onchange = () => {
const next = JSON.parse(JSON.stringify(kit));
next.fallbacks = next.fallbacks || {};
if (s.value) next.fallbacks[s.dataset.from] = s.value;
else delete next.fallbacks[s.dataset.from];
window.drumH3dSetKit(next); // event-driven renderKit() via drum_h3d:kit
};
});
// Refresh palette swatches so they reflect the new lane order.
if (window._drumH3dRenderPaletteSwatches) {
const pSel = document.getElementById('drumh3d-palette');
window._drumH3dRenderPaletteSwatches(pSel ? pSel.value : 'default');
}
}
function setStatus(msg, kind) {
const el = document.getElementById('drumh3d-kit-status');
if (!el) return;
el.textContent = msg;
el.className = 'text-xs ml-auto ' + (kind === 'err' ? 'text-red-400' : kind === 'ok' ? 'text-emerald-400' : 'text-gray-500');
setTimeout(() => { if (el.textContent === msg) el.textContent = ''; }, 3000);
}
// Expose the kit interaction surface to the inline onclick / onchange
// attributes used by the markup above.
window._drumH3dKit = {
onNameInput(name) {
// Use the name-only setter to avoid triggering 'drum_h3d:kit'
// on every keystroke — that event causes a full WebGL scene
// teardown/reinit which produces visible stutter while typing.
window.drumH3dSetKitName && window.drumH3dSetKitName(name);
},
onAddPiece(piece) {
if (!piece) return;
const kit = window.drumH3dGetKit();
kit.lanes.push({ piece });
window.drumH3dSetKit(kit); // event-driven renderKit() via drum_h3d:kit
},
copyToClipboard() {
const b64 = window.drumH3dExportKit && window.drumH3dExportKit();
if (!b64) return setStatus('export failed', 'err');
const _showImportFallback = (str) => {
// Clipboard unavailable — reveal the import row so the user can
// copy the string from the textarea manually.
document.getElementById('drumh3d-kit-import-row').classList.remove('hidden');
const ta = document.getElementById('drumh3d-kit-import-text');
ta.value = str;
ta.focus();
ta.select();
setStatus('clipboard blocked — copy from the textarea below', 'err');
};
if (!navigator.clipboard) {
_showImportFallback(b64);
return;
}
navigator.clipboard.writeText(b64).then(
() => setStatus('✓ kit copied', 'ok'),
() => _showImportFallback(b64),
);
},
importFromTextarea() {
const txt = document.getElementById('drumh3d-kit-import-text').value;
if (!txt.trim()) return setStatus('paste a kit string first', 'err');
const ok = window.drumH3dImportKit && window.drumH3dImportKit(txt);
// drumH3dImportKit calls drumH3dSetKit which dispatches drum_h3d:kit;
// the event listener re-renders the panel, so no explicit renderKit().
if (ok) { setStatus('✓ kit imported', 'ok'); }
else setStatus('invalid kit string', 'err');
},
resetDefault() {
window.drumH3dResetKit && window.drumH3dResetKit();
// drumH3dResetKit calls drumH3dSetKit → dispatches drum_h3d:kit
// → event listener re-renders. No explicit renderKit() needed.
setStatus('✓ reset to default', 'ok');
},
};
// Re-render the kit list whenever the kit changes (covers external
// changes from setKit() / importKit() not initiated by this panel).
window.addEventListener('drum_h3d:kit', () => renderKit());
// ─── MIDI device picker ───────────────────────────────
function renderMidiPicker() {
const sel = document.getElementById('drumh3d-midi-input');
if (!sel) return;
const inputs = window.drumH3dListMidiInputs ? window.drumH3dListMidiInputs() : [];
const current = window.drumH3dGetMidiInputId ? window.drumH3dGetMidiInputId() : '';
sel.innerHTML = '<option value="">— none —</option>';
for (const inp of inputs) {
const opt = document.createElement('option');
opt.value = inp.id;
opt.textContent = inp.name;
if (inp.id === current) opt.selected = true;
sel.appendChild(opt);
}
// Sync volume slider with the actual persisted value (default
// 0.7 if storage was empty or never written).
const vol = window.drumH3dGetSynthVolume ? window.drumH3dGetSynthVolume() : 0.7;
const volSlider = document.getElementById('drumh3d-synth-vol');
const volLabel = document.getElementById('drumh3d-synth-vol-val');
if (volSlider) volSlider.value = String(vol);
if (volLabel) volLabel.textContent = parseFloat(vol).toFixed(2);
}
window.addEventListener('drum_h3d:midi_devices', renderMidiPicker);
// Kick off MIDI initialisation if the player viz never ran (user
// opened Settings → 3D Drum Highway directly). _midiInit is
// idempotent so this is safe even when the viz is also active.
// Re-render the picker when the access promise settles.
(function pollMidi() {
if (window.drumH3dEnsureMidiInit) {
Promise.resolve(window.drumH3dEnsureMidiInit()).then(renderMidiPicker);
renderMidiPicker(); // first paint with whatever's cached
} else {
setTimeout(pollMidi, 100);
}
})();
// Hydrate controls from stored config on first paint.
// Narrow the try/catch to just localStorage reads — renderKit() and
// swatches must always run even when storage is blocked/disabled.
const pSel = document.getElementById('drumh3d-palette');
const cam = document.getElementById('drumh3d-camera');
const camVal = document.getElementById('drumh3d-camera-val');
try {
const storedPal = localStorage.getItem('drum_h3d_palette');
if (storedPal && PALETTES[storedPal]) pSel.value = storedPal;
const storedCam = parseFloat(localStorage.getItem('drum_h3d_camera_angle'));
if (Number.isFinite(storedCam)) {
const c = Math.min(1, Math.max(0, storedCam));
cam.value = String(c);
camVal.textContent = c.toFixed(2);
}
} catch (e) {
console.warn('[Drum-Hwy3D settings] hydration failed:', e);
}
renderKit();
window._drumH3dRenderPaletteSwatches(pSel.value);
})();
</script>
</div>