From a9f851ead5fd69e1fd0178fb1fb97e26c5480629 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 18 Jul 2026 11:21:44 -0700 Subject: [PATCH] Add M30 controller support to fight --- .../public/aesthetic.computer/disks/fight.mjs | 40 ++++- .../aesthetic.computer/disks/gamepad.mjs | 166 ++++++++++++------ .../lib/gamepad-mappings.mjs | 47 +++++ 3 files changed, 194 insertions(+), 59 deletions(-) diff --git a/system/public/aesthetic.computer/disks/fight.mjs b/system/public/aesthetic.computer/disks/fight.mjs index feb4ce7828..6f8bb73f7b 100644 --- a/system/public/aesthetic.computer/disks/fight.mjs +++ b/system/public/aesthetic.computer/disks/fight.mjs @@ -10,6 +10,9 @@ // a d ← → move // s ↓ punch // +// gamepads: D-pad/left stick moves. Xbox A/X/RB punches and B/Y/LB blocks; +// on an 8BitDo M30 the lower A/B/C row punches and upper X/Y/Z row blocks. +// // the sim is deterministic and snapshot-clean; nothing here touches a network. // // fight:boxes hit and hurt boxes, tick, checksum @@ -20,6 +23,7 @@ import * as game from "../lib/fight/sim.mjs"; import { syncTest, report, drills } from "../lib/fight/rollback.mjs"; import { createSession, createLink } from "../lib/fight/session.mjs"; +import { getGamepadMapping } from "../lib/gamepad-mappings.mjs"; const { P, PN, G, ST, SFX, SUB, BODY_W, BODY_H, PUNCH_F } = game; @@ -47,6 +51,9 @@ const SUIT = [ let s; // the whole simulation, one Int32Array const held = [0, 0]; +const keyHeld = [0, 0]; +const padsHeld = [0, 0]; +const padInput = new Map(); let half = 0; // ac sims at 120hz; the game ticks at 60 let boxes = false; let verdict; // synctest result, when asked for @@ -91,6 +98,31 @@ function boot({ colon }) { } } +function refreshPad(e) { + const player = e.gamepad & 1; + let pad = padInput.get(e.gamepad); + if (!pad) { + pad = { id: e.gamepadId || "standard", buttons: new Set(), axes: {} }; + padInput.set(e.gamepad, pad); + } + if (e.gamepadId) pad.id = e.gamepadId; + if (e.button !== undefined) { + if (e.action === "push") pad.buttons.add(e.button); + else if (e.action === "release") pad.buttons.delete(e.button); + } + if (e.axis !== undefined) pad.axes[e.axis] = e.value; + + const mapping = getGamepadMapping(pad.id); + const down = (list) => list.some((button) => pad.buttons.has(button)); + let bits = 0; + if (pad.buttons.has(14) || (pad.axes[0] || 0) < -0.35) bits |= game.LEFT; + if (pad.buttons.has(15) || (pad.axes[0] || 0) > 0.35) bits |= game.RIGHT; + if (down(mapping.fight?.punch || [0, 2, 5])) bits |= game.PUNCH; + if (down(mapping.fight?.block || [1, 3, 4])) bits |= game.BLOCK; + padsHeld[player] = bits; + held[player] = keyHeld[player] | padsHeld[player]; +} + function sim({ sound }) { if ((half ^= 1)) return; // every other 120hz step if (s[G.MATCH]) return; @@ -114,10 +146,12 @@ function sim({ sound }) { function act({ event: e }) { for (const key in KEYS) { const [p, bit] = KEYS[key]; - if (e.is(`keyboard:down:${key}`)) held[p] |= bit; - if (e.is(`keyboard:up:${key}`)) held[p] &= ~bit; + if (e.is(`keyboard:down:${key}`)) keyHeld[p] |= bit; + if (e.is(`keyboard:up:${key}`)) keyHeld[p] &= ~bit; + held[p] = keyHeld[p] | padsHeld[p]; } - if (e.is("keyboard:down:r") && s[G.MATCH]) { + if (e.is("gamepad")) refreshPad(e); + if ((e.is("keyboard:down:r") || (e.button === 9 && e.action === "push")) && s[G.MATCH]) { // a networked rematch is a fresh session on a fresh wire, not a reset state net = net ? open(opened) : null; s = net ? net.a.state : game.create(s[G.RNG]); diff --git a/system/public/aesthetic.computer/disks/gamepad.mjs b/system/public/aesthetic.computer/disks/gamepad.mjs index 8ebb3c2d5b..e6b76b6e64 100644 --- a/system/public/aesthetic.computer/disks/gamepad.mjs +++ b/system/public/aesthetic.computer/disks/gamepad.mjs @@ -20,15 +20,22 @@ import { import { cssColors } from "../lib/num.mjs"; const { keys } = Object; -const connectedGamepads = {}; -const buttonHistory = []; // Store button press history -const MAX_HISTORY = 256; // Enough frames to fill full screen height -const recentEvents = []; -const MAX_EVENTS = 10; -const FONT = "MatrixChunky8"; // Pixel-perfect font - -const timelinePaintings = [null, null, null, null]; // One timeline per gamepad - +const connectedGamepads = {}; +const buttonHistory = []; // Store button press history +const MAX_HISTORY = 256; // Enough frames to fill full screen height +const recentEvents = []; +const MAX_EVENTS = 10; +const FONT = "MatrixChunky8"; // Pixel-perfect font +const soundQueue = []; + +// Stable pitches make the test useful as a tiny instrument. The M30 face +// layout (A B C / X Y Z) lands on two related three-note rows. +const BUTTON_TONES = [261.63, 329.63, 523.25, 659.25, 783.99, 392.0, + 174.61, 196.0, 220.0, 246.94, 293.66, 349.23, 880.0, 987.77, 1174.66, + 1318.51, 1567.98]; + +const timelinePaintings = [null, null, null, null]; // One timeline per gamepad + function drawXboxControllerDiagram({ ink, line, circle, box }, gp, x, y) { const baseX = x; const baseY = y; @@ -124,13 +131,13 @@ function drawXboxControllerDiagram({ ink, line, circle, box }, gp, x, y) { ink(getColors(4)).box(baseX + 14, topY, 8, 2); // LB (inner) ink(getColors(7)).box(baseX + width - 14, topY, 12, 2); // RT (outer/right) - ink(getColors(5)).box(baseX + width - 22, topY, 8, 2); // RB (inner) -} - -function draw8BitDoMicroDiagram({ ink, line, circle, box }, gp, x, y) { - const baseX = x; - const baseY = y; - const gamepadId = gp.id || "standard"; + ink(getColors(5)).box(baseX + width - 22, topY, 8, 2); // RB (inner) +} + +function draw8BitDoMicroDiagram({ ink, line, circle, box }, gp, x, y) { + const baseX = x; + const baseY = y; + const gamepadId = gp.id || "standard"; // Get button colors from mapping const getColors = (btnIndex) => { @@ -216,17 +223,47 @@ function draw8BitDoMicroDiagram({ ink, line, circle, box }, gp, x, y) { // L2/R2 triggers - with gap in center (original mapping) ink(getColors(8)).box(baseX + 14, shoulderY, 8, 2); // L2 (narrower, gap on right) ink(getColors(9)).box(baseX + 26, shoulderY, 8, 2); // R2 (narrower, gap on left) - - // Home/Heart button at bottom center - ink(getColors(12)).box(centerX - 2, baseY + 19, 4, 3); -} - -function drawGamepadDiagram({ ink, line, circle, box }, gp, x, y) { - // Detect controller type and use appropriate diagram - if (gp.id && (gp.id.includes("045e") && gp.id.includes("0b13"))) { - // Xbox Controller - drawXboxControllerDiagram({ ink, line, circle, box }, gp, x, y); - } else { + + // Home/Heart button at bottom center + ink(getColors(12)).box(centerX - 2, baseY + 19, 4, 3); +} + +function draw8BitDoM30Diagram({ ink }, gp, x, y) { + const color = (button) => { + const colors = getButtonColors(gp.id, button); + return gp.pressedButtons.includes(button) ? colors.active : colors.inactive; + }; + const axisX = parseFloat(gp.axes["0"] || 0); + const axisY = parseFloat(gp.axes["1"] || 0); + const active = gp.pressedButtons.length || Math.abs(axisX) > 0.3 || Math.abs(axisY) > 0.3; + const w = 56; + + // The M30's low, rounded silhouette and its defining two rows of three. + ink(active ? "slategray" : "dimgray").box(x + 4, y, w - 8, 24); + ink(active ? "slategray" : "dimgray").box(x, y + 5, w, 14); + + const dx = x + 10, dy = y + 12, ds = 4; + ink(axisY < -0.3 || gp.pressedButtons.includes(12) ? "lime" : "gray").box(dx, dy - 5, ds, ds); + ink(axisY > 0.3 || gp.pressedButtons.includes(13) ? "lime" : "gray").box(dx, dy + 5, ds, ds); + ink(axisX < -0.3 || gp.pressedButtons.includes(14) ? "lime" : "gray").box(dx - 5, dy, ds, ds); + ink(axisX > 0.3 || gp.pressedButtons.includes(15) ? "lime" : "gray").box(dx + 5, dy, ds, ds); + ink("black").box(dx, dy, ds, ds); + + const fx = x + 34, fy = y + 7, bs = 4, gap = 6; + [2, 3, 4].forEach((button, i) => ink(color(button)).box(fx + i * gap, fy, bs, bs)); + [0, 1, 5].forEach((button, i) => ink(color(button)).box(fx + i * gap, fy + 8, bs, bs)); + ink(color(8)).box(x + 23, y + 8, 3, 2); + ink(color(9)).box(x + 27, y + 8, 3, 2); +} + +function drawGamepadDiagram({ ink, line, circle, box }, gp, x, y) { + // Detect controller type and use appropriate diagram + if (/m30|^xbox one game controller$/i.test(gp.id || "")) { + draw8BitDoM30Diagram({ ink, line, circle, box }, gp, x, y); + } else if (/xbox|xinput|045e/i.test(gp.id || "")) { + // Xbox Controller + drawXboxControllerDiagram({ ink, line, circle, box }, gp, x, y); + } else { // 8BitDo Micro or generic draw8BitDoMicroDiagram({ ink, line, circle, box }, gp, x, y); } @@ -413,14 +450,16 @@ function paint({ wipe, ink, write, screen, line, circle, box, painting, paste, h if (gp.id) { const nameY = slotY + slotHeight - diagramHeight - metadataHeight - 8; const maxWidth = slotWidth - 8; - - // Detect controller type and show friendly name - let displayName = gp.id; - if (displayName.includes("045e") && displayName.includes("0b13")) { - displayName = "Xbox Controller"; - } else if (displayName.includes("2dc8") && displayName.includes("9020")) { - displayName = "8BitDo Micro"; - } else { + + // Detect controller type and show friendly name + let displayName = gp.id; + if (/m30|^xbox one game controller$/i.test(displayName)) { + displayName = "8BitDo M30 — 6 Button"; + } else if (/xbox|xinput|045e/i.test(displayName)) { + displayName = "Xbox Controller"; + } else if (displayName.includes("2dc8") && displayName.includes("9020")) { + displayName = "8BitDo Micro"; + } else { // Clean up generic names - remove vendor/product IDs displayName = displayName.replace(/\s*\(STANDARD GAMEPAD[^)]*\)/i, ""); displayName = displayName.replace(/\s*\(Vendor:[^)]*\)/i, ""); @@ -507,13 +546,27 @@ function paint({ wipe, ink, write, screen, line, circle, box, painting, paste, h } } } - }); -} - -function act({ event: e }) { - if (e.is("gamepad")) { - const gpIndex = e.gamepad; - + }); +} + +function sim({ sound }) { + while (soundQueue.length) { + const tone = soundQueue.shift(); + sound?.synth?.({ + type: "triangle", + tone, + attack: 0.003, + duration: 0.12, + decay: 0.55, + volume: 0.18, + }); + } +} + +function act({ event: e }) { + if (e.is("gamepad")) { + const gpIndex = e.gamepad; + // Initialize gamepad entry if needed if (!connectedGamepads[gpIndex]) { connectedGamepads[gpIndex] = { @@ -559,16 +612,17 @@ function act({ event: e }) { // Update last event gp.lastEvent = e.name; - // Track button state - if (e.is(`gamepad:${gpIndex}:button`)) { - const buttonIndex = e.button; - if (e.action === "push") { - if (!gp.pressedButtons.includes(buttonIndex)) { - gp.pressedButtons.push(buttonIndex); - } - console.log(`🔴 Button ${buttonIndex} PRESSED -`, getButtonName(gp.id, buttonIndex)); - } else if (e.action === "release") { - gp.pressedButtons = gp.pressedButtons.filter(b => b !== buttonIndex); + // Track button state + if (e.is(`gamepad:${gpIndex}:button`)) { + const buttonIndex = e.button; + if (e.action === "push") { + if (!gp.pressedButtons.includes(buttonIndex)) { + gp.pressedButtons.push(buttonIndex); + } + soundQueue.push(BUTTON_TONES[buttonIndex] || 220 + buttonIndex * 30); + console.log(`🔴 Button ${buttonIndex} PRESSED -`, getButtonName(gp.id, buttonIndex)); + } else if (e.action === "release") { + gp.pressedButtons = gp.pressedButtons.filter(b => b !== buttonIndex); console.log(`⚪ Button ${buttonIndex} RELEASED -`, getButtonName(gp.id, buttonIndex)); } @@ -630,7 +684,7 @@ function act({ event: e }) { // } // function icon() { -// Render an application icon, aka favicon. -// } - -export { paint, act }; +// Render an application icon, aka favicon. +// } + +export { paint, act, sim }; diff --git a/system/public/aesthetic.computer/lib/gamepad-mappings.mjs b/system/public/aesthetic.computer/lib/gamepad-mappings.mjs index 7d98d65f32..49e1de876a 100644 --- a/system/public/aesthetic.computer/lib/gamepad-mappings.mjs +++ b/system/public/aesthetic.computer/lib/gamepad-mappings.mjs @@ -3,6 +3,43 @@ // Uses CSS color names from lib/num.mjs for consistent color coding export const GAMEPAD_MAPPINGS = { + // 8BitDo M30 (Bluetooth, 2.4G, and Wired for Xbox). In X-input mode + // browsers expose its six face buttons as ABXY + the two bumpers. + "M30": { + vendor: "8BitDo", + product: "M30", + description: "Classic six-button arcade controller", + fight: { + punch: [0, 1, 5], // A, B, C + block: [2, 3, 4], // X, Y, Z + }, + buttons: { + 0: { name: "A", position: "face_bottom_left", active: "lime", inactive: "darkgreen" }, + 1: { name: "B", position: "face_bottom_center", active: "red", inactive: "maroon" }, + 2: { name: "X", position: "face_top_left", active: "dodgerblue", inactive: "navy" }, + 3: { name: "Y", position: "face_top_center", active: "yellow", inactive: "darkgoldenrod" }, + 4: { name: "Z", position: "face_top_right", active: "orange", inactive: "sienna" }, + 5: { name: "C", position: "face_bottom_right", active: "turquoise", inactive: "darkcyan" }, + 6: { name: "LT", position: "trigger_left", active: "coral", inactive: "brown" }, + 7: { name: "RT", position: "trigger_right", active: "violet", inactive: "indigo" }, + 8: { name: "Select", position: "center_left", active: "white", inactive: "slategray" }, + 9: { name: "Start", position: "center_right", active: "springgreen", inactive: "darkslategray" }, + 10: { name: "L3", position: "stick_left_press", active: "cyan", inactive: "cadetblue" }, + 11: { name: "R3", position: "stick_right_press", active: "magenta", inactive: "purple" }, + 12: { name: "D-Up", position: "dpad_up", active: "chartreuse", inactive: "forestgreen" }, + 13: { name: "D-Down", position: "dpad_down", active: "greenyellow", inactive: "olivedrab" }, + 14: { name: "D-Left", position: "dpad_left", active: "limegreen", inactive: "seagreen" }, + 15: { name: "D-Right", position: "dpad_right", active: "yellowgreen", inactive: "darkolivegreen" }, + 16: { name: "Home", position: "center", active: "hotpink", inactive: "darkslateblue" }, + }, + axes: { + 0: { name: "D-Pad X", type: "dpad", direction: "horizontal", active: { left: "lime", right: "chartreuse" }, inactive: "darkseagreen" }, + 1: { name: "D-Pad Y", type: "dpad", direction: "vertical", active: { up: "greenyellow", down: "yellowgreen" }, inactive: "olivedrab" }, + 2: { name: "Right X", type: "stick", direction: "horizontal", active: "magenta", inactive: "dimgray" }, + 3: { name: "Right Y", type: "stick", direction: "vertical", active: "violet", inactive: "dimgray" }, + }, + layout: { type: "m30", hasAnalogSticks: false, width: 56, height: 24 }, + }, // 8BitDo Micro - Ultra-compact Bluetooth controller "8BitDo Micro gamepad": { vendor: "8BitDo", @@ -101,6 +138,10 @@ export const GAMEPAD_MAPPINGS = { vendor: "Generic", product: "Standard Gamepad", description: "W3C Standard Gamepad mapping", + fight: { + punch: [0, 2, 5], // A, X, RB + block: [1, 3, 4], // B, Y, LB + }, buttons: { 0: { name: "A/X", position: "face_bottom", active: "lime", inactive: "darkgreen" }, @@ -171,6 +212,12 @@ export const GAMEPAD_MAPPINGS = { * @returns {object|null} - The mapping object or null if not found */ export function getGamepadMapping(gamepadId) { + gamepadId ||= "standard"; + // The officially licensed M30 Wired Controller for Xbox identifies this + // generically in browsers, with no 8BitDo/M30 vendor text to detect. + if (gamepadId.toLowerCase() === "xbox one game controller") { + return GAMEPAD_MAPPINGS["M30"]; + } // Try exact match first if (GAMEPAD_MAPPINGS[gamepadId]) { return GAMEPAD_MAPPINGS[gamepadId]; -- 2.51.2