mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 03:09:57 +00:00
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:
co-authored by
Claude Fable 5
Sin
parent
744c848636
commit
1e2d5a6162
@@ -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>
|
||||
Reference in New Issue
Block a user