CONTROLS.html: load your bindings.txt and the board becomes YOUR board
The interactive controls page was a static picture of the stock layout -- a
player's carried migration rows, rebinds and HOTAS setup were invisible to it,
and the mismatch grew with every customization. Now a "Load your bindings.txt"
box (file picker + drag-drop) parses the real bindings grammar client-side and
rewrites the interactive keyboard in place:
* every key shows the loaded file's truth; keys that differ from the stock
board get a hazard outline (the legend says so); hover readouts follow
because they were already attribute-driven;
* the game's bindings-row-wins rule is modelled: the PgUp/PgDn volume and
backtick view BUILT-INS keep their face only while their key is unbound;
* rows that have no keyboard geometry (pad / padaxis / wizard joyaxis-
joybutton-joyhat / MOUSE keys) land in an "also in your file" list;
* one click restores the stock view. Nothing is uploaded -- FileReader on a
user-chosen local file, works from the extracted zip on file://.
Key-name registry walks the three key blocks in DOM order (Shift/Ctrl/Alt
resolve left-then-right; the numpad's U+2212 minus and its ASCII twin both map
to NUMPADMINUS), address meanings are harvested from the stock board itself
plus a small table for the slots the default board leaves off (unwired columns,
the cabinet-only throttle bank, PANIC).
Two self-inflicted bugs found by the harness and fixed before landing: the
address harvest invented a "BTN" label for the many stock keys that have none,
which made EVERY default row read as a customization; and generated axis labels
("AIM +") differed from the page's hand-authored vocabulary ("AIM UP"), custom-
marking the whole stock numpad. Both now speak the board's own labels.
VERIFIED headlessly (no browser extension needed): scratchpad/
test_controls_page.py injects a self-test harness into a copy of the page,
feeds it scratchpad/fixture_migrated_bindings.txt -- a REAL board-2 migrated
file (full defaults + two authored rows + a wizard section) -- and runs it
under chrome --headless=new --dump-dom. 11/11: the two authored rows carried
and marked, zero stray custom marks across the rest of the board, both
built-ins survive, wizard rows listed, status line honest, and reset restores
the stock board exactly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6eaed84fad
commit
e46fc706a6
@@ -398,6 +398,41 @@
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) { * { transition: none !important; } }
|
||||
/* ---- "load your bindings.txt" (the board becomes YOUR board) ---- */
|
||||
.loader {
|
||||
margin: 14px 0 4px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--glass-edge);
|
||||
border-radius: 6px;
|
||||
background: var(--void-2);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px 14px;
|
||||
}
|
||||
.loader.dragover { border-color: var(--phosphor); background: var(--glass); }
|
||||
.loader label.btn {
|
||||
cursor: pointer;
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--dim);
|
||||
border-radius: 4px;
|
||||
color: var(--phosphor-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.loader label.btn:hover { border-color: var(--phosphor); }
|
||||
.loader input[type="file"] { display: none; }
|
||||
.loader .status { color: var(--dim); font-size: 0.86em; }
|
||||
.loader .status b { color: var(--phosphor-2); }
|
||||
.loader .reset { color: var(--dim); font-size: 0.86em; cursor: pointer; text-decoration: underline; display: none; }
|
||||
.key.custom {
|
||||
outline: 1px solid var(--hazard-lit);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
.key.custom .k-sub { color: var(--hazard-lit); }
|
||||
.loader-extras { display: none; margin: 6px 0 0; font-size: 0.86em; color: var(--dim); }
|
||||
.loader-extras b { color: var(--phosphor-2); }
|
||||
.loader-legend { display: none; font-size: 0.82em; color: var(--dim); }
|
||||
.loader-legend i { color: var(--hazard-lit); font-style: normal; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
@@ -714,6 +749,16 @@
|
||||
saved as <code>bindings.old.txt</code>.
|
||||
</p>
|
||||
|
||||
<div class="loader" id="loader">
|
||||
<label class="btn" for="bindfile">Load your bindings.txt</label>
|
||||
<input type="file" id="bindfile" accept=".txt">
|
||||
<span class="status" id="load-status">…and the keyboard above becomes <b>your</b> keyboard
|
||||
(drop the file anywhere on this box). Nothing is uploaded — this page reads it locally.</span>
|
||||
<span class="reset" id="load-reset">show the stock layout again</span>
|
||||
<span class="loader-legend" id="load-legend"><i>outlined keys</i> differ from the stock board</span>
|
||||
</div>
|
||||
<p class="loader-extras" id="load-extras"></p>
|
||||
|
||||
<div class="two-col">
|
||||
<pre><i># grammar</i>
|
||||
<b>key</b> <KEY> <b>button</b> <addr>
|
||||
@@ -1060,5 +1105,296 @@
|
||||
|
||||
bind(kbEl, rdKb, ".key");
|
||||
bind(document.querySelector("svg.pad"), rdPad, ".ctl");
|
||||
//--------------------------------------------------------------------
|
||||
// LOAD YOUR bindings.txt (2026-07-26): the board above stops being a
|
||||
// picture of the stock layout and becomes the reader's actual keyboard.
|
||||
// Entirely local -- FileReader on a user-chosen file; nothing uploaded.
|
||||
//--------------------------------------------------------------------
|
||||
|
||||
// Registry: bindings.txt KEYNAME -> the rendered key element. Caps on the
|
||||
// board are display glyphs ("`", "−", "PgUp"), and Shift/Ctrl/Alt appear
|
||||
// twice (left first, right second), so walk each block in DOM order.
|
||||
var keyEls = {};
|
||||
function reg(name, el) { if (name && el && !keyEls[name]) keyEls[name] = el; }
|
||||
(function () {
|
||||
var mainCap = {
|
||||
"`": "BACKTICK", "-": "MINUS", "=": "EQUALS", "[": "LBRACKET",
|
||||
"]": "RBRACKET", "\\": "BACKSLASH", ";": "SEMICOLON", "'": "QUOTE",
|
||||
",": "COMMA", ".": "PERIOD", "/": "SLASH", "Space": "SPACE",
|
||||
"Tab": "TAB", "Bksp": "BACKSPACE", "Enter": "ENTER"
|
||||
};
|
||||
var dupes = { "Shift": ["LSHIFT", "RSHIFT"], "Ctrl": ["LCTRL", "RCTRL"],
|
||||
"Alt": ["LALT", "RALT"] };
|
||||
var seen = {};
|
||||
Array.prototype.forEach.call(main.querySelectorAll(".key"), function (el) {
|
||||
var cap = el.firstChild ? el.firstChild.textContent : "";
|
||||
if (!cap) return;
|
||||
if (dupes[cap]) {
|
||||
seen[cap] = (seen[cap] || 0);
|
||||
reg(dupes[cap][Math.min(seen[cap], 1)], el);
|
||||
seen[cap] += 1;
|
||||
return;
|
||||
}
|
||||
if (mainCap[cap]) { reg(mainCap[cap], el); return; }
|
||||
if (/^[A-Z0-9]$/.test(cap) || /^F1?[0-9]$/.test(cap)) reg(cap, el);
|
||||
});
|
||||
var navCap = { "Ins": "INSERT", "Home": "HOME", "PgUp": "PAGEUP",
|
||||
"Del": "DELETE", "End": "END", "PgDn": "PAGEDOWN",
|
||||
"↑": "UP", "←": "LEFT", "↓": "DOWN",
|
||||
"→": "RIGHT" };
|
||||
Array.prototype.forEach.call(navWrap.querySelectorAll(".key"), function (el) {
|
||||
var cap = el.firstChild ? el.firstChild.textContent : "";
|
||||
if (navCap[cap]) reg(navCap[cap], el);
|
||||
});
|
||||
var padCap = { "/": "NUMPADSLASH", "*": "NUMPADSTAR",
|
||||
"−": "NUMPADMINUS", "+": "NUMPADPLUS",
|
||||
".": "NUMPADDOT" };
|
||||
Array.prototype.forEach.call(pad.querySelectorAll(".key"), function (el) {
|
||||
var cap = el.firstChild ? el.firstChild.textContent : "";
|
||||
if (/^[0-9]$/.test(cap)) reg("NUMPAD" + cap, el);
|
||||
else if (padCap[cap]) reg(padCap[cap], el);
|
||||
// the pad's minus renders as U+2212; accept the ASCII twin too
|
||||
else if (cap === "-") reg("NUMPADMINUS", el);
|
||||
});
|
||||
})();
|
||||
|
||||
// Remember every key's stock face, for reset and for the differs-mark.
|
||||
var stock = {};
|
||||
Object.keys(keyEls).forEach(function (name) {
|
||||
var el = keyEls[name], subEl = el.querySelector(".k-sub");
|
||||
stock[name] = {
|
||||
sub: subEl ? subEl.textContent : "",
|
||||
addr: el.getAttribute("data-addr") || "",
|
||||
desc: el.getAttribute("data-desc") || "",
|
||||
name: el.getAttribute("data-name") || ""
|
||||
};
|
||||
});
|
||||
|
||||
// What an address MEANS: harvested from the stock board itself, plus the
|
||||
// slots the default board leaves off (unwired columns, the cabinet-only
|
||||
// throttle bank) so a custom row still reads as something.
|
||||
var addrInfo = {};
|
||||
Object.keys(stock).forEach(function (name) {
|
||||
var s = stock[name];
|
||||
// Keep the sub EXACTLY as the stock board shows it -- most MFD bank keys
|
||||
// have no short label at all, and inventing one ("BTN") made every
|
||||
// default row read as a customization when a real file was loaded.
|
||||
if (/^0x/i.test(s.addr) && !addrInfo[s.addr.toLowerCase()])
|
||||
addrInfo[s.addr.toLowerCase()] = { sub: s.sub, desc: s.desc };
|
||||
});
|
||||
[["0x16", "—", "Column slot the pod never wired"],
|
||||
["0x17", "—", "Column slot the pod never wired"],
|
||||
["0x1e", "—", "Column slot the pod never wired"],
|
||||
["0x1f", "—", "Column slot the pod never wired"],
|
||||
["0x38", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
|
||||
["0x39", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
|
||||
["0x3a", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
|
||||
["0x3b", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
|
||||
["0x3c", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"],
|
||||
["0x3d", "PANIC", "The pod's panic button"],
|
||||
["0x3e", "AUX", "Throttle bank -- cabinet-only (inert on desktop)"]
|
||||
].forEach(function (a) {
|
||||
if (!addrInfo[a[0]]) addrInfo[a[0]] = { sub: a[1], desc: a[2] };
|
||||
});
|
||||
|
||||
var chanShort = { "THROTTLE": "THR", "JOYSTICKX": "TWIST",
|
||||
"JOYSTICKY": "AIM", "LEFTPEDAL": "L PED",
|
||||
"RIGHTPEDAL": "R PED", "TURN": "TURN" };
|
||||
|
||||
// One parsed row -> a display face {sub, addr, desc}.
|
||||
function faceOf(tok) {
|
||||
var verb = (tok[2] || "").toUpperCase();
|
||||
if (verb === "BUTTON") {
|
||||
var addr = (tok[3] || "").toLowerCase();
|
||||
var info = addrInfo[addr] || { sub: "BTN", desc: "RIO button" };
|
||||
return { sub: info.sub, addr: tok[3] || "", desc: info.desc };
|
||||
}
|
||||
if (verb === "KEYPAD")
|
||||
return { sub: "KP " + tok[3] + "/" + tok[4], addr: "keypad",
|
||||
desc: "MFD keypad unit " + tok[3] + ", key " + tok[4] };
|
||||
if (verb === "AXIS") {
|
||||
var ch = (tok[3] || "").toUpperCase();
|
||||
var short_ = chanShort[ch] || tok[3] || "?";
|
||||
var how = (tok[4] || "").toUpperCase();
|
||||
if (how === "SET")
|
||||
return { sub: (ch === "THROTTLE" && tok[5] === "0")
|
||||
? "ALL STOP" : short_ + "=" + tok[5],
|
||||
addr: "axis", desc: tok[3] + " set to " + tok[5] };
|
||||
// Speak the board's own label vocabulary, or every stock axis row in
|
||||
// a loaded file reads as a "customization" purely by spelling
|
||||
// ("AIM +" vs the hand-authored "AIM UP").
|
||||
var axisLabel = {
|
||||
"JOYSTICKY+": "AIM UP", "JOYSTICKY-": "AIM DN",
|
||||
"JOYSTICKX+": "TWIST", "JOYSTICKX-": "TWIST",
|
||||
"LEFTPEDAL+": "L PED", "LEFTPEDAL-": "L PED",
|
||||
"RIGHTPEDAL+": "R PED", "RIGHTPEDAL-": "R PED",
|
||||
"THROTTLE+": "THR +", "THROTTLE-": "THR −",
|
||||
"TURN+": "TURN", "TURN-": "TURN"
|
||||
};
|
||||
var sign = tok[5] === "-" ? "-" : "+";
|
||||
var lbl = axisLabel[ch + sign]
|
||||
|| (short_ + (sign === "-" ? " −" : " +"));
|
||||
return { sub: lbl, addr: "axis",
|
||||
desc: tok[3] + " " + (how === "SLEW" ? "slew" : "deflect")
|
||||
+ " " + (tok[5] || "") + " " + (tok[6] || "") };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setFace(el, face, isCustom) {
|
||||
var subEl = el.querySelector(".k-sub");
|
||||
if (face && face.sub) {
|
||||
if (!subEl) {
|
||||
subEl = document.createElement("span");
|
||||
subEl.className = "k-sub";
|
||||
el.appendChild(subEl);
|
||||
}
|
||||
subEl.textContent = face.sub;
|
||||
} else if (subEl) {
|
||||
subEl.remove();
|
||||
}
|
||||
el.setAttribute("data-name", el.firstChild.textContent);
|
||||
el.setAttribute("data-addr", face ? face.addr : "");
|
||||
el.setAttribute("data-desc", face ? face.desc : "");
|
||||
el.classList.toggle("custom", !!isCustom);
|
||||
}
|
||||
|
||||
var statusEl = document.getElementById("load-status");
|
||||
var resetEl = document.getElementById("load-reset");
|
||||
var legendEl = document.getElementById("load-legend");
|
||||
var extrasEl = document.getElementById("load-extras");
|
||||
|
||||
function resetBoard() {
|
||||
Object.keys(keyEls).forEach(function (name) {
|
||||
var el = keyEls[name], s = stock[name];
|
||||
var subEl = el.querySelector(".k-sub");
|
||||
if (s.sub) {
|
||||
if (!subEl) {
|
||||
subEl = document.createElement("span");
|
||||
subEl.className = "k-sub";
|
||||
el.appendChild(subEl);
|
||||
}
|
||||
subEl.textContent = s.sub;
|
||||
} else if (subEl) {
|
||||
subEl.remove();
|
||||
}
|
||||
if (s.name) el.setAttribute("data-name", s.name);
|
||||
else el.removeAttribute("data-name");
|
||||
el.setAttribute("data-addr", s.addr);
|
||||
el.setAttribute("data-desc", s.desc);
|
||||
el.classList.remove("custom");
|
||||
});
|
||||
statusEl.innerHTML = "Showing the <b>stock</b> layout.";
|
||||
resetEl.style.display = "none";
|
||||
legendEl.style.display = "none";
|
||||
extrasEl.style.display = "none";
|
||||
}
|
||||
|
||||
function applyFile(text, fname) {
|
||||
// Parse every row; group by keyboard / elsewhere.
|
||||
var perKey = {}, extras = [], rows = 0;
|
||||
text.split(/\r?\n/).forEach(function (raw) {
|
||||
var line = raw.replace(/#.*$/, "").trim();
|
||||
if (!line) return;
|
||||
var tok = line.split(/\s+/);
|
||||
var kind = tok[0].toUpperCase();
|
||||
if (kind === "KEY" && tok.length >= 3) {
|
||||
rows += 1;
|
||||
var name = tok[1].toUpperCase();
|
||||
if (/^MOUSE/.test(name)) { extras.push(line); return; }
|
||||
(perKey[name] = perKey[name] || []).push(tok);
|
||||
} else if (kind === "PAD" || kind === "PADAXIS"
|
||||
|| kind === "JOYAXIS" || kind === "JOYBUTTON"
|
||||
|| kind === "JOYHAT") {
|
||||
rows += 1;
|
||||
extras.push(line);
|
||||
}
|
||||
});
|
||||
if (rows === 0) {
|
||||
statusEl.innerHTML = "That file has no binding rows — is it really "
|
||||
+ "a bindings.txt?";
|
||||
return;
|
||||
}
|
||||
|
||||
// The board becomes the file: every registered key resets, then rows
|
||||
// land. Built-ins (backtick view, PgUp/PgDn volume) keep their face
|
||||
// unless the file binds that key -- the game's bindings-row-wins rule.
|
||||
var builtIns = { "BACKTICK": 1, "PAGEUP": 1, "PAGEDOWN": 1 };
|
||||
var customCount = 0;
|
||||
Object.keys(keyEls).forEach(function (name) {
|
||||
var el = keyEls[name];
|
||||
var rowsHere = perKey[name];
|
||||
if (!rowsHere || rowsHere.length === 0) {
|
||||
if (builtIns[name]) { // built-in survives an unbound key
|
||||
setFace(el, { sub: stock[name].sub, addr: stock[name].addr,
|
||||
desc: stock[name].desc }, false);
|
||||
return;
|
||||
}
|
||||
var wasBound = !!stock[name].sub;
|
||||
setFace(el, null, false);
|
||||
if (wasBound) el.setAttribute("data-desc", "Unbound in your file "
|
||||
+ "(stock: " + stock[name].sub + ")");
|
||||
return;
|
||||
}
|
||||
var face = faceOf(rowsHere[0]);
|
||||
if (!face) { setFace(el, null, false); return; }
|
||||
if (rowsHere.length > 1) {
|
||||
face.desc += " · also: " + rowsHere.slice(1).map(function (t) {
|
||||
return t.slice(2).join(" ");
|
||||
}).join(" / ");
|
||||
}
|
||||
var isCustom = (face.sub !== stock[name].sub)
|
||||
|| (face.addr !== stock[name].addr);
|
||||
if (isCustom) customCount += 1;
|
||||
setFace(el, face, isCustom);
|
||||
});
|
||||
|
||||
statusEl.innerHTML = "Showing <b>" + (fname || "your bindings")
|
||||
+ "</b> — " + rows + " row(s), <b>" + customCount
|
||||
+ "</b> key(s) differ from stock.";
|
||||
resetEl.style.display = "inline";
|
||||
legendEl.style.display = customCount ? "inline" : "none";
|
||||
if (extras.length) {
|
||||
extrasEl.innerHTML = "<b>Also in your file</b> (not on the keyboard): "
|
||||
+ extras.map(function (l) {
|
||||
return "<code>" + l.replace(/&/g, "&").replace(/</g, "<")
|
||||
+ "</code>";
|
||||
}).join(" · ");
|
||||
extrasEl.style.display = "block";
|
||||
} else {
|
||||
extrasEl.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
var loaderEl = document.getElementById("loader");
|
||||
var fileEl = document.getElementById("bindfile");
|
||||
fileEl.addEventListener("change", function () {
|
||||
var f = fileEl.files && fileEl.files[0];
|
||||
if (!f) return;
|
||||
var r = new FileReader();
|
||||
r.onload = function () { applyFile(String(r.result), f.name); };
|
||||
r.readAsText(f);
|
||||
});
|
||||
resetEl.addEventListener("click", resetBoard);
|
||||
["dragover", "dragenter"].forEach(function (ev) {
|
||||
loaderEl.addEventListener(ev, function (e) {
|
||||
e.preventDefault();
|
||||
loaderEl.classList.add("dragover");
|
||||
});
|
||||
});
|
||||
["dragleave", "drop"].forEach(function (ev) {
|
||||
loaderEl.addEventListener(ev, function (e) {
|
||||
e.preventDefault();
|
||||
loaderEl.classList.remove("dragover");
|
||||
});
|
||||
});
|
||||
loaderEl.addEventListener("drop", function (e) {
|
||||
var f = e.dataTransfer && e.dataTransfer.files
|
||||
&& e.dataTransfer.files[0];
|
||||
if (!f) return;
|
||||
var r = new FileReader();
|
||||
r.onload = function () { applyFile(String(r.result), f.name); };
|
||||
r.readAsText(f);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user