Final Project – Motion Ship Against Robot Hands

Source Code Available Here

Previously in:

ContentS


1. Project Concept

The initial idea of my final is to transform my midterm project from an offline PvE game into an engaging online PvP experience. Building upon the PvP framework, I realized in the first weeks working on the final that the latest concept incorporates a physical robot to seemingly operate one of the players within the game. This dual-player setup creates a dynamic competition between a human-controlled player and a robot-controlled player, leveraging the newly established online PvP mechanism. As the physical installation is actually an illusion, the project also serves as a mind experiment to observe to what extent users will discover the installation during the experience.


2. Project Demonstration


3. Implementation Details

Interaction Design

The key components include:

  • Game Logic (p5.js): Manages game states, player actions, and AI behaviors.
  • Robot Hand (Arduino): Translates game commands into physical movements by controlling servos that simulate key presses.
  • Serial Communication: Facilitates real-time data exchange between the p5.js application and the Arduino-controlled robot hand, ensuring synchronized actions.

Physical Installation and Arduino Integration

  1. 3D Printing:
    • Materials: PLA filaments
    • Process:
      Experimental Print

      Separate Print Of Joints and Body
    • Hinges Construction:
      • Materials: 3D-printed molds and hot glue gun.
      • Purpose: Form sturdy and flexible hinges for finger movements.
      • Process: Injected hot glue into the molds and install the hinges between the joints.
    • Tendon Implementation:
      • Materials: Fishing lines.
      • Purpose: Act as tendons to control finger movements.
      • Process: Attached fishing lines to servos and the tips of the fingers.
    • Servo Control:
          • Components: 6 9g servo motors.
          • Control Mechanism: Driven by serial commands from the Arduino, allowing the robot hand to mimic key presses (`w`, `a`, `s`, `d`, `space`, `x`) by turning to specific angles

    • Assembly of the Installation
        • Components: All listed above, LEDs, jump wires, acrylic plates
        • Process:
          Acrylic assembly

#include <Servo.h>

unsigned long previousMillis = 0; // Store the last time the LED was updated
const long interval = 250; // Interval to wait (2 seconds)

// Define servo objects for each finger
Servo indexServo;
Servo middleServo;
Servo ringServo;
Servo pinkyServo;
Servo indexServo2;
Servo ringServo2;

// Define servo pins
const int indexPin = 2;
const int middlePin = 3;
const int ringPin = 4;
const int pinkyPin = 5;
const int indexPin2 = 6;
const int ringPin2 = 7;

// Define LED pins
const int LEDPins[] = {8, 9, 10, 11, 12, 13};
// indexLEDPin, middleLEDPin, ringLEDPin, pinkyLEDPin, indexLEDPin2, ringLEDPin2

// Array to hold servo objects for easy access
Servo servos[6];

// Blink LED while waiting for serial data
const int ledPin = LED_BUILTIN;

// Array to hold default angles
const int fingerDefaultAngles[] = {0, 15, 20, 20, 60, 30};

void setup() {
  // Initialize serial communication
  Serial.begin(9600);
  
  // Attach servos to their respective pins
  servos[0].attach(indexPin);
  servos[1].attach(middlePin);
  servos[2].attach(ringPin);
  servos[3].attach(pinkyPin);
  servos[4].attach(indexPin2);
  servos[5].attach(ringPin2);

  // Set LED pins to output mode
  pinMode(8, OUTPUT);
  pinMode(9, OUTPUT);
  pinMode(10, OUTPUT);
  pinMode(11, OUTPUT);
  pinMode(12, OUTPUT);
  pinMode(13, OUTPUT);
  
  // Initialize all servos to 0 degrees (open position)
  for(int i = 0; i < 6; i++) {
    servos[i].write(0);
    delay(100);
  }
  
  // Initialize LED pin
  pinMode(ledPin, OUTPUT);
  
  // Handshake: Wait for p5.js to send initial data
  while (Serial.available() <= 0) {
    digitalWrite(ledPin, HIGH); // LED on while waiting
    Serial.println("0,0,0,0,0,0"); // Send initial positions
    delay(300);
    digitalWrite(ledPin, LOW);
    delay(50);
  }
}

void loop() {
  // Check if data is available from p5.js
  while (Serial.available()) {
    // digitalWrite(ledPin, HIGH); // LED on while receiving data
    
    // Read the incoming line
    String data = Serial.readStringUntil('\n');
    data.trim(); // Remove any trailing whitespace
    
    // Split the data by commas
    int angles[6];
    int currentIndex = 0;
    int lastComma = -1;
    for(int i = 0; i < data.length(); i++) {
      if(data[i] == ',') {
        angles[currentIndex++] = data.substring(lastComma + 1, i).toInt();
        lastComma = i;
      }
    }
    // Last value after the final comma
    angles[currentIndex] = data.substring(lastComma + 1).toInt();
    
    // Get the current time
    unsigned long currentMillis = millis();

    // Check if the interval has passed
    if (currentMillis - previousMillis >= interval) {
      // Save the last time the LED was updated
      previousMillis = currentMillis;

        // Update servo positions
      for(int i = 0; i < 6; i++) {
        servos[i].write(angles[i]); // Set servo to desired angle
      } 
    }

    for(int i = 0; i < 6; i++) {
      digitalWrite(LEDPins[i], angles[i] != fingerDefaultAngles[i]? HIGH : LOW); // Light the LED accordingly
    }
    // Echo back the angles
    Serial.print(angles[0]);
    for(int i = 1; i < 6; i++) {
      Serial.print(",");
      Serial.print(angles[i]);
    }
    Serial.println();
    // digitalWrite(ledPin, LOW); // Turn off LED after processing
  }
}

p5.js

semi collapsed Project structure

project-root/
├── certs/
├── node_modules/
├── pics/
├── public/
│   ├── gameAssets/
│   ├── src/
│   │   ├── components/
│   │   ├── controllers/
│   │   └── utilities/
│   │   ├── index.html
│   │   ├── ml5.js
│   │   ├── p5.js
│   │   ├── p5.sound.min.js
│   │   ├── p5.web-server.js
│   │   ├── sketch.js
│   │   └── style.css
├── robot_hand_test/
├── install_dependencies.bat
├── LICENSE.txt
├── package-lock.json
├── package.json
├── README.md
├── run_local_server.bat
└── server.js

Online Game Setup

  1. Node.js and Socket.io:
    • Purpose: Establish real-time, bi-directional communication between clients.
    • Implementation: Set up a local server using Node.js and integrated Socket.io to handle event-based communication for synchronizing game states.
  2. Local Server for Data Communication:
    • Function: Manages user connections, broadcasts game state updates, and ensures consistency across all clients.
  3. Synchronized Game State:
    • Outcome: Ensures that both players have an up-to-date and consistent view of the game, enabling fair and competitive interactions.
// server.js

/* Install socket.io and config server
npm init -y
npm install express socket.io
node server.js
*/

/* Install mkcert and generate CERT for https
choco install mkcert
mkcert -install
mkcert <your_local_IP> localhost 127.0.0.1 ::1
mv <localIP>+2.pem server.pem
mv <localIP>+2-key.pem server-key.pem
mkdir certs
mv server.pem certs/
mv server-key.pem certs/
*/

const express = require('express');
const https = require('https');
const socketIo = require('socket.io');
const path = require('path');
const fs = require('fs'); // Required for reading directory contents

const app = express();

// Path to SSL certificates
const sslOptions = {
  key: fs.readFileSync(path.join(__dirname, 'certs', 'server-key.pem')),
  cert: fs.readFileSync(path.join(__dirname, 'certs', 'server.pem')),
};

// Create HTTPS server
const httpsServer = https.createServer(sslOptions, app);
// Initialize Socket.io
const io = socketIo(httpsServer);

// Serve static files from the 'public' directory
app.use(express.static('public'));

// Handle client connections
io.on('connection', (socket) => {
  console.log(`New client connected: ${socket.id}`);

  // Listen for broadcast messages from clients
  socket.on('broadcast', (data) => {
    // console.log(`Broadcast from ${socket.id}:`, data);
    // Emit the data to all other connected clients
    socket.broadcast.emit('broadcast', data);
  });

  // Handle client disconnections
  socket.on('disconnect', () => {
    console.log(`Client disconnected: ${socket.id}`);
  });
});

// Start HTTPS server
const PORT = 3000; // Use desired port
httpsServer.listen(PORT, () => {
  console.log(`HTTPS Server listening on port ${PORT}`);
});

Computer Player Algorithm

The computer player, controlled by AI within p5.js, employs sophisticated algorithms to simulate human-like behaviors, including:

  • Threat Detection and Evasion:
    • Mechanism: Continuously scans for incoming threats (e.g., enemy lasers, objects) and calculates optimal evasion paths to avoid collisions.
  • Strategic Movement and Firing:
    • Behavior: Moves toward or away from the enemy and fires lasers when within range, balancing offensive and defensive strategies based on current game states.
  • Tactic Engine Activation:
    • Function: Activates special abilities (e.g., infinite health or energy) when certain conditions are met, enhancing strategic depth and competitiveness.
// ComputerPlayer.js

class ComputerPlayer extends Player {
    constructor(model, texture, difficulty = 1, behaviorPriority = 'attack') {
      super(model, texture);
      this.difficulty = difficulty; // Higher values mean smarter AI
      this.behaviorPriority = behaviorPriority; // 'survival' or 'attack'
      this.enemy = game.enemy; 
      this.lastActionTime = millis();
      this.actionCooldown = map(this.difficulty, 1, 10, 500, 50); // in milliseconds
      this.actionQueue = []; // Queue of actions to perform
      this.currentAction = null;
      this.firingRange = 100; // Define firing range threshold
      this.bornTime = millis();
      this.difficultyTime = frameCount;
    }
  
    updateAI() {
      // Set local enemy target
      this.enemy = game.enemy; 

      // Count in frame, 1200 = 20s, to increase AI difficulty
      if (frameCount - this.difficultyTime > 1200) {
        this.difficulty ++;
      }

      if (currentTime - this.lastActionTime > this.actionCooldown) {
        console.log(`[AI][${this.behaviorPriority.toUpperCase()}] Deciding next action...`);
        this.decideNextAction();
        this.lastActionTime = currentTime;
      }
  
      // Execute actions from the queue
      this.executeActions();
    }
  
    decideNextAction() {
      // Determine behavior based on priority
      if (this.behaviorPriority === 'survival') {
        this.decideSurvivalActions();
      } else if (this.behaviorPriority === 'attack') {
        this.decideAttackActions();
      } else {
        // Default behavior
        this.decideAttackActions();
      }
    }
  
    decideSurvivalActions() {
      // Abandoned method, will not be used 
      // (unless another behavior mode 'Survival' is to be used)
    }
  
    decideAttackActions() {
      console.log(`[AI][DECIDE] Assessing attack strategies...`);

      // 1. Detect and handle threats
      let threats = this.detectThreats();
      if (threats.hasThreats) {
        console.log(`[AI][DECIDE] Threats detected: ${threats.allThreats.length} threats.`);
        
        if (threats.hasCriticalObjectThreat && this.energy >= 30) {
          console.log(`[AI][DECIDE] Critical object threat detected. Attempting to destroy it.`);
          for (let j = 0; j < 3; j++) {
            this.queueAction('fireAt', threats.criticalObject);
          }
        }
        
        // Evade all detected threats
        let evadeDirection = this.calculateEvasionDirection(threats.allThreats);
        console.log(`[AI][EVADE] Evasion direction: ${JSON.stringify(evadeDirection)}`);
        this.queueMovement(evadeDirection);
      
      } else {
        console.log(`[AI][DECIDE] No immediate threats detected.`);
        // 2. No immediate threats
        if ((this.energy < 40) && (this.enemy.health > 15)) {
          console.log(`[AI][DECIDE] Energy low (${this.energy.toFixed(2)}).`);
          
          if (30 <= this.energy) {
            console.log(`[AI][DECIDE] Energy low. Wait for replenish.`);
          } else {
            // Move towards the closest energyOre to gain energy
            let closestEnergyOre = this.findClosestEnergyOre();
            if (closestEnergyOre) {
                console.log(`[AI][DECIDE] Closest energy ore at (${closestEnergyOre.x}, ${closestEnergyOre.y}). Moving towards it.`);
                
                this.moveTowardsObject(closestEnergyOre);
                for (let j = 0; j < 3; j++) {
                this.queueAction('fireAt', closestEnergyOre); // Attempt to destroy it to collect energy
                }
            } else {
                console.log(`[AI][DECIDE] No energy ore found. Proceeding to attack.`);
                
                // Move towards the enemy and attack
                this.moveTowardsEnemy();
                for (let j = 0; j < 3; j++) {
                this.queueAction('fireAt', this.enemy);
                }
            }
          }
        } else {
          console.log(`[AI][DECIDE] Energy healthy (${this.energy.toFixed(2)}). Moving towards enemy to attack.`);
          
          // Move towards the enemy and attack
          this.moveTowardsEnemy();
          for (let j = 0; j < 3; j++) {
            this.queueAction('fireAt', this.enemy);
          }
        }
      }
  
      // 3. Utilize tactic engine if advantageous
      if (this.shouldUseTacticEngineAttack()) {
        console.log(`[AI][DECIDE] Activating tactic engine.`);
        this.difficulty ++;
        this.queueAction('activateTacticEngine');
      }
    }
  
    executeActions() {
      while (this.actionQueue.length > 0) {
        this.currentAction = this.actionQueue.shift();
        switch (this.currentAction.type) {
          case 'move':
            this.simulateMovement(this.currentAction.direction, this.currentAction.duration);
            break;
          case 'fireAt':
            this.simulateFireAt(this.currentAction.target);
            break;
          case 'activateTacticEngine':
            this.simulateTacticEngine();
            break;
          default:
            break;
        }
      }
    }
  
    simulateMovement(direction, duration = 500) {
      // Log the movement simulation
      console.log(`[AI][MOVE] Simulating movement directions: ${JSON.stringify(direction)} for ${duration}ms.`);
  
      // Direction is an object { up: bool, down: bool, left: bool, right: bool }
      // Duration is in milliseconds; map duration to number of frames based on difficulty
      const frames = Math.max(Math.floor((duration / 1000) * 60 / (11 - this.difficulty)), 1); // Higher difficulty, fewer frames
      console.log(`[AI][MOVE] Calculated frames for movement: ${frames}`);
  
      for (let i = 0; i < frames; i++) {
        if (direction.up) game.aiKeysPressed.w = true;
        if (direction.down) game.aiKeysPressed.s = true;
        if (direction.left) game.aiKeysPressed.a = true;
        if (direction.right) game.aiKeysPressed.d = true;
      }
    }
  
    simulateFire() {
      let currentTime = millis();
      if (currentTime - this.bornTime > stateBufferTime) {
        console.log(`[AI][FIRE] Simulating space key press for firing laser.`);
        // Simulate pressing the space key
        game.aiKeysPressed.space = true;
      } else {
        console.log(`[AI][CEASEFIRE] AI Waiting For Game Loading.`);
      }
    }
  
    simulateFireAt(target) {
      // Calculate distance to target before deciding to fire
      let distance = dist(this.x, this.y, target.x, target.y);
      console.log(`[AI][FIRE_AT] Distance to target (${target.type}): ${distance.toFixed(2)}.`);
  
      if (distance <= this.firingRange) {
        console.log(`[AI][FIRE_AT] Target within firing range (${this.firingRange}). Firing laser.`);
        // Target is close enough; simulate firing
        this.simulateFire();
      } else {
        console.log(`[AI][FIRE_AT] Target out of firing range (${this.firingRange}). Skipping fire.`);
        // Optional: Implement alternative actions if target is out of range
      }
    }
  
    simulateTacticEngine() {
      console.log(`[AI][TACTIC_ENGINE] Simulating 'x' key press for tactic engine activation.`);
      // Simulate pressing the 'x' key
      game.aiKeysPressed.x = true;
    }
  
    queueMovement(direction) {
      // console.log(`[AI][QUEUE] Queuing movement: ${JSON.stringify(direction)}.`);
      this.actionQueue.push({ type: 'move', direction: direction, duration: 500 });
    }
  
    queueAction(actionType, target = null) {
      if (actionType === 'fireAt' && target) {
        // console.log(`[AI][QUEUE] Queuing fireAt action for target: ${target.type} at (${target.x}, ${target.y}).`);
        this.actionQueue.push({ type: actionType, target: target });
      } else {
        // console.log(`[AI][QUEUE] Queuing action: ${actionType}.`);
        this.actionQueue.push({ type: actionType });
      }
    }
  
    detectThreats() {
      let threatsFound = false;
      let criticalObjectThreat = null;
      let allThreats = [];
  
      const laserThreatRange = 5 * this.difficulty; // Adjustable based on difficulty
      const objectThreatRange = 25 * this.difficulty; // Larger range for objects
  
      // Detect laser threats
      for (let laser of game.enemyLaser) {
        let distance = dist(this.x, this.y, laser.x, laser.y);
        if (distance < laserThreatRange) {
          threatsFound = true;
          allThreats.push(laser);
          // console.log(`[AI][DETECT] Laser threat detected at (${laser.x}, ${laser.y}) within range ${laserThreatRange}.`);
        }
      }
  
      // Detect object threats
      for (let obj of game.objects) {
        let distance = dist(this.x, this.y, obj.x, obj.y);
        if (distance < objectThreatRange) {
          // Additionally check z-axis proximity
          if ((obj.z - this.z) < 200) { // Threshold for z-axis proximity
            threatsFound = true;
            criticalObjectThreat = obj;
            allThreats.push(obj);
            // console.log(`[AI][DETECT] Critical object threat detected: ${obj.type} at (${obj.x}, ${obj.y}) within range ${objectThreatRange} and z-proximity.`);
          } else {
            threatsFound = true;
            allThreats.push(obj);
            // console.log(`[AI][DETECT] Object threat detected: ${obj.type} at (${obj.x}, ${obj.y}) within range ${objectThreatRange}.`);
          }
        }
      }
  
      return {
        hasThreats: threatsFound,
        hasCriticalObjectThreat: criticalObjectThreat !== null,
        criticalObject: criticalObjectThreat,
        allThreats: allThreats
      };
    }
  
    calculateEvasionDirection(threats) {
      // Determine evasion direction based on all threats
      let moveX = 0;
      let moveY = 0;
  
      for (let threat of threats) {
        if (threat.z > -2000) {
          let angle = atan2(this.y - threat.y, this.x - threat.x);
          moveX += cos(angle);
          moveY += sin(angle);
          console.log(`[AI][EVADE] Calculating evasion for threat at (${threat.x}, ${threat.y}). 
                        Angle: ${angle.toFixed(2)} radians.`);
        }
      }
  
      // Normalize and determine direction
      if (moveX > 0.5) moveX = 1;
      else if (moveX < -0.5) moveX = -1;
      else moveX = 0;
  
      if (moveY > 0.5) moveY = 1;
      else if (moveY < -0.5) moveY = -1;
      else moveY = 0;
  
      return {
        up: moveY === 1,
        down: moveY === -1,
        left: moveX === -1,
        right: moveX === 1
      };
    }
  
    findClosestEnergyOre() {
      let energyOres = game.objects.filter(obj => obj.type === 'energyOre'); // Assuming objects have a 'type' property
      if (energyOres.length === 0) {
        console.log(`[AI][ENERGY] No energy ore available to collect.`);
        return null;
      }
  
      let closest = energyOres[0];
      let minDistance = dist(this.x, this.y, closest.x, closest.y);
  
      for (let ore of energyOres) {
        let distance = dist(this.x, this.y, ore.x, ore.y);
        if (distance < minDistance) {
          closest = ore;
          minDistance = distance;
        }
      }
  
      console.log(`[AI][ENERGY] Closest energy ore found at (${closest.x}, ${closest.y}) with distance ${minDistance.toFixed(2)}.`);
      return closest;
    }
  
    moveTowardsObject(target) {
      // Determine direction towards the target object
      let dx = target.x - this.x;
      let dy = target.y - this.y;
  
      let direction = {
        up: dy < 20,
        down: dy > -20,
        left: dx < -20,
        right: dx > 20
      };
  
      console.log(`[AI][MOVE_TO_OBJECT] Moving towards ${target.type} at (${target.x}, ${target.y}). Direction: ${JSON.stringify(direction)}.`);
      this.queueMovement(direction);
    }
  
    moveTowardsEnemy() {
      // Determine direction towards the enemy
      let dx = this.enemy.x - this.x;
      let dy = this.enemy.y - this.y;
  
      let direction = {
        up: dy < 20,
        down: dy > -20,
        left: dx < -20,
        right: dx > 20
      };
  
      console.log(`[AI][MOVE_TO_ENEMY] Moving towards enemy at (${this.enemy.x}, ${this.enemy.y}). Direction: ${JSON.stringify(direction)}.`);
      this.queueMovement(direction);
    }
  
    shouldUseTacticEngineSurvival() {
      // Abandoned method
    }
  
    shouldUseTacticEngineAttack() {
      // Decide whether to activate tactic engine based on attack advantage
      if (!this.tacticEngineUsed) {
        if (this.health < 30) {
          console.log(`[AI][TACTIC_ENGINE] Conditions met for tactic engine activation (Health: ${this.health}, Energy: ${this.energy}).`);
          return true;
        }
        if (this.model === assets.models.playerShip2) {
          // Additional condition: If enemy health is low and need more energy to destroy it
          if (game.enemy.health < 30 && this.energy < 50) {
            console.log(`[AI][TACTIC_ENGINE] Condition met for playerShip2: Enemy health is low (${game.enemy.health}).`);
            return true;
          }
        }
      }
      return false;
    }
  
    render() {
      // Add indicators or different visuals for ComputerPlayer
      super.render();
      // Draw AI status
      push();
      fill(255);
      textFont(assets.fonts.ps2p);
      textSize(12);
      textAlign(LEFT, TOP);
      text(`X: ${this.x.toFixed(1)}`+`Y: ${this.y.toFixed(1)}`, this.x - 50, this.y - 75);
      text(`AI Difficulty: ${this.difficulty}`, this.x - 50, this.y - 60);
      if (this.currentAction != null) {
        text(`Behavior: ${this.currentAction.type}`, this.x - 50, this.y - 45);
      }
      pop();
    }
  }

Servo Motion Control

Commands from the AI are translated into servo movements to control the robot hand:

  1. Command Translation:
    • Process: Maps AI decisions to corresponding servo angles, ensuring accurate physical representations of game inputs.
  2. Async Update:
    • Outcome: Ensures that physical actions performed by the robot hand are crowded out by serial communication while keeping in sync with the game’s digital state.
class RobotHandController {
    constructor() {
      this.lastUpdateTime = millis();
    }

    init() {
      //
    }
    
    update() {
      // Update finger bends to Arduino
      this.updateFingerAngles();
    }

    // Update Fingers according to the virtual keys
    updateFingerAngles() {
      // Stop function if no serial connections
      if (!serialActive) return;

      let currentTime = millis();

      const keys = ['w', 'a', 's', 'd', 'space', 'x'];
      const angles = [30, 50, 50, 60, 75, 70]; // Different angles for each key
    
      for (let i = 0; i < 6; i++) {
        if (game.aiKeysPressed[keys[i]] === true) {
          if (fingerAngles[i] != angles[i]) {
            fingerAngles[i] = angles[i];
          } 
        }
      }

      // Send data every second
      if (frameCount % 120 === 0) {
        this.sendAngles(fingerAngles);
        
        // Schedule Release
        setTimeout(() => {
          console.log('reached')
          this.sendAngles(fingerDefaultAngles);
        }, 2000 / 2);
      }
      
      this.lastUpdateTime = currentTime;
      
    }

    // Send Current Angles to Arduino via Serial
    sendAngles(angles) {
      if (serialActive) {
        let message = angles.join(",") + "\n";
        writeSerial(message);
        console.log("Sent to Arduino:", message.trim());
      }
    }
}

/*
function readSerial(data) {
  // Handle incoming data from Arduino
  // For this project, we primarily send data to Arduino
}
  */

 


4. Project Highlights

Network Communication

  • Real-Time Synchronization: Successfully implemented real-time data exchange between clients using Node.js and Socket.io.
  • Robust Server Setup: Developed a stable local server that handles multiple connections.

Physical Installation

  • Robot Hand Fabrication: Crafted a functional robot hand using 3D printing, hot-glued hinges, and fishing line tendons.
  • Servo Integration: Connected and controlled multiple servos via Arduino to simulate human key presses.

AI Player Algorithm

  • Dynamic Threat Handling: Developed an AI that intelligently detects and responds to multiple simultaneous threats, prioritizing evasion and strategic attacks based on predefined behavior modes.

5. Future Improvements

Strengthening the Robot Hand

  • Enhanced Strength: Upgrade materials and servo to increase the robot hand’s strength and responsiveness, realizing actual control over the physical buttons.

Network Communication Structure

  • Peer-to-Peer Networking: Transition from a broadcast-based communication model to a peer-to-peer (P2P) architecture, facilitating support for more than two players and reducing server dependencies.

Week 13 – User Testing

Gladly, I had finished most of the project before the user testing – although it turned out that as soon as the uncertainty a user brought into the system arose, bugs followed.

Since my final project is built upon my midterm, I invited people outside the class to conduct user testing to avoid any pre-perceived knowledge about the system. The two samples also came from different backgrounds that varied in terms of their familiarity with video games, contributing to the comprehensiveness of my user testing.

On balance, I would say the tests were successful in terms of conveying the gist mechanism of the project – from the PVE experience to the presence of the robotic hand. Both participants have (almost) no issue in carrying out the experience (although the fact that people always stumble at how to config the game when pressing the keyboard persists, and a bug related to flow control popped up).

Other suggestions I collected include (but are not limited to):

  1. The purpose of the robot hands is a bit vague at the beginning. In the final presentation, with the aid of a larger screen and closer installation of the robot hands, this should be more obvious.
  2. The meaning of ‘X’, ‘tactic engine.’ More prominent notification is applied.
  3. The static AI difficulty may not be exciting enough (from a video game player). The gradual increase of AI difficulty is now applied.
  4. The pace of the interaction is a bit quick – the same input to control the game stages may cause accidental/undesired input. This is solved by adding buffer time between stage changes.
  5. The game objective could be a bit unclear – given some will skip the instruction or skim through. Another global notification in the game is added.
  6. The enemy may be too small on the screen. It is now moved closer to the player on the z axis.

Week 12 – Progress On The Final Project

Concept

Set off with the idea of transforming my midterm project into an online PVP game from an offline PVE game (which has been achieved by this time as my major progress this week—we’ll come back to this later), the concept of my final now includes another dimension of the human-robot competition—utilizing the PVP mechanism I have now and building a physical robot to operate one of the players in the game.

Of course, technically speaking, it would be mission impossible to develop a robot that could actually play and compete with humans within a week or so. Therefore, the physical installation of the robot would be a puppet that ostensibly controls one of the players, and the actual gaming logic would still be programmed in p5.

Illustration

The bottle is a placeholder for the hand robot to be realized.

Potential Hand Robot

Based on an open-source printable robot hand 3D model, servos could be attached to the fingers’ ends to bend each of them when needed—if parameters are tuned.

p5-Arduino Communication

As the gaming logic would be done within p5, what needs to be sent to Arduino are the translated movements of the robot hand—specifically the servos, such as for how long a specific finger needs to press a key that feeds back to trigger the movement of the player in the game.

Current Progress (Code Available)

By far, based on the midterm, the following functions have been achieved

  1. Control mode selection – determines if the game is going to be controlled by head movement (for humans) or direction keys (for the robot)
  2. Synchronized online battle – realizes the real-time communication between two (or more) sketches on two (or more) computers by setting up an HTTP WAN server using Node.js and socket.io. Necessary data from one sketch is shared with the other in real time (with a buffer time of 5 ms). Core code of this part of the sketch:
    // sketch.js
    
    // The global broadcast dicitonaires to communicate with the other player
    let globalBroadcastGet = {
      x: 0,
      y: 0,
      rotationX: 0,
      rotationY: 0,
      rotationZ: 0,
      health: 100,
      energy: 100,
      tacticEngineOn: false,
      laserCooldown: 100, // milliseconds
      lastLaserTime: 0,
      colliderRadius: 30, // Example radius for collision detection
      destroyCountdown: 90,
      toDestroy: false,
      laserFired: 0,
      damageState: false,
      readyToPlay: false,
      bkgSelected: 'background1'
    }
    let globalBroadcastSend = {
      x: 0,
      y: 0,
      rotationX: 0,
      rotationY: 0,
      rotationZ: 0,
      health: 100,
      energy: 100,
      tacticEngineOn: false,
      laserCooldown: 100, // milliseconds
      lastLaserTime: 0,
      colliderRadius: 30, // Example radius for collision detection
      destroyCountdown: 90,
      toDestroy: false,
      laserFired: 0,
      damageState: false,
      readyToPlay: false,
      bkgSelected: 'background1'
    }
    
    // Interval for sending broadcasts (in milliseconds)
    const BROADCAST_INTERVAL = 5; // 5000 ms = 5 seconds
    
    // Setup function initializes game components after assets are loaded
    function setup() {
      ...
    
      // Initialize Socket.io
      socket = io();
    
      // Handle connection events
      socket.on('connect', () => {
        console.log('Connected to server');
      });
    
      socket.on('disconnect', () => {
        console.log('Disconnected from server');
      });
    
      // Reconnection attempts
      socket.on('reconnect_attempt', () => {
        console.log('Attempting to reconnect');
      });
    
      socket.on('reconnect', (attemptNumber) => {
        console.log('Reconnected after', attemptNumber, 'attempts');
      });
    
      socket.on('reconnect_error', (error) => {
        console.error('Reconnection error:', error);
      });
    
      // Listen for broadcast messages from other clients
      socket.on('broadcast', (data) => {
        // console.log('Received broadcast');s
        try {
          // Ensure the received data is a valid object
          if (typeof data === 'object' && data !== null) {
            globalBroadcastGet = data; // Replace the entire BroadcastGet dictionary
          } else {
            console.warn('Received data is not a valid dictionary:', data);
          }
        } catch (error) {
          console.error('Error processing received data:', error);
        }
      });
    
      // Set up the periodic sending
      setInterval(sendBroadcast, BROADCAST_INTERVAL);
    
      ...
    }
    
    // Function to send the BroadcastSend dictionary
    function sendBroadcast() {
    
      // Update BroadcastSend dictionary
      let BroadcastSend = globalBroadcastSend;
    
      // Send the entire dictionary to the server to broadcast to other clients
      socket.emit('broadcast', BroadcastSend);
      // console.log('Sent broadcast:', BroadcastSend);
    }
    // server.js
    
    /* Install socket.io and config server
    npm init -y
    npm install express socket.io
    node server.js
    */
    
    /* Install mkcert and generate CERT for https
    choco install mkcert
    mkcert -install
    mkcert <your_local_IP> localhost 127.0.0.1 ::1
    mv 192.168.1.10+2.pem server.pem
    mv 192.168.1.10+2-key.pem server-key.pem
    mkdir certs
    mv server.pem certs/
    mv server-key.pem certs/
    */
    
    const express = require('express');
    const https = require('https');
    const socketIo = require('socket.io');
    const path = require('path');
    const fs = require('fs'); // Required for reading directory contents
    
    const app = express();
    
    // Path to SSL certificates
    const sslOptions = {
      key: fs.readFileSync(path.join(__dirname, 'certs', 'server-key.pem')),
      cert: fs.readFileSync(path.join(__dirname, 'certs', 'server.pem')),
    };
    
    // Create HTTPS server
    const httpsServer = https.createServer(sslOptions, app);
    // Initialize Socket.io
    const io = socketIo(httpsServer);
    
    // Serve static files from the 'public' directory
    app.use(express.static('public'));
    
    // Handle client connections
    io.on('connection', (socket) => {
      console.log(`New client connected: ${socket.id}`);
    
      // Listen for broadcast messages from clients
      socket.on('broadcast', (data) => {
        // console.log(`Broadcast from ${socket.id}:`, data);
        // Emit the data to all other connected clients
        socket.broadcast.emit('broadcast', data);
      });
    
      // Handle client disconnections
      socket.on('disconnect', () => {
        console.log(`Client disconnected: ${socket.id}`);
      });
    });
    
    // Start HTTPS server
    const PORT = 3000; // Use desired port
    httpsServer.listen(PORT, () => {
      console.log(`HTTPS Server listening on port ${PORT}`);
    });

    Other changes in classes to replace local parameter passing with externally synchronized data, such as:

    // EnemyShip.js
    
    class EnemyShip {
      ...
    
      update() {
        this.toDestroy = globalBroadcastGet.toDestroy;
    
        this.health = globalBroadcastGet.health;
        this.energy = globalBroadcastGet.energy;
    
        if (this.toDestroy === false) {
          this.x = globalBroadcastGet.x;
          this.y = globalBroadcastGet.y;
          
          // Update rotation based on head movement
          this.rotationX = globalBroadcastGet.rotationX;
          this.rotationY = globalBroadcastGet.rotationY;
          this.rotationZ = globalBroadcastGet.rotationZ;
          
          if (globalBroadcastGet.tacticEngineOn === true && this.tacticEngineUsed === false) {
            this.tacticEngine()
          }
    
          // Tactic engine reset
          if (this.tacticEngineOn === true) {
            let currentTime = millis();
            if (this.model === assets.models.playerShip1) {
              this.health = 100;
            } else {
              this.energy = 100;
            }
            if (currentTime - this.tacticEngineStart > 15000) {
              this.tacticEngineOn = false;
              if (this.model === assets.models.playerShip1) {
                this.health = 100;
              } else {
                this.energy = 100;
              }
            }
          }
        }
      }
    
      ...
    
    }
  3. Peripheral improvement to smoothen PVP experience, including recording the winner/loser, synchronizing game configuration, etc.

Week 11 – Final Project Proposal

What can physical computing provide/add on to the p5 experience? At least, this is the question for me when trying to devise my final project. It’s true that physicality is supposed to be more engaging, more intuitive, etc. While it is also true that the rich visual response p5 allows can easily overshadow the physical portion of a project. That being said, my mission would now become how to assigned the roles of p5 and physical computing.

Some tug-of-war staged in my mind, and it eventually came to me to stick to p5 as the centerpiece while harnessing physical computing as a means to improve the interactive experience. In a simple word, I’m thinking of leveraging my midterm project with the aid of the physical input—constructing a physical environment, simulating a pilot pod, and transforming the PVE experience into PVP.

From a technical perspective, there may be two approaches to achieving the vision that differ from each other by the number of computers involved in the project. On the one hand, if one computer plus an external screen would be supported by p5, then the physical installation should become easier; while if p5 does not allow it, then two computers may need to be set, and the burden on the physical part to communicate info would drastically increase. At this particular moment I believe it’s time to experiment a bit more with p5 and Arduino at large to find out the right pathway.

Week 11 – In-Class Practice

Following codes are built upon the bidi serial example

Practice 1: Shifting Ellipse

let rVal = 0;

function setup() {
  createCanvas(640, 480);
  textSize(18);
}

function draw() {
  background(10);

  fill(map(rVal, 0, 1023, 100, 255));

  if (!serialActive) {
    text("Press Space Bar to select Serial Port", 20, 30);
  } else {
    text("Connected", 20, 30);
    
    // Print the current values
    text('rVal = ' + str(rVal), 20, 50);

    ellipse(map(rVal, 150, 600, 0, width), height /2, map(rVal, 150, 600, 50, 200));
  }
}

function keyPressed() {
  if (key == " ") {
    // important to have in order to start the serial connection!!
    setUpSerial();
  }
}

// This function will be called by the web-serial library
// with each new *line* of data. The serial library reads
// the data until the newline and then gives it to us through
// this callback function
function readSerial(data) {
  ////////////////////////////////////
  //READ FROM ARDUINO HERE
  ////////////////////////////////////

  if (data != null) {
    // make sure there is actually a message
    // split the message
    let fromArduino = split(trim(data), ",");
    // if the right length, then proceed
    if (fromArduino.length == 1) {
      // only store values here
      // do everything with those values in the main draw loop
      
      // We take the string we get from Arduino and explicitly
      // convert it to a number by using int()
      // e.g. "103" becomes 103
      rVal = int(fromArduino[0]);
    }

    //////////////////////////////////
    //SEND TO ARDUINO HERE (handshake)
    //////////////////////////////////
    let sendToArduino = 0 + '\n';
    writeSerial(sendToArduino);
  }
}

/* Arduino Code

void setup() {
  // Start serial communication so we can send data
  // over the USB connection to our p5js sketch
  Serial.begin(9600);

  // We'll use the builtin LED as a status output.
  // We can't use the serial monitor since the serial connection is
  // used to communicate to p5js and only one application on the computer
  // can use a serial port at once.
  pinMode(LED_BUILTIN, OUTPUT);

  // start the handshake
  while (Serial.available() <= 0) {
    digitalWrite(LED_BUILTIN, HIGH); // on/blink while waiting for serial data
    Serial.println("0,0"); // send a starting message
    delay(300);            // wait 1/3 second
    digitalWrite(LED_BUILTIN, LOW);
    delay(50);
  }
}

void loop() {
  // wait for data from p5 before doing something
  while (Serial.available()) {
    digitalWrite(LED_BUILTIN, HIGH); // led on while receiving data

    if (Serial.read() == '\n') {
      int sensor = analogRead(A0);
      delay(5);
      Serial.println(sensor);
    }
  }
  digitalWrite(LED_BUILTIN, LOW);
}

*/

Practice 2: Brightness Control

let brightness = 0;
let rVal = 0;

function setup() {
  createCanvas(640, 480);
  textSize(18);
  background(0);
}

function draw() {
  background(map(brightness, 0, 255, 0, 255), 0, 0);

  fill(255);
  if (!serialActive) {
    text("Press 'Space' to connect to Serial Port", 20, 30);
  } else {
    text("Connected to Arduino", 20, 30);
    text('Brightness: ' + brightness, 20, 60);
    text('rVal: ' + rVal, 20, 90);

    // Map mouseX to brightness (0-255)
    brightness = map(mouseX, 0, width, 0, 255);
    brightness = int(constrain(brightness, 0, 255));

    // Display instructions
    text("Move mouse horizontally to change LED brightness", 20, height - 20);
  }
}

function keyPressed() {
  if (key === ' ') {
    setUpSerial();
  }
}

// This function will be called by the web-serial library
// with each new *line* of data. The serial library reads
// the data until the newline and then gives it to us through
// this callback function
function readSerial(data) {
  ////////////////////////////////////
  //READ FROM ARDUINO HERE
  ////////////////////////////////////
  if (data != null) {
    // make sure there is actually a message
    // split the message
    let fromArduino = split(trim(data), ",");
    // if the right length, then proceed
    if (fromArduino.length == 1) {
      // only store values here
      rVal = fromArduino[0];
    }

    //////////////////////////////////
    //SEND TO ARDUINO HERE (handshake)
    //////////////////////////////////
    let sendToArduino = brightness + '\n';
    writeSerial(sendToArduino);
  }
}

/*

// LED Brightness Control via Serial
const int ledPin = 9; 

void setup() {
  // Initialize serial communication at 9600 baud
  Serial.begin(9600);
  
  // Set LED pin as output
  pinMode(ledPin, OUTPUT);
  pinMode(LED_BUILTIN, OUTPUT);
  pinMode(2, OUTPUT);
  
  // Blink them so we can check the wiring
  analogWrite(ledPin, 255);
  delay(200);
  analogWrite(ledPin, 0);

  // start the handshake
  while (Serial.available() <= 0) {
    digitalWrite(LED_BUILTIN, HIGH); // on/blink while waiting for serial data
    Serial.println("0,0"); // send a starting message
    delay(300);            // wait 1/3 second
    digitalWrite(LED_BUILTIN, LOW);
    delay(50);
  }
}

void loop() {
  // Check if data is available on the serial port
  while (Serial.available()) {
    digitalWrite(LED_BUILTIN, HIGH);

    // Read the incoming byte:
    int brightness = Serial.parseInt();
    // Constrain the brightness to be between 0 and 255
    brightness = constrain(brightness, 0, 255);
    if (Serial.read() == '\n') {
      // Set the brightness of the LED
      analogWrite(ledPin, brightness);
      delay(5);
      // Send back the brightness value for confirmation
      Serial.println(brightness);
    } 
  }
  digitalWrite(LED_BUILTIN, LOW);
}

*/

Practice 3: Windy Balls Bouncing

let velocity;
let gravity;
let position;
let acceleration;
let wind;
let drag = 0.99;
let mass = 50;

// for uno connection
let rVal = 0;
let LED = 0; 

function setup() {
  createCanvas(640, 360);
  noFill();
  position = createVector(width/2, 0);
  velocity = createVector(0,0);
  acceleration = createVector(0,0);
  gravity = createVector(0, 0.5*mass);
  wind = createVector(0,0);
}

function draw() {
  background(255);

  fill(10);
  
  if (!serialActive) {
    text("Press S to select Serial Port", 20, 30);
  } else {
    text("Connected", 20, 30);
  
    // Print the current values
    text('rVal = ' + str(rVal), 20, 50);
  }
  
  if (rVal > 0) {
    wind.x += map(constrain(rVal, 200, 600), 200, 600, -0.5, 0.5);
    applyForce(wind);
  }
  
  applyForce(gravity);
  velocity.add(acceleration);
  velocity.mult(drag);
  position.add(velocity);
  acceleration.mult(0);
  ellipse(position.x,position.y,mass,mass);
  if (position.y > height-mass/2) {
      LED = 1;
      velocity.y *= -0.9;  // A little dampening when hitting the bottom
      position.y = height-mass/2;
  } else {
    LED = 0;
  }
}

function applyForce(force){
  // Newton's 2nd law: F = M * A
  // or A = F / M
  let f = p5.Vector.div(force, mass);
  acceleration.add(f);
}

function keyPressed(){
  if (key == "s") {
    // important to have in order to start the serial connection!!
    setUpSerial();
  }

  if (key==' '){
    mass=random(15,80);
    position.y=-mass;
    position.x=width/2;
    acceleration = createVector(0,0);
    velocity.mult(0);
    wind = createVector(0,0);
  }
}

function readSerial(data) {
  ////////////////////////////////////
  //READ FROM ARDUINO HERE
  ////////////////////////////////////

  if (data != null) {
    // make sure there is actually a message
    
    // split the message
    let fromArduino = split(trim(data), ","); 
    
    // if the right length, then proceed
    if (fromArduino.length == 1) {
      // convert it to a number by using int()
      rVal = int(fromArduino[0]);
    }

    //////////////////////////////////
    //SEND TO ARDUINO HERE (handshake)
    //////////////////////////////////
    let sendToArduino = LED+'\n';
    writeSerial(sendToArduino);
  }
}

/*

int ledPin = 9;

void setup() {
  // Start serial communication so we can send data
  // over the USB connection to our p5js sketch
  Serial.begin(9600);

  // We'll use the builtin LED as a status output.
  // We can't use the serial monitor since the serial connection is
  // used to communicate to p5js and only one application on the computer
  // can use a serial port at once.
  pinMode(LED_BUILTIN, OUTPUT);
  pinMode(ledPin, OUTPUT);

  // Blink them so we can check the wiring
  analogWrite(ledPin, 255);
  delay(200);
  analogWrite(ledPin, 0);

  // start the handshake
  while (Serial.available() <= 0) {
    digitalWrite(LED_BUILTIN, HIGH); // on/blink while waiting for serial data
    Serial.println("0,0"); // send a starting message
    delay(300);            // wait 1/3 second
    digitalWrite(LED_BUILTIN, LOW);
    delay(50);
  }
}

void loop() {
  // wait for data from p5 before doing something
  while (Serial.available()) {
    digitalWrite(LED_BUILTIN, HIGH); // led on while receiving data

    int ledState = Serial.parseInt();
    if (Serial.read() == '\n') {
      digitalWrite(ledPin, ledState);
      int sensor = analogRead(A0);
      delay(5);
      Serial.println(sensor);
    }
  }
  digitalWrite(LED_BUILTIN, LOW);
}


*/

Week 11 – Reading Response

Okay, let’s see what we got from this – passage from last century. Again, for me, it’s for sure an extension of our exploration and discussion on ‘balance,’ – but in an extended context.

For sure, whether the examples of eyeglasses, leg splints, iPods, and so on are within the context of disability or not, what intrigued me is how those supposedly pioneering principles suggested at that time are nowadays spread across a much broader scope: What’s particularly striking is how the central tensions – between fashion and discretion, simplicity and universality – have indeed become increasingly relevant beyond disability design. The Eames leg splint case study perfectly exemplifies this: what began as a disability-focused design solution ultimately influenced mainstream furniture design, challenging the traditional ‘trickle-down’ assumption that innovations flow from mainstream to specialized markets. The argument for moving beyond pure functionality or pure aesthetics to find ‘simplicity in concept and simplicity of form’ feels particularly prescient. Sometimes, it’s so easy to dwell in a term that seemingly fits all – yet we all know there is no such case. Therefore, whether we are dealing with balance or any of the included or parallel concepts, maybe the only fit-for-all is to find out what exactly the idea, ideology, or principle we claim to realize, follow, and develop stands for in the very specific case of our own designing.

Week 10 – Handisyn – An Instrument (Group project by Zavier & Xiaotian)

INTRO

What makes something an instrument? I’d like to borrow a concept from Rehding 2016, Three Music-Theory Lessons: ‘epistemic things,’ things that we use to practice knowledge while themselves are the knowledge in terms of how knowledge is generated. Aside from the extent to which a person can easily make acoustically pleasant sound waves out of an installation, what constitutes an instrument is that itself is an epistemic thing—having connections with and making it feasible to practice our common musical knowledge on while embodying a distinct system that generates/contributes to musical knowledge/practice.

That being said, how we divided our work is basically mapping the two sides (and the two stage of the development) to the two of us—the music and the interface, the backend and the frontend, the soft and the hard, etc. On my side, I had the Arduino and the speaker to start up a very basic synthesizer consisting of two audio oscillators and two control oscillators.

Process

In a nutshell, what I tried to realize is a simple cosine wave oscillator frequency modulated according to the four input parameters that control root frequency: intensity (the extent to which the root signal is modulated to the target frequency), vibrato speed (the rate at which the modulation is automatically carried on), and modulation ratio (the multiple to determine the target frequency from the root).

#include <Mozzi.h>
#include <Oscil.h> // oscillator
#include <tables/cos2048_int8.h> // table for Oscils to play
#include <Smooth.h>
#include <AutoMap.h>

// desired carrier frequency max and min, for AutoMap
const int MIN_CARRIER_FREQ = 22;
const int MAX_CARRIER_FREQ = 440;

// desired intensity max and min, for AutoMap, inverted for reverse dynamics
const int MIN_INTENSITY = 10;
const int MAX_INTENSITY = 1000;

// desired modulation ratio max and min
const int MIN_MODRATIO = 5;
const int MAX_MODRATIO = 2;

// desired mod speed max and min, for AutoMap, note they're inverted for reverse dynamics
const int MIN_MOD_SPEED = 10000;
const int MAX_MOD_SPEED = 1;

AutoMap kMapCarrierFreq(400,700,MIN_CARRIER_FREQ,MAX_CARRIER_FREQ);
AutoMap kMapIntensity(400,700,MIN_INTENSITY,MAX_INTENSITY);
AutoMap kMapModRatio(400,700,MIN_MODRATIO,MAX_MODRATIO);
AutoMap kMapModSpeed(400,700,MIN_MOD_SPEED,MAX_MOD_SPEED);

const int FREQ_PIN = 0;
const int MOD_PIN = 1;
const int RATIO_PIN = 3;
const int SPEED_PIN = 2;

Oscil<COS2048_NUM_CELLS, MOZZI_AUDIO_RATE> aCarrier(COS2048_DATA);
Oscil<COS2048_NUM_CELLS, MOZZI_CONTROL_RATE> kIntensityMod(COS2048_DATA);
Oscil<COS2048_NUM_CELLS, MOZZI_AUDIO_RATE> aModulator(COS2048_DATA);

int mod_ratio; // harmonics
long fm_intensity; // carries control info from updateControl() to updateAudio()

// smoothing for intensity to remove clicks on transitions
float smoothness = 0.95f;
Smooth <long> aSmoothIntensity(smoothness);

void setup(){
  Serial.begin(115200); // set up the Serial output for debugging
  startMozzi();
}

void updateControl(){
  // read the freq
  int freq_value = mozziAnalogRead<10>(FREQ_PIN); // value is 0-1023

  // map the input to carrier frequency
  int carrier_freq = kMapCarrierFreq(freq_value);

  // read the ratio
  int ratio_value = mozziAnalogRead<10>(RATIO_PIN);

  // map the input to ratio
  mod_ratio = kMapModRatio(ratio_value);

  //calculate the modulation frequency to stay in ratio
  int mod_freq = carrier_freq * mod_ratio;
 
  // set the FM oscillator frequencies to the calculated values
  aCarrier.setFreq(carrier_freq);
  aModulator.setFreq(mod_freq);

  // calculate the fm_intensity
  int mod_level= mozziAnalogRead<10>(MOD_PIN); // value is 0-1023
  fm_intensity = ((long)mod_level * (kIntensityMod.next()+128))>>8;

  // use a float here for low frequencies
  int speed_value = mozziAnalogRead<10>(SPEED_PIN);
  float mod_speed = (float)kMapModSpeed(speed_value)/1000;
  kIntensityMod.setFreq(mod_speed);
}

AudioOutput updateAudio(){
  long modulation = aSmoothIntensity.next(fm_intensity) * aModulator.next();
  return MonoOutput::from8Bit(aCarrier.phMod(modulation)); // phMod does the FM
}

void loop(){
  audioHook();
}

THE GLOVE

Zavier here, now it’s my turn! So after testing the circuit and code (huge thanks to Xiaotian for the awesome sound control!), I began working on attaching it to the glove, which was… painful. I first just measured out and positioned things, then I started attaching them with my small transparent tape (which isn’t the ideal way (sowing it would be better), and besides my tape wasn’t strong at all). After asking a friend for help, I got the sensors attached. Ok, the easy part done. Now the troubles begin. You see, I obviously had to connect the flex sensors to the Arduino. I thought I could I just use the female-male wires, but nope! The pins on the flex sensors were too small, so it was far too lose :(. I tried thinking of a few other options, but in the end, I had to do what I was trying to avoid, soldering. To be honest, I didn’t even mind it that much before (past me talking), and thought it would take a few fun minutes, but boy oh boy. I don’t know what it is about these solders (maybe they’re dirty?), but only a very tiny bit of the tip is actually going to melt the solder, so a lot of time was spent just moving and rotating the tip. Also, I shouldn’t have attached the flex sensors already. It was a huge pain to get them soldered. Now admittedly, (probably a large) part of that is because I’ve hardly soldered before, but also (in addition to the tip issue), I was making these 3 point connections (such as connecting the ground of one the flex sensors, to the ground of the adjacent ones), so whenever I tried soldering, it would just release the other 2!

Anyways, after some work, I finally got the flex sensors wired up, and it was finally working. Great! Ok, we’re done… we’re done?…. right?. Haha, nope. I thought it would be a good idea to add neopixels (addressable LED strips) to the gloves too. After testing a strip, I didn’t repeat my mistake, and this time I soldered together the strips first, before attaching them. This went a lot smoother (also thanks to just having some experience doing it for the flex sensors), but it still took sooo long. Unfortunately, since I soldered it first, the connections weren’t the right length 🙂. Luckily, I had expected them not to be precisely correct (and besides the distance between the strips would change a bit as the hand was flexed and relaxed), and so kept it a bit longer, so that the length could be adjusted as needed. This unintentionally also ended up creating a nice pattern 😅.

While it was a lot of work, it definitely made things a LOT cooler, and also provided a way to give some info to the user visually.

 

Final Product

Demo (note: the sound is very distorted in the recording)

Code:

// Configuring Mozzi's options
#include <MozziConfigValues.h>
#define MOZZI_ANALOG_READ_RESOLUTION 10 // Not strictly necessary, as Mozzi will automatically use the default resolution of the hardware (eg. 10 for the Arduino Uno), but they recommend setting it (either here globally, or on each call)

#include <Mozzi.h>
#include <Oscil.h> // oscillator
#include <tables/cos2048_int8.h> // table for Oscils to play
#include <Smooth.h>
#include <AutoMap.h>

#include <FastLED.h>


// Flex sensor stuff

// Define flex sensor pins (these have to be analog)
const int FREQ_SENSOR_PIN = A0;
const int MOD_SENSOR_PIN = A1;
const int SPEED_SENSOR_PIN = A2;
const int RATIO_SENSOR_PIN = A3;

// Smoothening for each pin (was previously using rolling averages)
Smooth<unsigned int> smoothFreq(0.8f);
Smooth<unsigned int> smoothMod(0.5f);
Smooth<unsigned int> smoothSpeed(0.75f);
Smooth<unsigned int> smoothRatio(0.5f);

// Input ranges for flex sensors (will be calibrated)
unsigned int freqInputMin = 1000; // Just FYI, the flex sensors in our setup roughly output in the range of ~ 200 - 650
unsigned int freqInputMax = 0;
unsigned int modInputMin = 1000;
unsigned int modInputMax = 0;
unsigned int speedInputMin = 1000;
unsigned int speedInputMax = 0;
unsigned int ratioInputMin = 1000;
unsigned int ratioInputMax = 0;


// Neopixel (addressable LED strip) stuff

// Define neopixel pins
const int FREQ_NEOPIXEL_PIN = 2;
const int MOD_NEOPIXEL_PIN = 3;
const int SPEED_NEOPIXEL_PIN = 4;
const int RATIO_NEOPIXEL_PIN = 5;

// Number of LEDs in each strip
const int NEOPIXEL_NUM_LEDS = 11;

// Define the array of leds
CRGB freqLEDs[NEOPIXEL_NUM_LEDS];
CRGB modLEDs[NEOPIXEL_NUM_LEDS];
CRGB speedLEDs[NEOPIXEL_NUM_LEDS];
CRGB ratioLEDs[NEOPIXEL_NUM_LEDS];


// Sound stuff

// desired carrier frequency max and min, for AutoMap
const int MIN_CARRIER_FREQ = 22;
const int MAX_CARRIER_FREQ = 440;

// desired intensity max and min, for AutoMap, inverted for reverse dynamics
const int MIN_INTENSITY = 10;
const int MAX_INTENSITY = 1000;

// desired modulation ratio max and min
const int MIN_MOD_RATIO = 5;
const int MAX_MOD_RATIO = 2;

// desired mod speed max and min, for AutoMap, note they're inverted for reverse dynamics
const int MIN_MOD_SPEED = 10000;
const int MAX_MOD_SPEED = 1;

Oscil<COS2048_NUM_CELLS, MOZZI_AUDIO_RATE> aCarrier(COS2048_DATA);
Oscil<COS2048_NUM_CELLS, MOZZI_CONTROL_RATE> kIntensityMod(COS2048_DATA);
Oscil<COS2048_NUM_CELLS, MOZZI_AUDIO_RATE> aModulator(COS2048_DATA);

int mod_ratio; // harmonics
long fm_intensity; // carries control info from updateControl() to updateAudio()

// smoothing for intensity to remove clicks on transitions
float smoothness = 0.95f;
Smooth<long> aSmoothIntensity(smoothness);


void setup(){
  Serial.begin(9600); // set up the Serial output for debugging

  // Set the flex sensor pins
  pinMode( FREQ_SENSOR_PIN, INPUT_PULLUP);
  pinMode(  MOD_SENSOR_PIN, INPUT_PULLUP);
  pinMode(SPEED_SENSOR_PIN, INPUT_PULLUP);
  pinMode(RATIO_SENSOR_PIN, INPUT_PULLUP);

  // Setup the neopixels
	FastLED.addLeds<NEOPIXEL, FREQ_NEOPIXEL_PIN>(freqLEDs, NEOPIXEL_NUM_LEDS);
	FastLED.addLeds<NEOPIXEL, MOD_NEOPIXEL_PIN>(modLEDs, NEOPIXEL_NUM_LEDS);
	FastLED.addLeds<NEOPIXEL, SPEED_NEOPIXEL_PIN>(speedLEDs, NEOPIXEL_NUM_LEDS);
	FastLED.addLeds<NEOPIXEL, RATIO_NEOPIXEL_PIN>(ratioLEDs, NEOPIXEL_NUM_LEDS);
	FastLED.setBrightness(32); // 0 - 255

  // Feed/prime/initialise the smoothing function to get a stable output from the first read (to ensure the calibration isn't messed up). A value of 1630 was chosen by trial and error (divide and conquer), and seems to work best (at least for our setup)
  smoothFreq.next(1630);
  smoothMod.next(1630);
  smoothSpeed.next(1630);
  smoothRatio.next(1630);

  startMozzi();
}


// Basically our actual traditional loop in Mozzi (but still needs to kept reasonably lean and fast)
void updateControl(){

  // Read the smoothened freq
  int freqValue = smoothFreq.next(mozziAnalogRead(FREQ_SENSOR_PIN - 14)); // value is 0-1023, -14 since mozzi just takes a number (eg. 0 instead of A0), and the analog ones are 14 onwards

  // Calibrate the mapping if needed
  if (freqValue < freqInputMin) freqInputMin = freqValue;
  if (freqValue > freqInputMax) freqInputMax = freqValue;

  // Map the input to the carrier frequency
  int carrier_freq = map(freqValue, freqInputMin, freqInputMax, MIN_CARRIER_FREQ, MAX_CARRIER_FREQ);


  // Read the smoothened ratio
  int ratioValue = smoothRatio.next(mozziAnalogRead(RATIO_SENSOR_PIN - 14));

  // Calibrate the mapping if needed
  if (ratioValue < ratioInputMin) ratioInputMin = ratioValue;
  if (ratioValue > ratioInputMax) ratioInputMax = ratioValue;

  // Map the input to the ratio
  mod_ratio = map(ratioValue, ratioInputMin, ratioInputMax, MIN_MOD_RATIO, MAX_MOD_RATIO);


  // calculate the modulation frequency to stay in ratio
  int mod_freq = carrier_freq * mod_ratio;
 
  // set the FM oscillator frequencies to the calculated values
  aCarrier.setFreq(carrier_freq);
  aModulator.setFreq(mod_freq);


  // Read the smoothened mod
  int modValue = smoothMod.next(mozziAnalogRead(MOD_SENSOR_PIN - 14));

  // Calibrate the mapping if needed
  if (modValue < modInputMin) modInputMin = modValue;
  if (modValue > modInputMax) modInputMax = modValue;

  // Calculate the fm_intensity
  fm_intensity = ((long)modValue * (kIntensityMod.next()+128))>>8;


  // Read the smoothened speed
  int speedValue = smoothSpeed.next(mozziAnalogRead(SPEED_SENSOR_PIN - 14));

  // Calibrate the mapping if needed
  if (speedValue < speedInputMin) speedInputMin = speedValue;
  if (speedValue > speedInputMax) speedInputMax = speedValue;

  // use a float here for low frequencies
  float mod_speed = (float)map(speedValue, speedInputMin, speedInputMax, MIN_MOD_SPEED, MAX_MOD_SPEED) / 1000;
  kIntensityMod.setFreq(mod_speed);


  // Set the leds

  FastLED.clear(); // Resets them

  // The frequency controls how many of the LEDs are light up (in a rainbow colour)
  int freqLEDAmount = map(freqValue, freqInputMin, freqInputMax, 0, NEOPIXEL_NUM_LEDS);
  fill_rainbow(&freqLEDs[NEOPIXEL_NUM_LEDS - freqLEDAmount], freqLEDAmount, CRGB::White, 25); // &...LEDs[i] to start lighting from there, allowing us to light them in reverse

  // For the mod, show a meter (blue - deep pink) showing the mix level of the 2 sounds
  int modLEDAmount = map(modValue, modInputMin, modInputMax, 0, NEOPIXEL_NUM_LEDS);
  fill_solid(modLEDs, NEOPIXEL_NUM_LEDS, CRGB::Blue);
  fill_solid(&modLEDs[NEOPIXEL_NUM_LEDS - modLEDAmount], modLEDAmount, CRGB::DeepPink);

  // The speed controls the blinking rate of its LEDs (between 1/2 to 3 seconds per blink cycle)
  int speedLEDBlinkRate = map(speedValue, speedInputMin, speedInputMax, 500, 3000);
  if (millis() % speedLEDBlinkRate < speedLEDBlinkRate/2)
	fill_rainbow(speedLEDs, NEOPIXEL_NUM_LEDS, CRGB::White, 25);

  // The ratio controls the hue of its LEDs
  int ratioLEDHue = map(ratioValue, ratioInputMin, ratioInputMax, 0, 360);
  fill_solid(ratioLEDs, NEOPIXEL_NUM_LEDS, CHSV(ratioLEDHue, 100, 50));
  // We could also blend between 2 colours based on the ratio, pick the one you prefer
  // fract8 ratioLEDFraction = map(ratioValue, ratioInputMin, ratioInputMax, 0, 255);
  // fill_solid(ratioLEDs, NEOPIXEL_NUM_LEDS, blend(CRGB::Blue, CRGB::DeepPink, ratioLEDFraction));

  FastLED.show(); // Shows them
}


// Mozzi's function for getting the sound. Must be as light and quick as possible to ensure the sound buffer is adequently filled
AudioOutput updateAudio() {
  long modulation = aSmoothIntensity.next(fm_intensity) * aModulator.next();
  return MonoOutput::from8Bit(aCarrier.phMod(modulation)); // phMod does the FM
}


// Since we're using Mozzi, we just call its hook
void loop() {
  audioHook();
}

 

Week 10 – Reading Response

Believe it or not, I nearly believed that Magic Ink link would be “super-brief.”

Well, in terms of disillusioning, I guess the readings did a great job. On the other hand, I wasn’t really interested (at least at the beginning) in the topic – yes, it’s obvious from the first paragraph what the reading is up for. And my mere response to that would be, ‘Okay, maybe that’s not a promising vision, but I still want us to achieve it someday.’ However, beyond the ‘rant’ itself, what intrigued me was the idea of ‘the conventional means of interfacing the brain to the world (i.e., the body).’ Essentially, from my perspective, that is my first impulse to get in touch with IM: how are we going to interact (input and output info) as human beings in the future?

I always told people that I don’t like to read—but I’m forced to do so just because it holds (arguably) the most dense info on this planet. That’s in terms of ‘the media’—whatever that delivers information. At least for now, according to the scope of info that we can quantify, text still has its edge. (Honestly, it’s really sad news for me to acknowledge at the beginning – like a couple of years ago – that probably music that is based on audio (2 parameters) and paints based on images (arguably three parameters?) as the art forms I love has their inherent limit to express—even if we haven’t (or maybe already) reached).

On the other hand, what about humans? I mean, what about our bodies? I would say that people who strive to devise new media and convey and people who strive to plunge into our cognitive system are two teams that approach the same theme from two angles. I cannot answer if ‘bypassing’ the body is a good thing or not. But, for now, I would say the body is still a component of what we call ‘human.’

Week 9 – Reading Response

Okay, we should have read these earlier, I guess. Rather than discovering now things (well, there are a few in the physical computing list), I would say that reading these two passages was like reacquainting myself with some of my takeaways from the first half of our course.

‘Shut up’ is a rude phrase, especially when it’s called to the alleged ‘creator’ of a piece – I thought. Then it comes to the question again: “To what extent could we deem to be the creator, and to what extent should the intentionalistic pursuit of the creator be respected?” I have no clear answer to this. But if we hop out of the framework of intentionalism and think of ‘shut up’ as also a part of the ‘creation’ process—as if there is no static ‘art piece’ but only the rating process (borrowed from musicking if you’re curious), then the seeming sacrifice of intentionalism could serve something higher than itself. Philosophical enough, even merely regarding the pragmatic effect that ‘shut up’ could bring us, I believe the amusing moments that happened in our mid-term presentation could speak for themselves, signaling us creators to take another lens and subsequently improve the creation. That is, in a sense, also the creation process, isn’t it?

Anyway, as for the topics/types of physical computing projects listed, obviously, just within our class (or even only within my projects), there are already a lot of ‘overlapping’ ideas. If you ask me if in this particular case I would be bothered by the ‘ingenuity’, the answer – surprising or not – would be a clear NO. My interpretation of this problem would be similar to cover versions of music and the iteratively performed classical music – each and every version/performance contributes to the collective musicking process with its own uniqueness. Even if we are ‘just copying’, there is still nothing to be ashamed of: copying, or more professionally speaking, imitating, is still one of the best ways to learn.

Week 9 – Sunfbot

intro

Again, identifying the challenging aspect of the mission seemed the very first step I would take in approaching a new product. This time, while digitally/analog-ly reading/writing can be achieved easily, finding the right connection between the readings and the writings—in terms of both values and functions is what I’d like to focus on. As I found the light sensor we tried in class interesting enough, I came up with this product by combining the light sensors and a servo.

process

Although theoretically, maybe we should have a clear picture of the final product before getting our hands dirty (and I thought I had), sometimes it did take time to distinguish between ‘think of a function’ and ‘think of a product.’ In the first stage of my development, the ‘rotating to follow the light source’ function was already achieved, while the ‘product’ had only the electronics exposed blatantly:

Later on, the very simple concept of a sunflower following the sun finally popped up in my mind. With one piece of cardboard added to the product, although the concept may seem simpler than ‘Dual Light Sensor Servo Control,’ the gist is much more directly conveyed, I believe. On top of that, the additional layer of cardboard actually brought the bonus of normalizing the light sensors physically by shielding out the ambient light:

schematics & illustration

Both of the graphs are drawn and generated with TinkerCAD

Code

The servo rotation is based on the difference between the readings of the two light sensors:

/* 
‘Sunfbot’
Dual Light Sensor Servo Control
This program controls a servo motor based on the difference between two light sensors.
The servo moves to track the stronger light source.

Components:
- 2x LDR (Light Dependent Resistors)
- 2x 10kΩ resistors for LDRs
- 2x 3300Ω resistors for LEDs
- 1x Switch
- 1x 9g Servo
- 1x Blue LED
- 1x Green LED


reference:
https://www.arduino.cc/en/Tutorial/BuiltInExamples/AnalogReadSerial
https://www.arduino.cc/en/Tutorial/LibraryExamples/Sweep
*/

#include <Servo.h>

// Pin definitions
#define SWITCH_PIN 2      // Toggle switch input pin
#define SERVO_PIN 9       // Servo control pin
#define BLUE_LED_PIN 10   // Blue LED pin (standby indicator)
#define GREEN_LED_PIN 11  // Green LED pin (running indicator)
#define SENSOR1_PIN A2    // First light sensor
#define SENSOR2_PIN A3    // Second light sensor

// Constants
#define SENSOR_THRESHOLD 30  // Minimum difference between sensors to trigger movement
#define SERVO_DELAY 30       // Delay between servo movements (ms)
#define SENSOR2_OFFSET 30    // Calibration offset for sensor 2

Servo myservo;  // create servo object
int pos = 90;    // variable to store servo position

void setup() {
  // Initialize serial communication
  Serial.begin(9600);
  
  // Initialize servo
  myservo.attach(SERVO_PIN);
  
  // Configure pins
  pinMode(SWITCH_PIN, INPUT_PULLUP);
  pinMode(BLUE_LED_PIN, OUTPUT);
  pinMode(GREEN_LED_PIN, OUTPUT);
}

void loop() {
  // Read sensors
  int sensor1Value = analogRead(SENSOR1_PIN);
  int sensor2Value = analogRead(SENSOR2_PIN);
  int sensorOffset;

  // Read switch state
  int switchState = digitalRead(SWITCH_PIN);
  
  if (switchState == LOW) {  // System active (switch pressed)
    // Update status LEDs
    digitalWrite(GREEN_LED_PIN, HIGH);
    digitalWrite(BLUE_LED_PIN, LOW);

    // Apply sensor calibration
    sensor2Value -= SENSOR2_OFFSET;
    
    // Check if right sensor is significantly brighter
    if ((sensor2Value - sensor1Value) > SENSOR_THRESHOLD) {
      if (pos < 180) {
        pos++;
        myservo.write(pos);
      }
    } 
    // Check if left sensor is significantly brighter
    else if ((sensor1Value - sensor2Value) > SENSOR_THRESHOLD) {
      if (pos > 0) {
        pos--;
        myservo.write(pos);
      }
    }
    // If difference is below threshold, maintain position
    myservo.write(pos);
    delay(SERVO_DELAY);
    
  } else {  // System in standby (switch released)
    // Update status LEDs
    digitalWrite(GREEN_LED_PIN, LOW);
    digitalWrite(BLUE_LED_PIN, HIGH);
    
    // Return to center position
    myservo.write(90);
  }

  // Debug output
  String message = String(sensor1Value) + " " + 
                  String(sensor2Value) + " " + 
                  String(switchState) + " " + 
                  String(abs(sensor1Value - sensor2Value));
  Serial.println(message);
}

Hindsight

Cardboard Gang YES. While I thought I could use the laser cutter to prepare the parts of this product, it turned out that cardboard allowed the product to be finished on time. It’s still true that laser-cut acrylic may look nicer, but it surely should only be introduced when things are settled.