Bust-A-Move DIY : une manette potentiomètre pour retrouver le feeling « manivelle »

L’idée

Dans Puzzle Bobble / Bust-A-Move, on ne vise pas vraiment « au stick » : on tourne une manivelle / molette pour placer la flèche. Sur PC, clavier ou pad classique cassent ce feeling.

Le projet, c’est donc une manette dédiée : un potentiomètre rotatif pour reproduire ce geste, plus les boutons indispensables — Credit, Start, Fire. Branched en USB sur un PC qui fait tourner le jeu via MAME.

Pourquoi un potentiomètre ?

Parce que c’est le geste du jeu :

  • tu tournes → la flèche se déplace
  • tu t’arrêtes → la flèche reste

L’intérêt est de coller à la manivelle d’origine : un axe qui tourne sous la main, pas un stick poussé à gauche/droite.

Ce que contient la manette

  • Arduino Pro Micro (USB natif)
  • Potentiomètre RK097N — la « manivelle »
  • 3 boutons — Credit, Start, Fire

Câblage type : pot sur A0 / VCC / GND, Fire pin 5, Start pin 6, Credit pin 7.

Comment ça marche

Le Pro Micro se comporte comme un clavier USB :

  • pot → ← / → (visée)
  • Fire → Ctrl
  • Start → 1
  • Credit → 5

MAME reçoit des entrées classiques. Un outil série permet de régler la sensibilité (scaledecay) sans reflash à chaque essai.

Sur le set Neo-Geo (pbobblen), la visée MAME est un joystick L/R : le firmware traduit le mouvement du pot en maintiens gauche/droite pour coller au feeling manivelle.

Côté PC

  • MAME + ROM légalement obtenue
  • petit script de lancement
  • option scanlines CRT si on aime le look tube

En jeu

Credit → Start → tourner la molette → Fire.

Astuce : pot au milieu en début de manche pour aligner centre molette / flèche au centre.

Pour qui ?

Pour ceux qui veulent le geste du jeu (tourner pour viser), sans construire une borne. Une manette simple, soudable, et tout de suite plus fidèle qu’un pad.

Limites (honnêtes)

Un potentiomètre a des butées, contrairement à une vraie manivelle arcade sans fin. Le firmware approxime le feeling. Pour un spinner infini, il faudrait un encodeur (ex. KY-040) — et idéalement un set MAME avec vrai dial.

/*
  Bust-A-Move controller — pbobblen (Neo-Geo joy L/R, no Dial)

  Pot movement accumulates "steer debt" then plays it out as Left/Right holds
  so a full pot sweep can cover the full in-game aim range.

  Serial 115200: help | stream | scale N | decay N | mode track|rate | ...
*/

#include "Keyboard.h"

#if !defined(USBCON)
#error "Leonardo ou Pro Micro 5V 16MHz requis."
#endif

const int PIN_POT = A0;
const int PIN_FIRE = 5;
const int PIN_START = 6;
const int PIN_CREDIT = 7;

enum AimMode : uint8_t { MODE_TRACK = 0, MODE_RATE = 1 };

// Higher scale = more aim per pot move. Too high => cursor hits end before pot butée.
int aimScale = 2;
// Each loop reduce |steerDebt|. Higher = less angle per pot move.
int steerDecay = 3;
int potNoise = 2;
int potCenter = 512;
int potDeadzone = 30;
bool invertPot = false;
AimMode aimMode = MODE_TRACK;
bool streamTelemetry = false;

const unsigned long LOOP_MS = 2;

static int lastPot = -1;
static int steerDebt = 0;  // >0 right, <0 left
static bool fireWas = false;
static bool startWas = false;
static bool creditWas = false;
static bool leftHeld = false;
static bool rightHeld = false;
static String cmdBuf;

static int readPotAveraged() {
  long sum = 0;
  for (int i = 0; i < 4; i++) {
    sum += analogRead(PIN_POT);
  }
  return (int)(sum / 4);
}

static void setLeft(bool on) {
  if (on && !leftHeld) {
    Keyboard.press(KEY_LEFT_ARROW);
    leftHeld = true;
  } else if (!on && leftHeld) {
    Keyboard.release(KEY_LEFT_ARROW);
    leftHeld = false;
  }
}

static void setRight(bool on) {
  if (on && !rightHeld) {
    Keyboard.press(KEY_RIGHT_ARROW);
    rightHeld = true;
  } else if (!on && rightHeld) {
    Keyboard.release(KEY_RIGHT_ARROW);
    rightHeld = false;
  }
}

static void releaseSteer() {
  setLeft(false);
  setRight(false);
}

static void syncKey(bool pressed, bool &was, uint8_t key) {
  if (pressed && !was) {
    Keyboard.press(key);
  } else if (!pressed && was) {
    Keyboard.release(key);
  }
  was = pressed;
}

static void applyTrackAim(int pot) {
  int delta = pot - lastPot;
  if (abs(delta) > potNoise) {
    lastPot = pot;
    if (invertPot) {
      delta = -delta;
    }
    // Accumulate rotation to apply (amplified by aimScale).
    long add = (long)delta * aimScale;
    if (add > 2000) {
      add = 2000;
    }
    if (add < -2000) {
      add = -2000;
    }
    steerDebt += (int)add;
    if (steerDebt > 8000) {
      steerDebt = 8000;
    }
    if (steerDebt < -8000) {
      steerDebt = -8000;
    }
  }

  // Play out debt as key holds.
  if (steerDebt > 0) {
    setLeft(false);
    setRight(true);
    steerDebt -= steerDecay;
    if (steerDebt < 0) {
      steerDebt = 0;
    }
  } else if (steerDebt < 0) {
    setRight(false);
    setLeft(true);
    steerDebt += steerDecay;
    if (steerDebt > 0) {
      steerDebt = 0;
    }
  } else {
    releaseSteer();
  }
}

static void applyRateAim(int pot) {
  steerDebt = 0;
  int delta = pot - potCenter;
  if (invertPot) {
    delta = -delta;
  }
  if (abs(delta) <= potDeadzone) {
    releaseSteer();
  } else if (delta < 0) {
    setRight(false);
    setLeft(true);
  } else {
    setLeft(false);
    setRight(true);
  }
  lastPot = pot;
}

static void printHelp() {
  Serial.println(F("=== pbobblen pot -> Left/Right ==="));
  Serial.println(F("help status stream"));
  Serial.println(F("mode track | mode rate"));
  Serial.println(F("scale N   (def 2 — si curseur au bout trop tot: BAISSER)"));
  Serial.println(F("decay N   (def 2 — si curseur au bout trop tot: MONTER)"));
  Serial.println(F("noise N / dead N / invert / center"));
  Serial.println(F("Fire=Ctrl Start=1 Credit=5"));
}

static void printStatus() {
  Serial.print(F("mode="));
  Serial.print(aimMode == MODE_TRACK ? F("track") : F("rate"));
  Serial.print(F(" scale="));
  Serial.print(aimScale);
  Serial.print(F(" decay="));
  Serial.print(steerDecay);
  Serial.print(F(" noise="));
  Serial.print(potNoise);
  Serial.print(F(" dead="));
  Serial.print(potDeadzone);
  Serial.print(F(" invert="));
  Serial.print(invertPot ? 1 : 0);
  Serial.print(F(" debt="));
  Serial.print(steerDebt);
  Serial.print(F(" pot="));
  Serial.println(readPotAveraged());
}

static void handleCommand(String line) {
  line.trim();
  line.toLowerCase();
  if (line.length() == 0) {
    return;
  }

  if (line == "help" || line == "?") {
    printHelp();
  } else if (line == "status" || line == "s") {
    printStatus();
  } else if (line == "stream") {
    streamTelemetry = !streamTelemetry;
    Serial.print(F("stream="));
    Serial.println(streamTelemetry ? 1 : 0);
  } else if (line == "invert") {
    invertPot = !invertPot;
    Serial.print(F("invert="));
    Serial.println(invertPot ? 1 : 0);
  } else if (line == "mode track") {
    aimMode = MODE_TRACK;
    lastPot = readPotAveraged();
    steerDebt = 0;
    releaseSteer();
    Serial.println(F("mode=track"));
  } else if (line == "mode rate") {
    aimMode = MODE_RATE;
    steerDebt = 0;
    releaseSteer();
    Serial.println(F("mode=rate"));
  } else if (line == "center") {
    potCenter = readPotAveraged();
    lastPot = potCenter;
    steerDebt = 0;
    releaseSteer();
    Serial.print(F("center="));
    Serial.println(potCenter);
  } else if (line.startsWith("scale ")) {
    aimScale = constrain(line.substring(6).toInt(), 1, 40);
    Serial.print(F("scale="));
    Serial.println(aimScale);
  } else if (line.startsWith("decay ")) {
    steerDecay = constrain(line.substring(6).toInt(), 1, 50);
    Serial.print(F("decay="));
    Serial.println(steerDecay);
  } else if (line.startsWith("noise ")) {
    potNoise = constrain(line.substring(6).toInt(), 0, 50);
    Serial.print(F("noise="));
    Serial.println(potNoise);
  } else if (line.startsWith("dead ")) {
    potDeadzone = constrain(line.substring(5).toInt(), 0, 200);
    Serial.print(F("dead="));
    Serial.println(potDeadzone);
  } else {
    Serial.println(F("inconnu — help"));
  }
}

static void pollSerial() {
  while (Serial.available() > 0) {
    char c = (char)Serial.read();
    if (c == '\n' || c == '\r') {
      if (cmdBuf.length() > 0) {
        handleCommand(cmdBuf);
        cmdBuf = "";
      }
    } else if (cmdBuf.length() < 40) {
      cmdBuf += c;
    }
  }
}

void setup() {
  pinMode(PIN_FIRE, INPUT_PULLUP);
  pinMode(PIN_START, INPUT_PULLUP);
  pinMode(PIN_CREDIT, INPUT_PULLUP);

  Keyboard.begin();
  Serial.begin(115200);
  delay(500);

  lastPot = readPotAveraged();
  potCenter = lastPot;
  printHelp();
  printStatus();
}

void loop() {
  pollSerial();

  const int pot = readPotAveraged();
  if (aimMode == MODE_TRACK) {
    applyTrackAim(pot);
  } else {
    applyRateAim(pot);
  }

  syncKey(digitalRead(PIN_FIRE) == LOW, fireWas, KEY_LEFT_CTRL);
  syncKey(digitalRead(PIN_START) == LOW, startWas, '1');
  syncKey(digitalRead(PIN_CREDIT) == LOW, creditWas, '5');

  if (streamTelemetry) {
    static unsigned long lastPrint = 0;
    unsigned long now = millis();
    if (now - lastPrint >= 100) {
      lastPrint = now;
      Serial.print(F("pot="));
      Serial.print(pot);
      Serial.print(F(" debt="));
      Serial.print(steerDebt);
      Serial.print(F(" L="));
      Serial.print(leftHeld);
      Serial.print(F(" R="));
      Serial.println(rightHeld);
    }
  }

  delay(LOOP_MS);
}