lemmings/lemming_sim_student.js

664 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/* 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 <http://www.gnu.org/licenses/>.
*/
// 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 += '<br> 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);
}
}