lemmings/lemming_sim_student.js

595 lines
21 KiB
JavaScript
Raw Normal View History

2017-11-10 14:32:01 +01:00
/* Lemmings - robot and GUI script.
*
* Copyright 2016 Harmen de Weerd
2017-11-27 03:05:27 +01:00
* Copyright 2017 Johannes Keyser, James Cooke, George Kachergis, Yorick van Pelt
2017-11-10 14:32:01 +01:00
*
* 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/>.
*/
2017-11-27 03:05:27 +01:00
"use strict"
2017-11-10 14:32:01 +01:00
// Simulation settings; please change anything that you think makes sense.
2017-11-27 03:05:27 +01:00
var simInfo = {
maxSteps: 50000, // maximal number of simulation steps to run
airDrag: 0.1, // "air" friction of enviroment; 0 is vacuum, 0.9 is molasses
2017-12-02 17:02:11 +01:00
boxFric: 0.001, // friction between boxes during collisions
2017-11-27 03:05:27 +01:00
boxMass: 0.01, // mass of boxes
2017-11-29 01:55:10 +01:00
boxSize: 10, // size of the boxes, in pixels
2017-11-27 03:05:27 +01:00
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
bayScale: 2, // scale within 2nd, inset canvas showing robot in it's "bay"
debugSensors: true, // plot sensor rays and mark detected objects
debugMouse: true, // allow dragging any object with the mouse
2017-12-02 17:02:11 +01:00
wanderRate: 0.0002,
2017-11-10 14:32:01 +01:00
};
2017-11-27 03:05:27 +01:00
class Lemming extends Robot {
constructor(props) {
super(Object.assign({
sensors: [
// define another sensor
2017-12-02 17:02:11 +01:00
new DistanceSensor('dist', {
2017-11-27 03:05:27 +01:00
attachAngle: -Math.PI/7,
color: [0, 150, 0],
2017-12-02 17:02:11 +01:00
attachRadius: 20,
lookAngle: Math.PI/4,
2017-11-27 03:05:27 +01:00
}),
new Gyroscope('gyro', {
attachRadius: 5,
color: [100,100,0]
}),
new ColorSensor('carry', {
2017-11-29 01:54:28 +01:00
attachAngle: Math.PI/5,
lookAngle: -Math.PI/5,
2017-11-27 03:05:27 +01:00
color: [255, 100, 0],
2017-11-29 01:54:28 +01:00
attachRadius: 5,
dist: 5,
width: 15,
2017-11-27 03:05:27 +01:00
}),
// define another sensor
2017-12-02 17:02:11 +01:00
new DistanceSensor('wall', {
2017-11-27 03:05:27 +01:00
attachAngle: -Math.PI/7,
color: [0, 150, 0],
2017-12-02 17:02:11 +01:00
filter: x => x.role != 'box',
attachRadius: 20,
lookAngle: Math.PI/4,
2017-11-27 03:05:27 +01:00
}),
].concat(props.sensors || [])
}, props))
2017-11-10 14:32:01 +01:00
}
2017-12-02 17:02:11 +01:00
turnDeg(degs, cb) {
if (Math.abs(degs) >= 320)
return this.turnDeg(320, x => this.turnDeg(degs -320, cb))
const torque = Math.sign(degs) * 0.002
2017-11-27 03:05:27 +01:00
const start = this.getSensorValById('gyro')
this.move = function() {
const curAngle = this.getSensorValById('gyro')
2017-12-02 17:02:11 +01:00
const turned = ((curAngle - start) * Math.sign(degs) + 360) % 360
if (turned < 340 && (turned - Math.abs(degs) > 0)) {
2017-11-27 03:05:27 +01:00
delete this.move
if (cb) cb()
} else {
this.rotate(torque)
2017-11-10 14:32:01 +01:00
}
}
}
2017-11-27 03:05:27 +01:00
move() {
//if (sim.curSteps % 250 == 0) this.turnDeg(-90)
// TODO: Define Lemming program here.
2017-12-02 17:02:11 +01:00
const {carry: [r,g,b], dist, wall} = this.getSensors()
2017-11-27 03:05:27 +01:00
let block = 0
if (r > (g+b)) {
block = 'red'
} else if (b > r+g) {
block = 'blue'
}
2017-11-29 01:54:56 +01:00
this.block = block
2017-12-02 17:02:11 +01:00
if (dist < wall - 5) {
2017-11-29 01:54:56 +01:00
this.state = 'block'
2017-11-27 03:05:27 +01:00
// if it senses a block
if (!block) return this.drive(2e-4) // no block: drive towards block?
if (block == 'blue') void 0; // if blue: ignore
if (block == 'red') return this.turnDeg(-90) // if red: leave block
2017-12-02 17:02:11 +01:00
} else if (wall < Infinity) {
2017-11-29 01:54:56 +01:00
this.state = 'wall'
2017-11-27 03:05:27 +01:00
// no block: turn left or right
if (!block) return this.turnDeg(90 * (Math.random() < 0.5 ? -1 : 1))
if (block == 'blue') return this.turnDeg(-90) // blue: leave
if (block == 'red') return this.turnDeg(90) // red: keep
2017-11-29 01:54:56 +01:00
} else {
this.state = 'nothing'
2017-11-27 03:05:27 +01:00
}
// by default: wander
2017-12-02 17:02:11 +01:00
this.rotate(simInfo.wanderRate);
2017-11-27 03:05:27 +01:00
this.drive(0.0002);
2017-11-10 14:32:01 +01:00
}
2017-11-29 01:54:56 +01:00
getDisplay() {
2017-12-02 17:02:11 +01:00
return "carrying " + (this.block || "no") + " block seeing " + this.state
2017-11-29 01:54:56 +01:00
}
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
var sim = null
2017-12-02 17:02:11 +01:00
var resultsTable = null
let sim_id_counter = 0
2017-11-27 03:05:27 +01:00
class Simulation {
constructor() {
this.bay = null
this.robots = null
this.runner = null
this.world = null
this.engine = null
this.curSteps = 0
this.doContinue = false
this.robots = []
2017-12-02 17:02:11 +01:00
this.id = sim_id_counter++
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
init() {
const arena = document.getElementById("arenaLemming"),
{height, width} = arena
this.elem = arena
arena.style.backgroundColor = 'silver'
Object.assign(this, {height, width})
/* Create a MatterJS engine and world. */
this.engine = Matter.Engine.create();
this.world = this.engine.world;
this.world.gravity.y = simInfo.gravity;
this.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(this.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
const color = (Math.random() < 0.5) ? [0, 0, 200] : [200, 0, 0]
return Matter.Bodies.rectangle(x, y, simInfo.boxSize, simInfo.boxSize, {
frictionAir: simInfo.airDrag,
friction: simInfo.boxFric,
mass: simInfo.boxMass,
role: 'box',
color
});
}
2017-11-10 14:32:01 +01:00
2017-11-27 03:05:27 +01:00
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(this.world, stack);
/* Add debug ging mouse control for dragging objects. */
if (simInfo.debugMouse){
const mouseConstraint = Matter.MouseConstraint.create(this.engine, {
mouse: Matter.Mouse.create(arena),
// spring stiffness mouse ~ object
constraint: {stiffness: 0.5}
});
Matter.World.add(this.world, mouseConstraint);
}
// Add the tracker functions from mouse.js
addMouseTracker(arena);
2017-11-10 14:32:01 +01:00
2017-11-27 03:05:27 +01:00
/* Running the MatterJS physics engine (without rendering). */
this.runner = Matter.Runner.create({fps: 60, isFixed: false});
// register function simStep() as callback to MatterJS's engine events
Matter.Events.on(this.engine, 'tick', this.step.bind(this));
2017-11-10 14:32:01 +01:00
2017-11-27 03:05:27 +01:00
this.bay = new Bay()
}
2017-12-02 17:02:28 +01:00
destroy() {
this.stop()
Matter.Engine.clear(this.engine)
this.elem.removeMouseTracker()
this.bay.destroy()
}
step(draw = true) {
// console.log("stepping", this.id)
2017-11-27 03:05:27 +01:00
// advance simulation by one step (except MatterJS engine's physics)
if (this.curSteps < simInfo.maxSteps) {
2017-12-02 17:02:28 +01:00
if (draw) {
this.bay.repaint();
this.draw();
}
2017-11-27 03:05:27 +01:00
this.robots.forEach(robot => {
robot.updateSensors();
robot.move();
// 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;
robot.x = robot.body.position.x - rSize;
robot.y = robot.body.position.y - rSize;
})
// count and display number of steps
this.curSteps += 1;
2017-12-02 17:02:28 +01:00
if (draw) document.getElementById("SimStepLabel").innerHTML =
2017-11-27 03:05:27 +01:00
padnumber(this.curSteps, 5) +
' of ' +
padnumber(simInfo.maxSteps, 5);
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
else {
2017-12-02 17:02:11 +01:00
this.stop()
2017-11-10 14:32:01 +01:00
}
}
2017-11-27 03:05:27 +01:00
draw() {
const context = this.elem.getContext('2d')
context.clearRect(0, 0, this.width, this.height);
// draw objects within world
const Composite = Matter.Composite,
bodies = Composite.allBodies(this.world);
bodies.forEach(({role, vertices, color}) => {
if (role == 'robot') return
if (color) {
context.strokeStyle = convrgb(color);
}
drawVertices(context, vertices);
})
context.lineWidth = 1;
2017-11-10 14:32:01 +01:00
2017-11-27 03:05:27 +01:00
// draw all robots
this.robots.forEach(robot => robot.plotRobot(context))
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
addRobot(robot) {
this.robots.push(
makeInteractiveElement(robot, this.elem))
if (!this.bay.robot) this.bay.load(robot)
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
removeRobot(robot) {
this.robots = this.robots.filter(x => x == robot)
Matter.World.remove(this.world, robot.body)
}
start() {
if (!this.doContinue) Matter.Runner.start(this.runner, this.engine);
this.doContinue = true
}
stop() {
if (this.doContinue) Matter.Runner.stop(this.runner)
this.doContinue = false
}
toggle() {
if (this.doContinue) this.stop()
else this.start()
2017-11-10 14:32:01 +01:00
}
2017-12-02 17:02:28 +01:00
runSteps(steps) {
this.stop()
const sensorDebug_ = simInfo.debugSensors
simInfo.debugSensors = false
for(let i = 0; i < steps-1; i++) {
Matter.Engine.update(this.engine, 1000/60);
this.step(false)
}
simInfo.debugSensors = sensorDebug_
Matter.Engine.update(this.engine, 1000/60);
this.step(true)
}
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
function init() { // called once when loading HTML file
// the pathseg polyfill will run in the html document
// but we need it in the svg. I changed the IIFE in to a
// regular function and inject it into the svg using eval
const svg = document.getElementById('robotbodySVG')
svg.contentWindow.eval('(' + window.pathseg.toString() + ')()')
2017-12-02 17:02:28 +01:00
resultsTable = new ResultsTable(document.getElementById("results"))
resultsTable.clear()
initSimulation()
2017-11-27 03:05:27 +01:00
sim.start()
};
2017-12-02 17:02:28 +01:00
function initSimulation() {
if (sim) {
sim.stop()
sim.destroy()
}
simInfo.boxFric = +document.getElementById("boxFric").value
simInfo.wanderRate = +document.getElementById("wanderRate").value
sim = new Simulation()
sim.init()
let noRobots = +document.getElementById('robotAmnt').value
while(noRobots--) {
sim.addRobot(new Lemming({
// random color
color: [Math.random()*255, Math.random()*255, Math.random()*255], // color of the robot shape
init: {x: 50*noRobots + 50, y: 50, angle: 0}, // initial position and orientation
}))
}
}
function promiseTimeout(time) {
return new Promise((yes, no) => setTimeout(yes, time))
}
function blocksSorted(sim) {
const {width, height} = sim
return sim.world.composites[0].bodies.every(({position: {x, y}, color: [r, g, b]}) => {
const dist = Math.min(x - 10, width - 10 - x, y - 10, height - 10 - y)
// if red block: ignore
if (r > g + b) return true
// blue block
if (b > r + g) {
const dist = Math.min(x - 10, width - 10 - x, y - 10, height - 10 - y)
// distance from wall
return dist < 70
} else {
console.warn("unknown block type")
}
})
}
function redblocksatwall(sim) {
const {width, height} = sim
let result = 0
sim.world.composites[0].bodies.forEach(({position: {x, y}, color: [r, g, b]}) => {
const dist = Math.min(x - 10, width - 10 - x, y - 10, height - 10 - y)
// if red block: count
if (r > g + b) {
if (dist < 70) result++
}
})
return result
}
async function experimentRunner(stopCondition=blocksSorted) {
initSimulation()
while(!stopCondition(sim) && sim.curSteps < simInfo.maxSteps) {
sim.runSteps(500)
await promiseTimeout(50)
}
// save data
const rv = {
steps: sim.curSteps,
done: stopCondition(sim),
redblocks: redblocksatwall(sim),
robots: sim.robots.length
}
sim.destroy()
sim = null
return rv
}
async function runExperiments() {
let count = +document.getElementById('ffwdtimes').value
while(count--) {
await (experimentRunner().then(x => resultsTable.add(x)))
}
}
class ResultsTable {
constructor(elem) {
this.elem = elem
}
clear() {
this.elem.innerHTML = "<tr><th colspan=4>Experiment Results</th></tr>"
const tr = document.createElement('tr')
const headers = ["robots", "steps", "sorted", "red blocks@wall"]
headers.forEach(txt => {
tr.appendChild(document.createElement('th')).appendChild(document.createTextNode(txt))
})
this.elem.appendChild(tr)
}
add({steps, done, redblocks, robots}) {
const tr = document.createElement('tr')
;[robots, steps, done, redblocks].forEach(txt => {
tr.appendChild(document.createElement('td')).appendChild(document.createTextNode(txt))
})
this.elem.appendChild(tr)
}
}
2017-11-10 14:32:01 +01:00
2017-11-27 03:05:27 +01:00
class Bay {
constructor() {
this.elem = document.getElementById("bayLemming")
this.context = this.elem.getContext('2d')
this.center = {x: this.elem.width / simInfo.bayScale / 2,
y: this.elem.height / simInfo.bayScale / 2}
this.robot = null
this.elem.style.backgroundColor = 'silver'
addMouseTracker(this.elem);
}
2017-12-02 17:02:28 +01:00
destroy() {
this.elem.removeMouseTracker()
}
2017-11-27 03:05:27 +01:00
load(robot) {
this.robot = robot
robot.sensors.forEach(sensor => {
makeInteractiveElement(sensor, this.elem)
})
// todo: removeinteractiveelement?
this.repaint()
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
repaint() {
const {context, robot, elem: robotBay} = this
// update inset canvas showing information about selected robot
context.clearRect(0, 0, this.elem.width, this.elem.height);
if (!robot) return
context.save()
context.scale(simInfo.bayScale, simInfo.bayScale)
context.translate(this.center.x, this.center.y)
context.rotate(-Math.PI/2)
robot.plotRobot(context, 0, 0, 0);
context.restore()
// print sensor values of selected robot next to canvas
if (!(sim.curSteps % 5)) { // update slow enough to read
const sensorString = robot.sensors.map(({id, valueStr, color}) => {
return `<br> <span style="color:${color ? convrgb(color) : 'black'}">id '${id}': ${valueStr}</span>`
}).join('')
document.getElementById('SensorLabel').innerHTML = sensorString;
2017-11-29 01:54:56 +01:00
document.getElementById('bayDisplay').innerHTML = robot.getDisplay()
2017-11-27 03:05:27 +01:00
}
}
transformMouse({x,y}) {
// scale, translate, rotate by 90 degrees,
return { y: (x / simInfo.bayScale) - this.center.x,
x: -((y / simInfo.bayScale) - this.center.y) }
}
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
function loadFromSVG() {
var vertexSets = [];
const svg = document.getElementById('robotbodySVG'),
data = svg.contentDocument;
2017-11-10 14:32:01 +01:00
2017-11-27 03:05:27 +01:00
for (const path of data.getElementsByTagName('path')) {
const points = Matter.Svg.pathToVertices(path, 30);
vertexSets.push(Matter.Vertices.scale(points, 0.2, 0.2));
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
return vertexSets;
};
2017-11-10 14:32:01 +01:00
2017-11-27 03:05:27 +01:00
function Robot(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(sim.world, this.body);
Matter.Body.setAngle(this.body, robotInfo.init.angle);
// instantiate its sensors
this.sensors = robotInfo.sensors;
this.sensors.forEach(sensor => sensor.parent = this)
// attach its helper functions
this.info = robotInfo;
2017-11-10 14:32:01 +01:00
}
2017-11-27 03:05:27 +01:00
Object.assign(Robot.prototype, {
rotate(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.
*/
this.body.torque = torque;
},
2017-11-29 01:54:56 +01:00
getDisplay() {
return ""
},
2017-11-27 03:05:27 +01:00
drive(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 = this.body.angle,
force_vec = Matter.Vector.create(force, 0),
move_vec = Matter.Vector.rotate(force_vec, orientation);
Matter.Body.applyForce(this.body, this.body.position , move_vec);
},
updateSensors() {
this.sensors.forEach(sensor => sensor.sense())
},
getSensorValById(id) {
const sensor = this.sensors.find(sensor => sensor.id == id)
return sensor ? sensor.value : undefined
},
2017-11-29 02:05:53 +01:00
getSensors() {
return this.sensors.reduce((p,{id, value}) => (p[id] = value, p), {})
},
2017-11-27 03:05:27 +01:00
move() {
// TODO: Define Lemming program here.
const distL = this.getSensorValById('distL'),
distR = this.getSensorValById('distR');
//robot.rotate(+0.005);
//robot.drive(0.0005);
},
mouseHit(x, y) {
return Vec2.distLess(this.body.position, {x,y}, this.getWidth()/2 + 1)
},
getWidth() { return 2 * simInfo.robotSize },
getHeight() { return 2 * simInfo.robotSize },
onDrop(robot, event) {
this.isDragged = false
},
onDrag(robot, event) {
this.isDragged = true
sim.bay.load(this)
return true
},
plotRobot(context,
x = this.body.position.x,
y = this.body.position.y,
angle = this.body.angle) {
const showInternalEdges = false;
const body = this.body
// MatterJS thinks in world coords
// translate the world canvas to compensate
context.save();
context.rotate(-body.angle + angle);
context.translate(-body.position.x + x, -body.position.y + y);
// handle compound parts
context.beginPath();
context.strokeStyle = convrgb(body.color);
context.lineWidth = 1.5;
body.parts.forEach(({vertices}, k) => {
if (k == 0 && body.parts.length > 1) return
context.moveTo(vertices[0].x, vertices[0].y);
let fn, wasInternal = true
vertices.forEach(fn = ({x,y, isInternal}, j) => {
if (wasInternal) context.moveTo(x, y)
else context.lineTo(x, y)
wasInternal = isInternal && !showInternalEdges
})
fn(vertices[0])
})
context.stroke()
// to draw the rest, rotate & translate again
context.restore()
context.save();
context.translate(x, y);
context.rotate(angle);
// Plot sensor positions into world canvas.
this.sensors.forEach(sensor => sensor.plotSensor(context))
context.restore();
}
})
2017-11-10 14:32:01 +01:00
function padnumber(number, size) {
2017-11-27 03:05:27 +01:00
if (number == 'Infinity') return 'inf'
return (''+number).padStart(size, '0')
2017-11-10 14:32:01 +01:00
}
function format(number) {
2017-11-27 03:05:27 +01:00
// prevent HTML elements to jump around at sign flips etc
return (number >= 0 ? '+' : '') + Math.abs(number).toFixed(1);
2017-11-10 14:32:01 +01:00
}
function toggleSimulation() {
2017-11-27 03:05:27 +01:00
sim.toggle()
2017-11-10 14:32:01 +01:00
}