/*
Processing Sketch — Light Chase Game Dashboard
Screen layout left to right: Node 1 | Teacher | Node 2
Node IDs used in serial: 1 | 0 | 2
screenPos[] maps left-to-right screen position to node ID:
screenPos[0] = 1 (left = Node 1)
screenPos[1] = 0 (center = Teacher)
screenPos[2] = 2 (right = Node 2)
Arrow keys move by screen position, then convert to node ID.
Keyboard:
LEFT / RIGHT → move between panels left/right
UP / DOWN → move between top / bottom LED
R → restart after shutdown
Mouse:
Click any LED circle to move the light there.
Click RESTART button when game is shut down.
Serial FROM Arduino:
LIGHT:node,pos light moved here
NODE:id,HALL:val hall sensor state
NODE:id,STATUS:1 node came online
EVENT:TIMEOUT_MOVE:n timeout move n of 2
EVENT:SHUTDOWN game shut down
STATUS:MASTER_READY master booted
STATUS:RESTARTED game restarted
Serial TO Arduino:
MOVE:node,pos\n
RESTART\n
*/
import processing.serial.*;
Serial port;
// ── Screen position → node ID mapping ────────────────────────
// Left=0, Center=1, Right=2 (screen positions)
// Maps to node IDs: Node1=1, Teacher=0, Node2=2
int[] screenPos = {1, 0, 2}; // screenPos[screenCol] = nodeID
// Reverse: nodeID → screenCol
int nodeToCol(int nodeID) {
for (int i = 0; i < 3; i++) if (screenPos[i] == nodeID) return i;
return 1;
}
// ── Game state ────────────────────────────────────────────────
int lightNode = -1;
int lightPos = -1;
int moveCount = 0;
boolean gameActive = false;
boolean shutdown = false;
boolean[] hallActive = {false, false, false}; // indexed by nodeID
boolean[] nodeOnline = {true, false, false}; // indexed by nodeID
// ── Event log ─────────────────────────────────────────────────
ArrayList<String> eventLog = new ArrayList<String>();
int maxLog = 8;
// ── Layout ────────────────────────────────────────────────────
int panelW = 190;
int panelH = 300;
int panelY = 90;
// Panel x positions by screen column (0=left, 1=center, 2=right)
int[] panelX = {30, 295, 560};
int ledCenterX = 95;
int topLedY = 130;
int botLedY = 230;
int ledR = 32;
int restartBtnX = 300, restartBtnY = 420, restartBtnW = 200, restartBtnH = 38;
// ── Colors ────────────────────────────────────────────────────
color bgColor = color(15, 15, 22);
color panelBg = color(26, 26, 38);
color ledOff = color(40, 40, 58);
color ledOn = color(255, 210, 50);
color ledGlowCol = color(255, 230, 100, 70);
color hallGreen = color(60, 200, 110);
color textMain = color(215, 215, 235);
color textDim = color(110, 110, 145);
color teacherAccent = color(80, 130, 220);
color node1Accent = color(60, 200, 160);
color node2Accent = color(200, 90, 165);
color warnColor = color(230, 140, 40);
color shutdownColor = color(220, 60, 60);
// ── Setup ─────────────────────────────────────────────────────
void setup() {
size(800, 530);
smooth();
// Search for Teacher board — avoids hardcoded index
String targetPort = null;
String[] ports = Serial.list();
println("=== Available serial ports ===");
for (int i = 0; i < ports.length; i++) {
println("[" + i + "] " + ports[i]);
if ((ports[i].contains("usbmodem") || ports[i].contains("usbserial") || ports[i].contains("COM"))
&& !ports[i].contains("Bluetooth") && !ports[i].contains("Cricut")) {
targetPort = ports[i];
}
}
println("==============================");
if (targetPort != null) {
println("Connecting to: " + targetPort);
try {
port = new Serial(this, targetPort, 115200);
port.bufferUntil('\n');
println("Connected OK");
// Ask Teacher to resend its current state after a short delay
delay(500);
port.write("STATUS\n");
} catch (Exception e) {
println("ERROR: " + e.getMessage());
println("Close Arduino Serial Monitor and restart Processing.");
}
} else {
println("ERROR: Teacher board not found. Check USB and close Serial Monitor.");
}
}
// ── Draw ──────────────────────────────────────────────────────
void draw() {
background(bgColor);
drawTitle();
// Draw panels in screen order: col 0, 1, 2
drawNode(screenPos[0], "NODE 1", node1Accent, panelX[0]);
drawNode(screenPos[1], "TEACHER", teacherAccent, panelX[1]);
drawNode(screenPos[2], "NODE 2", node2Accent, panelX[2]);
drawMoveCounter();
drawEventLog();
if (shutdown) drawShutdownOverlay();
}
void drawTitle() {
fill(textMain);
textAlign(CENTER);
textSize(20);
text("Light Chase — ESP-NOW Dashboard", width / 2, 44);
textSize(12);
fill(textDim);
text("Click LED · Arrow keys to move · Hall sensor resets 3s timer · ignored twice = shutdown", width / 2, 65);
}
void drawNode(int nodeID, String label, color accent, int px) {
boolean online = nodeOnline[nodeID];
boolean warn = !shutdown && gameActive && moveCount > 0;
fill(panelBg);
stroke(shutdown ? shutdownColor : (warn ? warnColor : accent));
strokeWeight(online ? (shutdown ? 1.5 : 2) : 0.8);
rect(px, panelY, panelW, panelH, 12);
noStroke();
fill(shutdown ? shutdownColor : accent);
textAlign(CENTER);
textSize(14);
text(label, px + ledCenterX, panelY + 28);
fill(hallActive[nodeID] ? hallGreen : color(45, 55, 50));
ellipse(px + ledCenterX, panelY + 52, 12, 12);
fill(hallActive[nodeID] ? hallGreen : textDim);
textSize(10);
text("HALL", px + ledCenterX, panelY + 68);
// TOP LED — Teacher uses GPIO9, nodes use GPIO9 top / GPIO2 bottom
String topPin = (nodeID == 0) ? "GPIO 9" : "GPIO 9";
String botPin = (nodeID == 0) ? "GPIO 8" : "GPIO 2";
drawLed(nodeID, 0, px + ledCenterX, panelY + topLedY, accent, topPin, "TOP");
drawLed(nodeID, 1, px + ledCenterX, panelY + botLedY, accent, botPin, "BOT");
if (!online) {
fill(0, 0, 0, 90);
noStroke();
rect(px, panelY, panelW, panelH, 12);
fill(textDim);
textAlign(CENTER);
textSize(12);
text("waiting...", px + ledCenterX, panelY + panelH / 2);
}
}
void drawLed(int node, int pos, int cx, int cy, color accent, String pinLabel, String posLabel) {
boolean active = (lightNode == node && lightPos == pos && !shutdown);
boolean hovered = !shutdown && isLedHovered(node, pos);
if (active) {
noStroke();
fill(ledGlowCol);
ellipse(cx, cy, ledR * 2 + 18, ledR * 2 + 18);
}
stroke(active ? ledOn : (hovered ? accent : color(55, 55, 75)));
strokeWeight(active ? 2.5 : 1.5);
fill(active ? ledOn : (hovered ? color(50, 52, 72) : ledOff));
ellipse(cx, cy, ledR * 2, ledR * 2);
noStroke();
fill(active ? color(25, 22, 5) : textDim);
textAlign(CENTER);
textSize(10);
text(posLabel, cx, cy - 4);
text(pinLabel, cx, cy + 9);
}
void drawMoveCounter() {
if (shutdown) return;
int cx = width / 2;
int y = panelY + panelH + 22;
textAlign(CENTER);
textSize(12);
fill(textDim);
text("timeout moves used:", cx, y);
for (int i = 0; i < 2; i++) {
color c = i < moveCount ? (moveCount >= 2 ? shutdownColor : warnColor) : color(50, 50, 68);
fill(c);
noStroke();
ellipse(cx - 16 + i * 32, y + 18, 18, 18);
}
textSize(11);
fill(moveCount == 0 ? textDim : (moveCount == 1 ? warnColor : shutdownColor));
String msg = moveCount == 0 ? "active" : (moveCount == 1 ? "1 warning — 1 left" : "shutting down...");
text(msg, cx, y + 36);
}
void drawEventLog() {
int lx = 20;
int ly = panelY + panelH + 80;
fill(textDim);
textAlign(LEFT);
textSize(11);
text("Event log:", lx, ly);
ly += 16;
int start = max(0, eventLog.size() - maxLog);
for (int i = eventLog.size() - 1; i >= start; i--) {
String e = eventLog.get(i);
if (e.contains("SHUTDOWN")) fill(shutdownColor);
else if (e.contains("TIMEOUT_MOVE")) fill(warnColor);
else if (e.contains("HALL:1")) fill(hallGreen);
else fill(textDim);
text(e, lx, ly);
ly += 15;
}
}
void drawShutdownOverlay() {
fill(0, 0, 0, 140);
noStroke();
rect(0, panelY - 10, width, panelH + 20);
fill(shutdownColor);
textAlign(CENTER);
textSize(26);
text("GAME OFF", width / 2, panelY + panelH / 2 - 10);
textSize(13);
fill(textDim);
text("No hall sensor detected for 9 seconds", width / 2, panelY + panelH / 2 + 18);
boolean hov = mouseX > restartBtnX && mouseX < restartBtnX + restartBtnW &&
mouseY > restartBtnY && mouseY < restartBtnY + restartBtnH;
fill(hov ? color(60, 180, 100) : color(40, 140, 75));
noStroke();
rect(restartBtnX, restartBtnY, restartBtnW, restartBtnH, 8);
fill(255);
textAlign(CENTER);
textSize(14);
text("RESTART (or press R)", restartBtnX + restartBtnW / 2, restartBtnY + 24);
}
// ── Hit testing — uses nodeToCol to find the panel x ─────────
int ledGlobalX(int node) { return panelX[nodeToCol(node)] + ledCenterX; }
int ledGlobalY(int pos) { return panelY + (pos == 0 ? topLedY : botLedY); }
boolean isLedHovered(int node, int pos) {
return dist(mouseX, mouseY, ledGlobalX(node), ledGlobalY(pos)) <= ledR;
}
// ── Mouse ─────────────────────────────────────────────────────
void mouseMoved() {
if (shutdown) { cursor(ARROW); return; }
for (int n = 0; n <= 2; n++)
for (int p = 0; p <= 1; p++)
if (isLedHovered(n, p)) { cursor(HAND); return; }
cursor(ARROW);
}
void mousePressed() {
if (shutdown) {
boolean overBtn = mouseX > restartBtnX && mouseX < restartBtnX + restartBtnW &&
mouseY > restartBtnY && mouseY < restartBtnY + restartBtnH;
if (overBtn) sendRestart();
return;
}
for (int n = 0; n <= 2; n++)
for (int p = 0; p <= 1; p++)
if (isLedHovered(n, p)) { sendMove(n, p); return; }
}
// ── Keyboard ──────────────────────────────────────────────────
void keyPressed() {
if (shutdown) {
if (key == 'r' || key == 'R') sendRestart();
return;
}
if (lightNode < 0) return;
int currentCol = nodeToCol(lightNode); // current screen column (0,1,2)
if (keyCode == LEFT) {
int newCol = max(0, currentCol - 1);
sendMove(screenPos[newCol], lightPos);
} else if (keyCode == RIGHT) {
int newCol = min(2, currentCol + 1);
sendMove(screenPos[newCol], lightPos);
} else if (keyCode == UP) {
sendMove(lightNode, 0);
} else if (keyCode == DOWN) {
sendMove(lightNode, 1);
}
}
// ── Send helpers ──────────────────────────────────────────────
void sendMove(int node, int pos) {
if (port != null) port.write("MOVE:" + node + "," + pos + "\n");
lightNode = node;
lightPos = pos;
moveCount = 0;
logEvent("→ MOVE node=" + node + " pos=" + pos);
}
void sendRestart() {
if (port != null) port.write("RESTART\n");
shutdown = false;
gameActive = true;
moveCount = 0;
logEvent("→ RESTART sent");
}
// ── Serial receive ────────────────────────────────────────────
void serialEvent(Serial p) {
String line = trim(p.readStringUntil('\n'));
if (line == null || line.length() == 0) return;
logEvent(line);
if (line.startsWith("LIGHT:")) {
String[] parts = split(line.substring(6), ',');
if (parts.length == 2) { lightNode = int(parts[0]); lightPos = int(parts[1]); }
}
else if (line.startsWith("NODE:")) {
String[] parts = split(line.substring(5), ',');
if (parts.length == 2) {
int id = int(parts[0]);
if (parts[1].startsWith("HALL:") && id >= 0 && id <= 2)
hallActive[id] = (int(parts[1].substring(5)) == 1);
if (parts[1].startsWith("STATUS:") && id >= 0 && id <= 2)
nodeOnline[id] = true;
}
}
else if (line.startsWith("EVENT:TIMEOUT_MOVE:")) {
moveCount = int(line.substring(19));
}
else if (line.equals("EVENT:SHUTDOWN")) {
shutdown = true; gameActive = false; moveCount = 2;
lightNode = -1; lightPos = -1;
}
else if (line.equals("STATUS:MASTER_READY") || line.equals("STATUS:RESTARTED")) {
gameActive = true; shutdown = false; moveCount = 0;
nodeOnline[0] = true;
}
}
void logEvent(String msg) {
String ts = nf(hour(),2)+":"+nf(minute(),2)+":"+nf(second(),2);
eventLog.add("["+ts+"] "+msg);
if (eventLog.size() > 50) eventLog.remove(0);
}