Multitouch Virtual Joystick Controller Class, working in APDE

This is for all the APDE users that have issues with the build-in multi-touch system, that not updates the number of active touches array on each finger up event but only evey second or the last finger up event when trying to make a multitouch controller.
Which is flipping urgs…

Or if someone just wants a nice drop in place game controller that comes with a full build-in default gamepad if nothing is configured, as true “drop in aka. copy & paste” option.
Just to save some headache :laughing:

Well, here you go folks, and to save it for myself at a secure place, i present to you free of charge the:
“Super multi joystick deluxe”

Overview

All active touches that your device can handle,
are getting accurately tracked.
Unlimited virtual joysticks.
Unlimited buttons.
Easy and straightforward access to joystick and button actions, and additionally a interface for more in-depth access.
Comes with a user manual!
Many configurable features like vibration, slide-on press, slide-off release, auto-center joystick, toggle and timed buttons.
Customizable style including joystick and button state images, through json layout files or directly through setter methods.

Here the manual: super_multi_joystick_deluxe_manual

Example code comes maybe in another post due to message size restrictions..
But here a zip of the sketch:
Super_multi_joystick_deluxe_sketch

Here an example sketch.


// All the needed imports
import android.view.MotionEvent;
import android.os.Vibrator;
import android.content.Context;

// Callback interfaces
interface JoystickListener {
  void onJoystickPress(int id);
  void onJoystickMove(int id, float x, float y);
  void onJoystickRelease(int id);
}

interface ButtonListener {
  void onButtonPress(int index, String name);
  void onButtonRelease(int index, String name);
}

// Global joystick outputs
HashMap<Integer, Float[]> joystickOutputs = new HashMap<Integer, Float[]>();

// Main sketch
TouchController controller;
Sprite player; // Just for testing

void setup() {
  fullScreen();
  orientation(LANDSCAPE);
  controller = new TouchController("layout.json"); // if the file doesn't exist, a default layout will be loaded.
  player = new Sprite(width/2, height/2, 5.0);

  // Set joystick listener
  controller.setJoystickListener(new JoystickListener() {
    @Override
      void onJoystickPress(int id) {
      println("Joystick " + id + " pressed (listener)");
    }
    @Override
      void onJoystickMove(int id, float x, float y) {
      if (id == 1) {
        player.update(x, y);
      }
    }
    @Override
      void onJoystickRelease(int id) {
      println("Joystick " + id + " released (listener)");
    }
  }
  );

  // Set button listener
  controller.setButtonListener(new ButtonListener() {
    @Override
      void onButtonPress(int index, String name) {
      println("Button " + index + ": " + name + " pressed (listener)");
      if (name.equals("fire") || name.equals("toggle")) {
        fill(0, 255, 0); // Example: change player color
      }
    }
    @Override
      void onButtonRelease(int index, String name) {
      println("Button " + index + ": " + name + " released (listener)");
      if (name.equals("fire") || name.equals("toggle")) {
        fill(255, 0, 0); // Reset color
      }
    }
  }
  );
}

void draw() {
  background(255);
  controller.update();
  player.draw();
  controller.draw();

  // Display joystick values for debugging
  textAlign(CENTER);
  fill(0);
  int yOffset = 50;
  for (Integer id : joystickOutputs.keySet()) {
    Float[] outputs = joystickOutputs.get(id);
    text("Joystick " + id + ": X=" + nf(outputs[0], 1, 2) + ", Y=" + nf(outputs[1], 1, 2), width/2, yOffset);
    yOffset += 50;
  }
}

void touchStarted(TouchEvent e) {
  controller.touchStarted((MotionEvent) e.getNative());
}

void touchMoved(TouchEvent e) {
  controller.touchMoved((MotionEvent) e.getNative());
}

void touchEnded(TouchEvent e) {
  controller.touchEnded((MotionEvent) e.getNative());
}


// Action functions (fallback)

void action(int buttonIndex, String name) {
  println("Button " + buttonIndex + ": " + name + " pressed");
}

void actionUp(int buttonIndex, String name) {
  println("Button " + buttonIndex + ": " + name + " released");
}

void actionJoystick(int joystickIndex) {
  println("Joystick " + joystickIndex + " pressed");
}

void actionJoystickUp(int joystickIndex) {
  println("Joystick " + joystickIndex + " released");
}


// Controller class
class TouchController {
  // Timer type constants
  public static final String TIMER_TYPE_NONE = "none";
  public static final String TIMER_TYPE_BLOCK = "block";
  public static final String TIMER_TYPE_LATCHING = "latching";
  public static final String TIMER_TYPE_TOGGLE = "toggle";

  // Vibrator
  private Vibrator vibrator;
  private JoystickListener joystickListener;
  private ButtonListener buttonListener;

  // Joystick class
  private class Joystick {
    int id;
    float x, y;
    float radius;
    float dotRadius;
    float deadzone;
    float dotX, dotY;
    boolean active;
    int touchId;
    boolean returnToCenter;
    boolean slideOffRelease;
    PImage baseImage;
    PImage knobImage;
    PImage knobPressedImage;
    boolean useImageSize;
    boolean visible;
    float opacity;
    float touchRadius;
    long vibrateDuration;
    boolean vibrateOnMove;
    float vibrateOnMoveDist;
    float sensitivity;
    float lastVibrateX, lastVibrateY;

    Joystick(int id, float x, float y, float radius) {
      this.id = id;
      this.x = x <= 1.0 ? x * width : x;
      this.y = y <= 1.0 ? y * height : y;
      this.radius = radius;
      this.dotRadius = radius * 0.3;
      this.deadzone = 0.2;
      this.dotX = this.x;
      this.dotY = this.y;
      this.active = false;
      this.touchId = -1;
      this.returnToCenter = true;
      this.slideOffRelease = false;
      this.baseImage = null;
      this.knobImage = null;
      this.knobPressedImage = null;
      this.useImageSize = false;
      this.visible = true;
      this.opacity = 1.0;
      this.touchRadius = radius;
      this.vibrateDuration = 0;
      this.vibrateOnMove = false;
      this.vibrateOnMoveDist = 0.2;
      this.sensitivity = 1.0;
      this.lastVibrateX = this.x;
      this.lastVibrateY = this.y;
      joystickOutputs.put(id, new Float[]{0.0, 0.0});
    }
  }

  // Button class
  private class Button {
    float x, y;
    float radius;
    boolean active;
    int touchId;
    int index;
    String name;
    boolean slideOffRelease;
    boolean slideOn;
    String timerType;
    long timerDuration;
    long activationTime;
    long blockedUntil;
    PImage inactiveImage;
    PImage activeImage;
    boolean useImageSize;
    boolean visible;
    float opacity;
    float touchRadius;
    long vibrateDuration;

    Button(String name, float x, float y, float radius) {
      this(name, x, y, radius, false, true, TIMER_TYPE_NONE, 0);
    }

    Button(String name, float x, float y, float radius, boolean slideOffRelease, boolean slideOn, 
      String timerType, long timerDuration) {
      this.name = name;
      this.x = x <= 1.0 ? x * width : x;
      this.y = y <= 1.0 ? y * height : y;
      this.radius = radius;
      this.active = false;
      this.touchId = -1;
      this.index = nextButtonIndex++;
      this.slideOffRelease = slideOffRelease;
      this.slideOn = slideOn;
      this.timerType = timerType;
      this.timerDuration = timerDuration;
      this.activationTime = 0;
      this.blockedUntil = 0;
      this.inactiveImage = null;
      this.activeImage = null;
      this.useImageSize = false;
      this.visible = true;
      this.opacity = 1.0;
      this.touchRadius = radius;
      this.vibrateDuration = 0;
    }
  }

  // Storage
  private HashMap<Integer, Joystick> joysticks;
  private HashMap<String, Button> buttons;
  private int nextButtonIndex;
  private ArrayList<Integer> touchIds;

  // Empty constructor
  TouchController() {
    joysticks = new HashMap<Integer, Joystick>();
    buttons = new HashMap<String, Button>();
    nextButtonIndex = 0;
    touchIds = new ArrayList<Integer>();
    vibrator = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE);
    joystickListener = null;
    buttonListener = null;
  }

  // JSON constructor
  TouchController(String filename) {
    this();
    loadLayout(filename);
  }

  // Set listeners
  void setJoystickListener(JoystickListener listener) {
    this.joystickListener = listener;
  }

  void setButtonListener(ButtonListener listener) {
    this.buttonListener = listener;
  }

  // Vibrate method
  void vibrate(long duration) {
    if (vibrator != null && duration > 0) {
      vibrator.vibrate(duration);
    }
  }

  // Reload layout
  void reloadLayout(String filename) {
    loadLayout(filename);
  }

  // Load layout from JSON file
  void loadLayout(String filename) {
    try {
      JSONObject json = loadJSONObject(filename);
      joysticks.clear();
      joystickOutputs.clear();
      buttons.clear();
      nextButtonIndex = 0;

      // Parse joysticks
      JSONArray joysticksJson = json.getJSONArray("joysticks");
      for (int i = 0; i < joysticksJson.size(); i++) {
        JSONObject joy = joysticksJson.getJSONObject(i);
        int id = joy.getInt("id");
        float x = joy.getFloat("x");
        float y = joy.getFloat("y");
        float radius = joy.getFloat("radius");
        addJoystick(id, x, y, radius);
        Joystick j = joysticks.get(id);
        j.dotRadius = joy.isNull("dotRadius") ? j.dotRadius : max(0, joy.getFloat("dotRadius"));
        j.deadzone = joy.isNull("deadzone") ? j.deadzone : max(0, min(1, joy.getFloat("deadzone")));
        j.slideOffRelease = joy.isNull("slideOffRelease") ? j.slideOffRelease : joy.getBoolean("slideOffRelease");
        j.returnToCenter = joy.isNull("returnToCenter") ? j.returnToCenter : joy.getBoolean("returnToCenter");
        j.useImageSize = joy.isNull("useImageSize") ? j.useImageSize : joy.getBoolean("useImageSize");
        j.visible = joy.isNull("visible") ? j.visible : joy.getBoolean("visible");
        j.opacity = joy.isNull("opacity") ? j.opacity : max(0, min(1, joy.getFloat("opacity")));
        j.touchRadius = joy.isNull("touchRadius") ? j.touchRadius : max(j.radius, joy.getFloat("touchRadius"));
        j.vibrateDuration = joy.isNull("vibrateDuration") ? 0 : (long) max(0, joy.getFloat("vibrateDuration"));
        j.vibrateOnMove = joy.isNull("vibrateOnMove") ? j.vibrateOnMove : joy.getBoolean("vibrateOnMove");
        j.vibrateOnMoveDist = joy.isNull("vibrateOnMoveDist") ? j.vibrateOnMoveDist : max(0, joy.getFloat("vibrateOnMoveDist"));
        j.sensitivity = joy.isNull("sensitivity") ? 1.0 : max(0, min(2, joy.getFloat("sensitivity")));
        String baseImage = joy.isNull("baseImage") ? null : joy.getString("baseImage");
        String knobImage = joy.isNull("knobImage") ? null : joy.getString("knobImage");
        String knobPressedImage = joy.isNull("knobPressedImage") ? null : joy.getString("knobPressedImage");
        if (baseImage != null) setJoystickBaseImage(id, baseImage);
        if (knobImage != null) setJoystickKnobImage(id, knobImage, knobPressedImage);
      }

      // Parse buttons
      JSONArray buttonsJson = json.getJSONArray("buttons");
      for (int i = 0; i < buttonsJson.size(); i++) {
        JSONObject btn = buttonsJson.getJSONObject(i);
        String name = btn.getString("name");
        float x = btn.getFloat("x");
        float y = btn.getFloat("y");
        float radius = btn.getFloat("radius");
        boolean slideOffRelease = btn.isNull("slideOffRelease") ? false : btn.getBoolean("slideOffRelease");
        boolean slideOn = btn.isNull("slideOn") ? true : btn.getBoolean("slideOn");
        String timerType = btn.isNull("timerType") ? TIMER_TYPE_NONE : btn.getString("timerType");
        long timerDuration = btn.isNull("timerDuration") ? 0 : (long) max(0, btn.getFloat("timerDuration"));
        boolean useImageSize = btn.isNull("useImageSize") ? false : btn.getBoolean("useImageSize");
        boolean visible = btn.isNull("visible") ? true : btn.getBoolean("visible");
        float opacity = btn.isNull("opacity") ? 1.0 : max(0, min(1, btn.getFloat("opacity")));
        float touchRadius = btn.isNull("touchRadius") ? radius : max(radius, btn.getFloat("touchRadius"));
        long vibrateDuration = btn.isNull("vibrateDuration") ? 0 : (long) max(0, btn.getFloat("vibrateDuration"));

        if (timerType.equals("none") || timerType.equals("block") || timerType.equals("latching") || timerType.equals("toggle")) {
          addButton(name, x, y, radius, slideOffRelease, slideOn, timerType, timerDuration);
          Button b = buttons.get(name);
          b.useImageSize = useImageSize;
          b.visible = visible;
          b.opacity = opacity;
          b.touchRadius = touchRadius;
          b.vibrateDuration = vibrateDuration;
          String inactiveImage = btn.isNull("inactiveImage") ? null : btn.getString("inactiveImage");
          String activeImage = btn.isNull("activeImage") ? null : btn.getString("activeImage");
          if (inactiveImage != null && activeImage != null) {
            setButtonImage(name, inactiveImage, activeImage);
          }
        }
      }
    } 
    catch (Exception e) {
      // Fallback default layout: one joystick, two buttons
      addJoystick(1, 0.2, 0.8, 100);
      Joystick j = joysticks.get(1);
      j.dotRadius = 30;
      j.deadzone = 0.2;
      j.slideOffRelease = false;
      j.returnToCenter = true;
      addButton("fire", 0.8, 0.6, 50, true, true, "none", 0);
      addButton("jump", 0.8, 0.4, 50, false, true, "block", 500);
    }
  }

  // Add joystick
  void addJoystick(int id, float x, float y, float radius) {
    if (!joysticks.containsKey(id)) {
      joysticks.put(id, new Joystick(id, x, y, radius));
    }
  }

  // Remove joystick
  void removeJoystick(int id) {
    if (joysticks.containsKey(id)) {
      joysticks.remove(id);
      joystickOutputs.remove(id);
    }
  }

  // Clear all joysticks
  void clearJoysticks() {
    joysticks.clear();
    joystickOutputs.clear();
  }

  // Set joystick properties
  void setJoystickBaseImage(int id, String filename) {
    if (joysticks.containsKey(id)) {
      PImage img = loadImage(filename);
      joysticks.get(id).baseImage = img != null ? img : null;
    }
  }

  void setJoystickKnobImage(int id, String filename, String pressedFilename) {
    if (joysticks.containsKey(id)) {
      Joystick j = joysticks.get(id);
      PImage img = loadImage(filename);
      j.knobImage = img != null ? img : null;
      PImage pressedImg = pressedFilename != null ? loadImage(pressedFilename) : null;
      j.knobPressedImage = pressedImg != null ? pressedImg : null;
    }
  }

  void setJoystickDotRadius(int id, float dotRadius) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).dotRadius = max(0, dotRadius);
    }
  }

  void setJoystickDeadzone(int id, float deadzone) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).deadzone = max(0, min(1, deadzone));
    }
  }

  void setJoystickSlideOffRelease(int id, boolean enabled) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).slideOffRelease = enabled;
    }
  }

  void setJoystickReturnToCenter(int id, boolean enabled) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).returnToCenter = enabled;
    }
  }

  void setJoystickUseImageSize(int id, boolean enabled) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).useImageSize = enabled;
    }
  }

  void setJoystickVisible(int id, boolean visible) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).visible = visible;
    }
  }

  void setJoystickOpacity(int id, float opacity) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).opacity = max(0, min(1, opacity));
    }
  }

  void setJoystickTouchRadius(int id, float touchRadius) {
    if (joysticks.containsKey(id)) {
      Joystick j = joysticks.get(id);
      j.touchRadius = max(j.radius, touchRadius);
    }
  }

  void setJoystickVibrateDuration(int id, long duration) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).vibrateDuration = (long) max(0, duration);
    }
  }

  void setJoystickVibrateOnMove(int id, boolean enabled) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).vibrateOnMove = enabled;
    }
  }

  void setJoystickVibrateOnMoveDist(int id, float dist) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).vibrateOnMoveDist = max(0, dist);
    }
  }

  void setJoystickSensitivity(int id, float sensitivity) {
    if (joysticks.containsKey(id)) {
      joysticks.get(id).sensitivity = max(0, min(2, sensitivity));
    }
  }

  // Set button properties
  void setButtonImage(String name, String inactiveFilename, String activeFilename) {
    if (buttons.containsKey(name)) {
      Button btn = buttons.get(name);
      btn.inactiveImage = loadImage(inactiveFilename);
      btn.activeImage = loadImage(activeFilename);
    }
  }

  void setButtonUseImageSize(String name, boolean enabled) {
    if (buttons.containsKey(name)) {
      buttons.get(name).useImageSize = enabled;
    }
  }

  void setButtonSlideOffRelease(String name, boolean enabled) {
    if (buttons.containsKey(name)) {
      buttons.get(name).slideOffRelease = enabled;
    }
  }

  void setButtonSlideOn(String name, boolean enabled) {
    if (buttons.containsKey(name)) {
      buttons.get(name).slideOn = enabled;
    }
  }

  void setButtonTimerType(String name, String timerType) {
    if (buttons.containsKey(name) && 
      (timerType.equals("none") || 
      timerType.equals("block") || 
      timerType.equals("latching") || 
      timerType.equals("toggle"))) {
      Button btn = buttons.get(name);
      btn.timerType = timerType;
      if (!timerType.equals(TIMER_TYPE_BLOCK)) {
        btn.blockedUntil = 0;
      }
      if (timerType.equals(TIMER_TYPE_NONE) || timerType.equals(TIMER_TYPE_TOGGLE)) {
        btn.timerDuration = 0;
      }
    }
  }

  void setButtonTimerDuration(String name, long timerDuration) {
    if (buttons.containsKey(name)) {
      Button btn = buttons.get(name);
      btn.timerDuration = (long) max(0, timerDuration);
      if (btn.active && btn.timerType.equals(TIMER_TYPE_BLOCK) && btn.activationTime > 0) {
        btn.blockedUntil = btn.activationTime + btn.timerDuration;
      }
    }
  }

  void setButtonVisible(String name, boolean visible) {
    if (buttons.containsKey(name)) {
      buttons.get(name).visible = visible;
    }
  }

  void setButtonOpacity(String name, float opacity) {
    if (buttons.containsKey(name)) {
      buttons.get(name).opacity = max(0, min(1, opacity));
    }
  }

  void setButtonTouchRadius(String name, float touchRadius) {
    if (buttons.containsKey(name)) {
      Button btn = buttons.get(name);
      btn.touchRadius = max(btn.radius, touchRadius);
    }
  }

  void setButtonVibrateDuration(String name, long duration) {
    if (buttons.containsKey(name)) {
      buttons.get(name).vibrateDuration = (long) max(0, duration);
    }
  }

  // Add a button (minimal) for quick builds
  void addButton(String name, float x, float y, float radius) {
    addButton(name, x, y, radius, false, true, TIMER_TYPE_NONE, 0);
  }

  // Add a button (full, for JSON)
  void addButton(String name, float x, float y, float radius, boolean slideOffRelease, boolean slideOn, 
    String timerType, long timerDuration) {
    if (!buttons.containsKey(name) && 
      (timerType.equals("none") || 
      timerType.equals("block") || 
      timerType.equals("latching") || 
      timerType.equals("toggle"))) {
      buttons.put(name, new Button(name, x, y, radius, slideOffRelease, slideOn, timerType, timerDuration));
    }
  }

  // Remove a button
  void removeButton(String name) {
    if (buttons.containsKey(name)) {
      buttons.remove(name);
    }
  }

  // Clear all buttons
  void clearButtons() {
    buttons.clear();
    nextButtonIndex = 0;
  }

  void update() {
    // Update joysticks
    for (Joystick j : joysticks.values()) {
      if (!j.active && j.returnToCenter) {
        j.dotX = j.x;
        j.dotY = j.y;
        joystickOutputs.get(j.id)[0] = 0.0;
        joystickOutputs.get(j.id)[1] = 0.0;
      }
    }

    // Update button timers
    long currentTime = millis();
    for (Button btn : buttons.values()) {
      if (btn.timerType.equals(TIMER_TYPE_BLOCK) && btn.blockedUntil > 0 && currentTime >= btn.blockedUntil) {
        btn.blockedUntil = 0;
      }
      if (btn.active && (btn.timerType.equals(TIMER_TYPE_BLOCK) || btn.timerType.equals(TIMER_TYPE_LATCHING))) {
        if (btn.activationTime > 0 && currentTime >= btn.activationTime + btn.timerDuration) {
          btn.active = false;
          btn.touchId = -1;
          btn.activationTime = 0;
          if (buttonListener != null) {
            buttonListener.onButtonRelease(btn.index, btn.name);
          } else {
            actionUp(btn.index, btn.name);
          }
        }
      }
      if (!btn.active && !btn.timerType.equals(TIMER_TYPE_LATCHING) && !btn.timerType.equals(TIMER_TYPE_TOGGLE)) {
        btn.touchId = -1;
        btn.activationTime = 0;
      }
    }
  }

  void draw() {
    // Draw joysticks
    for (Joystick j : joysticks.values()) {
      if (!j.visible) continue;
      pushStyle();
      tint(255, j.opacity * 255);
      // Draw base
      if (j.baseImage != null && !j.useImageSize) {
        image(j.baseImage, j.x - j.radius, j.y - j.radius, j.radius * 2, j.radius * 2);
      } else if (j.baseImage != null) {
        image(j.baseImage, j.x - j.baseImage.width / 2, j.y - j.baseImage.height / 2);
      } else {
        fill(100, 100, 100, j.opacity * 150);
        ellipse(j.x, j.y, j.radius * 2, j.radius * 2);
      }
      // Draw deadzone.
      fill(150, 150, 150, j.opacity * 100);
      ellipse(j.x, j.y, j.radius * j.deadzone * 2, j.radius * j.deadzone * 2);
      // Draw knob
      if (j.knobImage != null && !j.useImageSize) {
        PImage knobImg = (j.active && j.knobPressedImage != null) ? j.knobPressedImage : j.knobImage;
        image(knobImg, j.dotX - j.dotRadius, j.dotY - j.dotRadius, j.dotRadius * 2, j.dotRadius * 2);
      } else if (j.knobImage != null) {
        PImage knobImg = (j.active && j.knobPressedImage != null) ? j.knobPressedImage : j.knobImage;
        image(knobImg, j.dotX - knobImg.width / 2, j.dotY - knobImg.height / 2);
      } else {
        fill(200, 200, 200, j.opacity * 200);
        ellipse(j.dotX, j.dotY, j.dotRadius * 2, j.dotRadius * 2);
      }
      popStyle();
    }

    // Draw buttons
    for (Button btn : buttons.values()) {
      if (!btn.visible) continue;
      pushStyle();
      tint(255, btn.opacity * 255);
      if (btn.inactiveImage != null && btn.activeImage != null && !btn.useImageSize) {
        PImage img = btn.active ? btn.activeImage : btn.inactiveImage;
        image(img, btn.x - btn.radius, btn.y - btn.radius, btn.radius * 2, btn.radius * 2);
      } else if (btn.inactiveImage != null && btn.activeImage != null) {
        PImage img = btn.active ? btn.activeImage : btn.inactiveImage;
        image(img, btn.x - img.width / 2, btn.y - img.height / 2);
      } else {
        fill(btn.active ? color(255, 100, 100, btn.opacity * 255) : color(100, 100, 255, btn.opacity * 150));
        ellipse(btn.x, btn.y, btn.radius * 2, btn.radius * 2);
      }
      popStyle();
    }
  }

  void touchStarted(MotionEvent me) {
    ArrayList<Integer> newIds = getMotionEventTouchIds(me);
    if (me.getActionMasked() != MotionEvent.ACTION_DOWN) {
      newIds.removeAll(touchIds);
    }

    for (int id : newIds) {
      int ptrIdx = me.findPointerIndex(id);
      if (ptrIdx < 0) continue;
      float px = me.getX(ptrIdx);
      float py = me.getY(ptrIdx);

      // Check joysticks
      for (Joystick j : joysticks.values()) {
        if (!j.visible || j.active || dist(px, py, j.x, j.y) >= j.touchRadius) continue;
        j.active = true;
        j.touchId = id;
        updateJoystick(j, px, py);
        if (j.vibrateDuration > 0) vibrate(j.vibrateDuration);
        if (joystickListener != null) {
          joystickListener.onJoystickPress(j.id);
        } else {
          actionJoystick(j.id);
        }
        break;
      }

      // Check buttons
      for (Button btn : buttons.values()) {
        if (!btn.visible || dist(px, py, btn.x, btn.y) >= btn.touchRadius) continue;
        if (btn.timerType.equals(TIMER_TYPE_BLOCK) && btn.blockedUntil > millis()) continue;
        if (btn.timerType.equals(TIMER_TYPE_TOGGLE)) {
          // Toggle mode: flip active state
          btn.active = !btn.active;
          btn.touchId = id;
          if (btn.active) {
            btn.activationTime = millis();
            if (btn.vibrateDuration > 0) vibrate(btn.vibrateDuration);
            if (buttonListener != null) {
              buttonListener.onButtonPress(btn.index, btn.name);
            } else {
              action(btn.index, btn.name);
            }
          } else {
            btn.touchId = -1;
            btn.activationTime = 0;
            if (buttonListener != null) {
              buttonListener.onButtonRelease(btn.index, btn.name);
            } else {
              actionUp(btn.index, btn.name);
            }
          }
        } else if (!btn.active) {
          // Non-toggle modes: activate if not already active
          btn.active = true;
          btn.touchId = id;
          btn.activationTime = millis();
          if (btn.timerType.equals(TIMER_TYPE_BLOCK)) {
            btn.blockedUntil = btn.activationTime + btn.timerDuration;
          }
          if (btn.vibrateDuration > 0) vibrate(btn.vibrateDuration);
          if (buttonListener != null) {
            buttonListener.onButtonPress(btn.index, btn.name);
          } else {
            action(btn.index, btn.name);
          }
        }
        break;
      }
    }

    touchIds = getMotionEventTouchIds(me);
  }

  void touchMoved(MotionEvent me) {
    for (int i = 0; i < me.getPointerCount(); i++) {
      int id = me.getPointerId(i);
      if (!touchIds.contains(id)) continue;

      int ptrIdx = me.findPointerIndex(id);
      if (ptrIdx < 0) continue;
      float px = me.getX(ptrIdx);
      float py = me.getY(ptrIdx);

      // Update joysticks
      for (Joystick j : joysticks.values()) {
        if (!j.active || j.touchId != id) continue;
        if (j.slideOffRelease && dist(px, py, j.x, j.y) > j.touchRadius) {
          j.active = false;
          j.touchId = -1;
          if (j.returnToCenter) {
            j.dotX = j.x;
            j.dotY = j.y;
            joystickOutputs.get(j.id)[0] = 0.0;
            joystickOutputs.get(j.id)[1] = 0.0;
          }
          if (joystickListener != null) {
            joystickListener.onJoystickRelease(j.id);
          } else {
            actionJoystickUp(j.id);
          }
        } else {
          float moveDist = dist(px, py, j.lastVibrateX, j.lastVibrateY);
          if (j.vibrateOnMove && moveDist > j.radius * j.vibrateOnMoveDist) {
            vibrate(j.vibrateDuration);
            j.lastVibrateX = px;
            j.lastVibrateY = py;
          }
          updateJoystick(j, px, py);
          if (joystickListener != null) {
            joystickListener.onJoystickMove(j.id, joystickOutputs.get(j.id)[0], joystickOutputs.get(j.id)[1]);
          }
        }
      }

      // Update buttons
      for (Button btn : buttons.values()) {
        boolean withinRadius = dist(px, py, btn.x, btn.y) < btn.touchRadius;
        if (btn.touchId == id) {
          if (btn.slideOffRelease && !withinRadius && !btn.timerType.equals(TIMER_TYPE_LATCHING) && !btn.timerType.equals(TIMER_TYPE_TOGGLE)) {
            btn.active = false;
            btn.touchId = -1;
            btn.activationTime = 0;
            if (buttonListener != null) {
              buttonListener.onButtonRelease(btn.index, btn.name);
            } else {
              actionUp(btn.index, btn.name);
            }
          } else if (btn.timerType.equals(TIMER_TYPE_TOGGLE)) {
            btn.touchId = -1; // Allow new touch to toggle
          }
        } else if (btn.slideOn && !btn.active && withinRadius && !btn.timerType.equals(TIMER_TYPE_TOGGLE)) {
          if (btn.timerType.equals(TIMER_TYPE_BLOCK) && btn.blockedUntil > millis()) continue;
          btn.active = true;
          btn.touchId = id;
          btn.activationTime = millis();
          if (btn.timerType.equals(TIMER_TYPE_BLOCK)) {
            btn.blockedUntil = btn.activationTime + btn.timerDuration;
          }
          if (btn.vibrateDuration > 0) vibrate(btn.vibrateDuration);
          if (buttonListener != null) {
            buttonListener.onButtonPress(btn.index, btn.name);
          } else {
            action(btn.index, btn.name);
          }
        }
      }
    }
  }

  void touchEnded(MotionEvent me) {
    ArrayList<Integer> oldIds = new ArrayList<Integer>();
    touchIds = getMotionEventTouchIds(me);
    int action = me.getActionMasked();

    if (action == MotionEvent.ACTION_UP) {
      oldIds = new ArrayList<Integer>(touchIds);
    } else if (action == MotionEvent.ACTION_POINTER_UP) {
      int idx = me.getActionIndex();
      int id = me.getPointerId(idx);
      oldIds.add(id);
    } else if (action == MotionEvent.ACTION_MOVE) {
      ArrayList<Integer> currentIds = getTouchesTouchIds();
      oldIds = new ArrayList<Integer>(currentIds);
      oldIds.removeAll(touchIds);
    }

    // Release joysticks
    for (Joystick j : joysticks.values()) {
      if (oldIds.contains(j.touchId)) {
        j.active = false;
        j.touchId = -1;
        if (j.returnToCenter) {
          j.dotX = j.x;
          j.dotY = j.y;
          joystickOutputs.get(j.id)[0] = 0.0;
          joystickOutputs.get(j.id)[1] = 0.0;
        }
        if (joystickListener != null) {
          joystickListener.onJoystickRelease(j.id);
        } else {
          actionJoystickUp(j.id);
        }
      }
    }

    // Release buttons
    for (Button btn : buttons.values()) {
      if (oldIds.contains(btn.touchId) && !btn.timerType.equals(TIMER_TYPE_LATCHING) && !btn.timerType.equals(TIMER_TYPE_TOGGLE)) {
        btn.active = false;
        btn.touchId = -1;
        btn.activationTime = 0;
        if (buttonListener != null) {
          buttonListener.onButtonRelease(btn.index, btn.name);
        } else {
          actionUp(btn.index, btn.name);
        }
      } else if (oldIds.contains(btn.touchId)) {
        btn.touchId = -1;
      }
    }

    touchIds.removeAll(oldIds);
  }

  private void updateJoystick(Joystick j, float x, float y) {
    float dx = x - j.x;
    float dy = y - j.y;
    float dist = sqrt(dx * dx + dy * dy);

    // Constrain to circle
    if (dist > j.radius) {
      float angle = atan2(dy, dx);
      dx = cos(angle) * j.radius;
      dy = sin(angle) * j.radius;
      dist = j.radius;
    }

    j.dotX = j.x + dx;
    j.dotY = j.y + dy;

    // Apply deadzone and sensitivity
    float deadzoneRadius = j.radius * j.deadzone;
    if (dist < deadzoneRadius) {
      joystickOutputs.get(j.id)[0] = 0.0;
      joystickOutputs.get(j.id)[1] = 0.0;
    } else {
      float range = j.radius - deadzoneRadius;
      float outputX = (dx - (dx / dist) * deadzoneRadius) / range;
      float outputY = -(dy - (dy / dist) * deadzoneRadius) / range;
      joystickOutputs.get(j.id)[0] = constrain(outputX * j.sensitivity, -1.0, 1.0);
      joystickOutputs.get(j.id)[1] = constrain(outputY * j.sensitivity, -1.0, 1.0);
    }
  }

  private ArrayList<Integer> getMotionEventTouchIds(MotionEvent me) {
    ArrayList<Integer> ids = new ArrayList<Integer>();
    for (int i = 0; i < me.getPointerCount(); i++) {
      ids.add(me.getPointerId(i));
    }
    return ids;
  }

  private ArrayList<Integer> getTouchesTouchIds() {
    ArrayList<Integer> ids = new ArrayList<Integer>();
    for (int i = 0; i < touches.length; i++) {
      ids.add(touches[i].id);
    }
    return ids;
  }
}

// Simple Sprite class for testing
class Sprite {
  float x, y;
  float speed;

  Sprite(float x, float y, float speed) {
    this.x = x;
    this.y = y;
    this.speed = speed;
  }

  void update(float dx, float dy) {
    x += dx * speed;
    y += dy * speed;
    x = constrain(x, 0, width);
    y = constrain(y, 0, height);
  }

  void draw() {
    fill(255, 0, 0);
    ellipse(x, y, 50, 50);
  }
}

1 Like