/* Lemmings - robot and GUI script. * * Copyright 2016 Harmen de Weerd * Copyright 2017 Johannes Keyser, James Cooke, George Kachergis * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ // Description of robot(s), and attached sensor(s) used by InstantiateRobot() RobotInfo = [ {body: null, // for MatterJS body, added by InstantiateRobot() color: [255, 255, 255], // color of the robot shape init: {x: 50, y: 50, angle: 0}, // initial position and orientation sensors: [ // define an array of sensors on the robot // define one sensor {sense: senseDistance, // function handle, determines type of sensor minVal: 0, // minimum detectable distance, in pixels maxVal: 50, // maximum detectable distance, in pixels attachAngle: Math.PI/4, // where the sensor is mounted on robot body lookAngle: 0, // direction the sensor is looking (relative to center-out) id: 'distR', // a unique, arbitrary ID of the sensor, for printing/debugging color: [150, 0, 0], // sensor color [in RGB], to distinguish them parent: null, // robot object the sensor is attached to, added by InstantiateRobot value: null // sensor value, i.e. distance in pixels; updated by sense() function }, // define another sensor {sense: senseDistance, minVal: 0, maxVal: 50, attachAngle: -Math.PI/4, lookAngle: 0, id: 'distL', color: [0, 150, 0], parent: null, value: null } ] } ]; // Simulation settings; please change anything that you think makes sense. simInfo = { maxSteps: 50000, // maximal number of simulation steps to run airDrag: 0.1, // "air" friction of enviroment; 0 is vacuum, 0.9 is molasses boxFric: 0.005, // friction between boxes during collisions boxMass: 0.01, // mass of boxes boxSize: 20, // size of the boxes, in pixels robotSize: 13, // approximate robot radius, in pixels (note the SVG gets scaled down) robotMass: 0.4, // robot mass (a.u) gravity: 0, // constant acceleration in Y-direction bayRobot: null, // currently selected robot baySensor: null, // currently selected sensor bayScale: 3, // scale within 2nd, inset canvas showing robot in it's "bay" doContinue: true, // whether to continue simulation, set in HTML debugSensors: false, // plot sensor rays and mark detected objects debugMouse: false, // allow dragging any object with the mouse engine: null, // MatterJS 2D physics engine world: null, // world object (composite of all objects in MatterJS engine) runner: null, // object for running MatterJS engine height: null, // set in HTML file; height of arena (world canvas), in pixels width: null, // set in HTML file; width of arena (world canvas), in pixels curSteps: 0 // increased by simStep() }; robots = new Array(); sensors = new Array(); function init() { // called once when loading HTML file const robotBay = document.getElementById("bayLemming"), arena = document.getElementById("arenaLemming"), height = arena.height, width = arena.width; simInfo.height = height; simInfo.width = width; /* Create a MatterJS engine and world. */ simInfo.engine = Matter.Engine.create(); simInfo.world = simInfo.engine.world; simInfo.world.gravity.y = simInfo.gravity; simInfo.engine.timing.timeScale = 1; /* Create walls and boxes, and add them to the world. */ // note that "roles" are custom properties for rendering (not from MatterJS) function getWall(x, y, width, height) { return Matter.Bodies.rectangle(x, y, width, height, {isStatic: true, role: 'wall', color:[150, 150, 150]}); }; const wall_lo = getWall(width/2, height-5, width-5, 5), wall_hi = getWall(width/2, 5, width-5, 5), wall_le = getWall(5, height/2, 5, height-15), wall_ri = getWall(width-5, height/2, 5, height-15); Matter.World.add(simInfo.world, [wall_lo, wall_hi, wall_le, wall_ri]); /* Add a bunch of boxes in a neat grid. */ function getBox(x, y) { // flip coin for red vs blue and add rgb colFlag = Math.round(Math.random()); // random 0,1 variable for box color if (colFlag == 1 ){ color = [0, 0, 200]; } else { color = [200, 0, 0]; } box = Matter.Bodies.rectangle(x, y, simInfo.boxSize, simInfo.boxSize, {frictionAir: simInfo.airDrag, friction: simInfo.boxFric, mass: simInfo.boxMass, role: 'box', color: color}); return box; }; const startX = 100, startY = 100, nBoxX = 5, nBoxY = 5, gapX = 40, gapY = 30, stack = Matter.Composites.stack(startX, startY, nBoxX, nBoxY, gapX, gapY, getBox); Matter.World.add(simInfo.world, stack); /* Add debugging mouse control for dragging objects. */ if (simInfo.debugMouse){ const mouseConstraint = Matter.MouseConstraint.create(simInfo.engine, {mouse: Matter.Mouse.create(arena), // spring stiffness mouse ~ object constraint: {stiffness: 0.5}}); Matter.World.add(simInfo.world, mouseConstraint); } // Add the tracker functions from mouse.js addMouseTracker(arena); addMouseTracker(robotBay); /* Running the MatterJS physics engine (without rendering). */ simInfo.runner = Matter.Runner.create({fps: 60, isFixed: false}); Matter.Runner.start(simInfo.runner, simInfo.engine); // register function simStep() as callback to MatterJS's engine events Matter.Events.on(simInfo.engine, 'tick', simStep); /* Create robot(s). */ setRobotNumber(1); // requires defined simInfo.world loadBay(robots[0]); }; function convrgb(values) { return 'rgb(' + values.join(', ') + ')'; }; function rotate(robot, torque=0) { /* Apply a torque to the robot to rotate it. * * Parameters * torque - rotational force to apply to the body. * Try values around +/- 0.005. */ robot.body.torque = torque; }; function drive(robot, force=0) { /* Apply a force to the robot to move it. * * Parameters * force - force to apply to the body. * Try values around +/- 0.0005. */ const orientation = robot.body.angle, force_vec = Matter.Vector.create(force, 0), move_vec = Matter.Vector.rotate(force_vec, orientation); Matter.Body.applyForce(robot.body, robot.body.position , move_vec); }; function senseDistance() { /* Distance sensor simulation based on ray casting. Called from sensor * object, returns nothing, updates a new reading into this.value. * * Idea: Cast a ray with a certain length from the sensor, and check * via collision detection if objects intersect with the ray. * To determine distance, run a Binary search on ray length. * Note: Sensor ray needs to ignore robot (parts), or start outside of it. * The latter is easy with the current circular shape of the robots. * Note: Order of tests are optimized by starting with max ray length, and * then only testing the maximal number of initially resulting objects. * Note: The sensor's "ray" could have any other (convex) shape; * currently it's just a very thin rectangle. */ const context = document.getElementById('arenaLemming').getContext('2d'); var bodies = Matter.Composite.allBodies(simInfo.engine.world); const robotAngle = this.parent.body.angle, attachAngle = this.attachAngle, rayAngle = robotAngle + attachAngle + this.lookAngle; const rPos = this.parent.body.position, rSize = simInfo.robotSize, startPoint = {x: rPos.x + (rSize+1) * Math.cos(robotAngle + attachAngle), y: rPos.y + (rSize+1) * Math.sin(robotAngle + attachAngle)}; function getEndpoint(rayLength) { return {x: rPos.x + (rSize + rayLength) * Math.cos(rayAngle), y: rPos.y + (rSize + rayLength) * Math.sin(rayAngle)}; }; function sensorRay(bodies, rayLength) { // Cast ray of supplied length and return the bodies that collide with it. const rayWidth = 1e-100, endPoint = getEndpoint(rayLength); rayX = (endPoint.x + startPoint.x) / 2, rayY = (endPoint.y + startPoint.y) / 2, rayRect = Matter.Bodies.rectangle(rayX, rayY, rayLength, rayWidth, {isSensor: true, isStatic: true, angle: rayAngle, role: 'sensor'}); var collidedBodies = []; for (var bb = 0; bb < bodies.length; bb++) { var body = bodies[bb]; // coarse check on body boundaries, to increase performance: if (Matter.Bounds.overlaps(body.bounds, rayRect.bounds)) { for (var pp = body.parts.length === 1 ? 0 : 1; pp < body.parts.length; pp++) { var part = body.parts[pp]; // finer, more costly check on actual geometry: if (Matter.Bounds.overlaps(part.bounds, rayRect.bounds)) { const collision = Matter.SAT.collides(part, rayRect); if (collision.collided) { collidedBodies.push(body); break; } } } } } return collidedBodies; }; // call 1x with full length, and check all bodies in the world; // in subsequent calls, only check the bodies resulting here var rayLength = this.maxVal; bodies = sensorRay(bodies, rayLength); // if some collided, search for maximal ray length without collisions if (bodies.length > 0) { var lo = 0, hi = rayLength; while (lo < rayLength) { if (sensorRay(bodies, rayLength).length > 0) { hi = rayLength; } else { lo = rayLength; } rayLength = Math.floor(lo + (hi-lo)/2); } } // increase length to (barely) touch closest body (if any) rayLength += 1; bodies = sensorRay(bodies, rayLength); if (simInfo.debugSensors) { // if invisible, check order of object drawing // draw the resulting ray endPoint = getEndpoint(rayLength); context.beginPath(); context.moveTo(startPoint.x, startPoint.y); context.lineTo(endPoint.x, endPoint.y); context.strokeStyle = this.parent.info.color; context.lineWidth = 0.5; context.stroke(); // mark all objects's lines intersecting with the ray for (var bb = 0; bb < bodies.length; bb++) { var vertices = bodies[bb].vertices; context.moveTo(vertices[0].x, vertices[0].y); for (var vv = 1; vv < vertices.length; vv += 1) { context.lineTo(vertices[vv].x, vertices[vv].y); } context.closePath(); } context.stroke(); } // indicate if the sensor exceeded its maximum length by returning infinity if (rayLength > this.maxVal) { rayLength = Infinity; } else { // apply mild noise on the sensor reading, and clamp between valid values function gaussNoise(sigma=1) { const x0 = 1.0 - Math.random(); const x1 = 1.0 - Math.random(); return sigma * Math.sqrt(-2 * Math.log(x0)) * Math.cos(2 * Math.PI * x1); }; rayLength = Math.floor(rayLength + gaussNoise(3)); rayLength = Matter.Common.clamp(rayLength, this.minVal, this.maxVal); } this.value = rayLength; }; function dragSensor(sensor, event) { const robotBay = document.getElementById('bayLemming'), bCenter = {x: robotBay.width/2, y: robotBay.height/2}, rSize = simInfo.robotSize, bScale = simInfo.bayScale, sSize = sensor.getWidth(), mAngle = Math.atan2( event.mouse.x - bCenter.x, -(event.mouse.y - bCenter.y)); sensor.info.attachAngle = mAngle; sensor.x = bCenter.x - sSize - bScale * rSize * Math.sin(-mAngle); sensor.y = bCenter.y - sSize - bScale * rSize * Math.cos( mAngle); repaintBay(); } function loadSensor(sensor, event) { loadSensorInfo(sensor.sensor); } function loadSensorInfo(sensorInfo) { simInfo.baySensor = sensorInfo; } function loadBay(robot) { simInfo.bayRobot = robot; sensors = new Array(); const robotBay = document.getElementById("bayLemming"); const bCenter = {x: robotBay.width/2, y: robotBay.height/2}, rSize = simInfo.robotSize, bScale = simInfo.bayScale; for (var ss = 0; ss < robot.info.sensors.length; ++ss) { const curSensor = robot.sensors[ss], attachAngle = curSensor.attachAngle; // put current sensor into global variable, make mouse-interactive sensors[ss] = makeInteractiveElement(new SensorGraphics(curSensor), document.getElementById("bayLemming")); const sSize = sensors[ss].getWidth(); sensors[ss].x = bCenter.x - sSize - bScale * rSize * Math.sin(-attachAngle); sensors[ss].y = bCenter.y - sSize - bScale * rSize * Math.cos( attachAngle); sensors[ss].onDragging = dragSensor; sensors[ss].onDrag = loadSensor; } repaintBay(); } function SensorGraphics(sensorInfo) { this.info = sensorInfo; this.plotSensor = plotSensor; // add functions getWidth/getHeight for graphics.js & mouse.js, // to enable dragging the sensor in the robot bay this.getWidth = function() { return 6; }; this.getHeight = function() { return 6; }; } function loadFromSVG() { var vertexSets = []; const svg = document.getElementById('robotbodySVG'), data = svg.contentDocument; jQuery(data).find('path').each(function(_, path) { var points = Matter.Svg.pathToVertices(path, 30); vertexSets.push(Matter.Vertices.scale(points, 0.2, 0.2)); }); return vertexSets; }; function InstantiateRobot(robotInfo) { // load robot's body shape from SVG file const bodySVGpoints = loadFromSVG(); this.body = Matter.Bodies.fromVertices(robotInfo.init.x, robotInfo.init.y, bodySVGpoints, {frictionAir: simInfo.airDrag, mass: simInfo.robotMass, color: [255, 255, 255], role: 'robot'}, true); Matter.World.add(simInfo.world, this.body); Matter.Body.setAngle(this.body, robotInfo.init.angle); // instantiate its sensors this.sensors = robotInfo.sensors; for (var ss = 0; ss < this.sensors.length; ++ss) { this.sensors[ss].parent = this; } // attach its helper functions this.rotate = rotate; this.drive = drive; this.info = robotInfo; this.plotRobot = plotRobot; // add functions getWidth/getHeight for graphics.js & mouse.js, // to enable selection by clicking the robot in the arena this.getWidth = function() { return 2 * simInfo.robotSize; }; this.getHeight = function() { return 2 * simInfo.robotSize; }; } function robotUpdateSensors(robot) { // update all sensors of robot; puts new values into sensor.value for (var ss = 0; ss < robot.sensors.length; ss++) { robot.sensors[ss].sense(); } }; function getSensorValById(robot, id) { for (var ss = 0; ss < robot.sensors.length; ss++) { if (robot.sensors[ss].id == id) { return robot.sensors[ss].value; } } return undefined; // if not returned yet, id doesn't exist }; function robotMove(robot) { // TODO: Define Lemming program here. const distL = getSensorValById(robot, 'distL'), distR = getSensorValById(robot, 'distR'); robot.rotate(robot, +0.005); robot.drive(robot, 0.0005); }; function plotSensor(context, x = this.x, y = this.y) { context.beginPath(); context.arc(x + this.getWidth()/2, y + this.getHeight()/2, this.getWidth()/2, 0, 2*Math.PI); context.closePath(); context.fillStyle = 'black'; context.strokeStyle = 'black'; context.fill(); context.stroke(); } function plotRobot(context, xTopLeft = this.body.position.x, yTopLeft = this.body.position.y) { var x, y, scale, angle, i, half, full, rSize = simInfo.robotSize; const showInternalEdges = false; if (context.canvas.id == "bayLemming") { scale = simInfo.bayScale; half = Math.floor(rSize/2*scale); full = half * 2; x = xTopLeft + full; y = yTopLeft + full; angle = -Math.PI / 2; } else { scale = 1; half = Math.floor(rSize/2*scale); full = half * 2; x = xTopLeft; y = yTopLeft; angle = this.body.angle; } context.save(); context.translate(x, y); context.rotate(angle); if (context.canvas.id == "arenaLemming") { // draw into world canvas without transformations, // because MatterJS thinks in world coords... context.restore(); const body = this.body; // handle compound parts context.beginPath(); for (k = body.parts.length > 1 ? 1 : 0; k < body.parts.length; k++) { part = body.parts[k]; context.moveTo(part.vertices[0].x, part.vertices[0].y); for (j = 1; j < part.vertices.length; j++) { if (!part.vertices[j - 1].isInternal || showInternalEdges) { context.lineTo(part.vertices[j].x, part.vertices[j].y); } else { context.moveTo(part.vertices[j].x, part.vertices[j].y); } if (part.vertices[j].isInternal && !showInternalEdges) { context.moveTo(part.vertices[(j + 1) % part.vertices.length].x, part.vertices[(j + 1) % part.vertices.length].y); } } context.lineTo(part.vertices[0].x, part.vertices[0].y); } context.strokeStyle = convrgb(body.color); context.lineWidth = 1.5; context.stroke(); // to draw the rest, rotate & translate again context.save(); context.translate(x, y); context.rotate(angle); } // Plot sensor positions into world canvas. if (context.canvas.id == "arenaLemming") { for (ss = 0; ss < this.info.sensors.length; ++ss) { context.beginPath(); context.arc(full * Math.cos(this.info.sensors[ss].attachAngle), full * Math.sin(this.info.sensors[ss].attachAngle), scale, 0, 2*Math.PI); context.closePath(); context.fillStyle = 'black'; context.strokeStyle = 'black'; context.fill(); context.stroke(); } } context.restore(); } function simStep() { // advance simulation by one step (except MatterJS engine's physics) if (simInfo.curSteps < simInfo.maxSteps) { repaintBay(); drawBoard(); for (var rr = 0; rr < robots.length; ++rr) { robotUpdateSensors(robots[rr]); robotMove(robots[rr]); // To enable selection by clicking (via mouse.js/graphics.js), // the position on the canvas needs to be defined in (x, y): const rSize = simInfo.robotSize; robots[rr].x = robots[rr].body.position.x - rSize; robots[rr].y = robots[rr].body.position.y - rSize; } // count and display number of steps simInfo.curSteps += 1; document.getElementById("SimStepLabel").innerHTML = padnumber(simInfo.curSteps, 5) + ' of ' + padnumber(simInfo.maxSteps, 5); } else { toggleSimulation(); } } function drawBoard() { var context = document.getElementById('arenaLemming').getContext('2d'); context.fillStyle = "#444444"; context.fillRect(0, 0, simInfo.width, simInfo.height); // draw objects within world const Composite = Matter.Composite, bodies = Composite.allBodies(simInfo.world); for (var bb = 0; bb < bodies.length; bb += 1) { var vertices = bodies[bb].vertices, vv; // draw all non-robot bodies here (walls and boxes) // don't draw robot's bodies here; they're drawn in plotRobot() if (bodies[bb].role != 'robot') { context.beginPath(); context.moveTo(vertices[0].x, vertices[0].y); for (vv = 1; vv < vertices.length; vv += 1) { context.lineTo(vertices[vv].x, vertices[vv].y); } if (bodies[bb].color) { context.strokeStyle = convrgb(bodies[bb].color); context.closePath(); context.stroke(); } } } context.lineWidth = 1; // draw all robots for (var rr = 0; rr < robots.length; ++rr) { robots[rr].plotRobot(context); } } function repaintBay() { // update inset canvas showing information about selected robot const robotBay = document.getElementById('bayLemming'), context = robotBay.getContext('2d'); context.clearRect(0, 0, robotBay.width, robotBay.height); simInfo.bayRobot.plotRobot(context, 10, 10); for (var ss = 0; ss < sensors.length; ss++) { sensors[ss].plotSensor(context); } // print sensor values of selected robot next to canvas if (!(simInfo.curSteps % 5)) { // update slow enough to read var sensorString = ''; const rsensors = simInfo.bayRobot.sensors; for (ss = 0; ss < rsensors.length; ss++) { sensorString += '
id \'' + rsensors[ss].id + '\': ' + padnumber(rsensors[ss].value, 2); } document.getElementById('SensorLabel').innerHTML = sensorString; } } function setRobotNumber(newValue) { var n; while (robots.length > newValue) { n = robots.length - 1; Matter.World.remove(simInfo.world, robots[n].body); robots[n] = null; robots.length = n; } while (robots.length < newValue) { if (newValue > RobotInfo.length) { console.warn('You request '+newValue+' robots, but only ' + RobotInfo.length + ' are defined in RobotInfo!'); toggleSimulation(); return; } n = robots.length; robots[n] = makeInteractiveElement(new InstantiateRobot(RobotInfo[n]), document.getElementById("arenaLemming")); robots[n].onDrop = function(robot, event) { robot.isDragged = false; }; robots[n].onDrag = function(robot, event) { robot.isDragged = true; loadBay(robot); return true; }; } } function padnumber(number, size) { if (number == Infinity) { return 'inf'; } const s = "000000" + number; return s.substr(s.length - size); } function format(number) { // prevent HTML elements to jump around at sign flips etc return (number >= 0 ? '+' : '−') + Math.abs(number).toFixed(1); } function toggleSimulation() { simInfo.doContinue = !simInfo.doContinue; if (simInfo.doContinue) { Matter.Runner.start(simInfo.runner, simInfo.engine); } else { Matter.Runner.stop(simInfo.runner); } }