lemmings/lemming_sim_student.js

764 lines
28 KiB
JavaScript
Raw Permalink 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
* Copyright 2017 Niels Boer, Tanja van Alfen, Yorick van Pelt
*
* 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/>.
*/
/*
The simulator was edited quite extensively to make it easier to work with.
The essential changes are the color sensor and gyroscope (in sensors.js) and the robot behavior
(in class Lemming in this file). Sensor placement was changed as well.
Other changes: object-orientation. es6. fixed the bay. consolidated drawing behavior,
svg pathseg workaround, robot debug display. Huge rewrites.
Feel free to use any of this code.
*/
// es6 modules are behind a flag in firefox.
// import { ColorSensor, DistanceSensor, Gyroscope } from './sensors.js'
"use strict"
// Simulation settings; please change anything that you think makes sense.
var simInfo = {
maxSteps: 20000, // 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: 15, // 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
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
wanderRate: 0.0002,
};
class SVGRobot extends Robot {
makeBody(robotInfo) {
// load robot's body shape from SVG file
const bodySVGpoints = loadFromSVG();
return Matter.Bodies.fromVertices(robotInfo.init.x,
robotInfo.init.y,
bodySVGpoints, {
frictionAir: simInfo.airDrag,
mass: simInfo.robotMass,
color: [255, 255, 255],
role: 'robot'
}, true);
}
}
class HebbDidaBot extends Robot {
constructor(props) {
const angle = Math.PI/3.5
super(Object.assign({
sensors: [
new CollisionSensor('collL', {
attachAngle: -angle
}),
new CollisionSensor('collR', {
attachAngle: angle
}),
new DistanceSensor('distL', {
attachAngle: -angle
}),
new DistanceSensor('distR', {
attachAngle: angle
}),
].concat(props.sensors || [])
}, props))
this.display = document.createElement('canvas')
this.display.width = this.display.height = 100
this.display.style.border = '1px solid black'
this.weights = [[Math.random()/10,Math.random()/10],[Math.random()/10,Math.random()/10]]
}
move() {
const {distL, distR, collL, collR} = this.getSensors()
// feed forward: perception
// collision
function feedforward(weights, activations, intrinsic) {
return intrinsic.map((x, i) => {
return x + sum(zip(weights[i], activations).map(([a,b]) => a*b))
})
}
function hebb(weight, lrate, frate, act_src, act_dst) {
const avg = sum(act_src) / act_src.length
return weight.map((row, i) => row.map((wij, j) =>
wij + (lrate * (act_src[j] * act_dst[i]) - frate * avg * wij) / act_src.length
))
}
// input:
const motorweights = [[0,1],[1,0]]
const collisionAct = [+collL, +collR]
const threshold = theta => x => x > theta
const percact = [distL, distR].map(x => x == Infinity ? 0 : (50 - x)/50)
const collact = [+collL, +collR]
const collisionlayer = feedforward(this.weights, percact, collact)
const motorLayer = feedforward(motorweights, collisionlayer, [0,0])
this.weights = hebb(this.weights, simInfo.learningRate, simInfo.forgetRate, percact, collisionlayer)
const [left, right] = motorLayer.map(threshold(0.5))
// output: 2 neurons. if left is activated, turn left. if right is activated, turn right.
// if both, go backwards. otherwise: forward
if (left && !right) this.rotate(-0.003)
else if (!left && right) this.rotate(+0.003)
else if (left && right) this.drive(-2e-4)
else this.drive(2e-4)
this.layers = [percact, collisionlayer, motorLayer]
}
getDisplay() {
return this.layers
}
updateDisplay() {
const ctx = this.display.getContext('2d')
if (!this.weights) return
ctx.clearRect(0,0,100,100)
this.weights.forEach((a, i) => {
a.forEach((b, j) => {
ctx.fillStyle = convrgb([b*128, b*128, b*128].map(x => 255-x))
const [x,y] = [j*50,i*50]
ctx.fillRect(x, y, x+50, y+50)
})
})
}
}
// our own lemming implementation
class Lemming extends SVGRobot {
constructor(props) {
super(Object.assign({
sensors: [
// define another sensor
new DistanceSensor('dist', {
attachAngle: -Math.PI/7,
color: [0, 150, 0],
attachRadius: 20,
lookAngle: Math.PI/4,
}),
new Gyroscope('gyro', {
attachRadius: 5,
color: [100,100,0]
}),
new ColorSensor('carry', {
attachAngle: Math.PI/5,
lookAngle: -Math.PI/5,
color: [255, 100, 0],
attachRadius: 5,
dist: 5,
width: 15,
}),
// define another sensor
new DistanceSensor('wall', {
attachAngle: -Math.PI/7,
color: [0, 150, 0],
filter: x => x.role != 'box',
attachRadius: 20,
lookAngle: Math.PI/4,
}),
].concat(props.sensors || [])
}, props))
}
// turn this amount of degrees to a side
// exploit prototypal inheritance to temporarily override robot.move
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
const start = this.getSensorValById('gyro')
this.move = function() {
const curAngle = this.getSensorValById('gyro')
const turned = ((curAngle - start) * Math.sign(degs) + 360) % 360
if (turned < 340 && (turned - Math.abs(degs) > 0)) {
delete this.move
if (cb) cb()
} else {
this.rotate(torque)
}
}
}
move() {
// our lemming behaviour
const {carry: [r,g,b], dist, wall} = this.getSensors()
let block = 0
if (r > (g+b)) {
block = 'red'
} else if (b > r+g) {
block = 'blue'
}
this.block = block
if (dist < wall - 5) {
this.state = 'block'
// 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
} else if (wall < Infinity) {
this.state = 'wall'
// 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
} else {
this.state = 'nothing'
}
// by default: wander
this.rotate(simInfo.wanderRate);
this.drive(0.0002);
}
getDisplay() {
return "carrying " + (this.block || "no") + " block seeing " + this.state
}
}
var sim = null
var resultsTable = null
let sim_id_counter = 0
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 = []
this.id = sim_id_counter++
}
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
});
}
const [nBoxX, nBoxY] = ["boxX", "boxY"].map(x => +document.getElementById(x).value)
const gapX = 40, gapY = 30
const startX = (width - (simInfo.boxSize * nBoxX + (nBoxX-1) * gapX)) / 2,
startY = (height - (simInfo.boxSize * nBoxY + (nBoxY-1) * gapY)) / 2,
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);
/* 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));
this.bay = new Bay()
}
destroy() {
this.stop()
Matter.Engine.clear(this.engine)
this.elem.removeMouseTracker()
this.bay.destroy()
}
step(draw = true) {
// console.log("stepping", this.id)
// advance simulation by one step (except MatterJS engine's physics)
if (this.curSteps < simInfo.maxSteps) {
if (draw) {
this.bay.repaint();
this.draw();
}
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;
if (draw) document.getElementById("SimStepLabel").innerHTML =
padnumber(this.curSteps, 5) +
' of ' +
padnumber(simInfo.maxSteps, 5);
}
else {
this.stop()
}
}
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;
// draw all robots
this.robots.forEach(robot => robot.plotRobot(context))
}
addRobot(robot) {
this.robots.push(
makeInteractiveElement(robot, this.elem))
if (!this.bay.robot) this.bay.load(robot)
}
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()
}
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)
}
}
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() + ')()')
resultsTable = new ResultsTable(document.getElementById("results"))
resultsTable.clear()
initSimulation()
sim.start()
};
function initSimulation() {
if (sim) {
sim.stop()
sim.destroy()
}
simInfo.boxFric = +document.getElementById("boxFric").value
simInfo.learningRate = +document.getElementById("learningRate").value
simInfo.forgetRate = +document.getElementById("forgetRate").value
sim = new Simulation()
sim.init()
let noRobots = +document.getElementById('robotAmnt').value
while(noRobots--) {
sim.addRobot(new HebbDidaBot({
// 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
}
function countclusters(sim) {
const boxes = sim.world.composites[0].bodies
function dist(box1, box2) {
return Vec2.dist(box1.position, box2.position)
}
const clusters = []
const maxdist = 2 * Math.sqrt(2) * simInfo.boxSize
const unclaimed = new Set(boxes)
while(unclaimed.size) {
const box = unclaimed.entries().next().value[0]
const queue = new Set([box])
let no_boxes = 0
while(queue.size) {
const box2 = queue.entries().next().value[0]
unclaimed.delete(box2)
queue.delete(box2)
no_boxes++
Array.from(unclaimed).filter(box3 => (dist(box2, box3) < maxdist)).forEach(x => queue.add(x))
}
clusters.push(no_boxes)
}
return clusters.sort().reverse()
}
// automatically run the experiments based on the values from the html
async function experimentRunner(stopCondition) {
initSimulation()
resultsTable.clearChart()
while((stopCondition ? !stopCondition(sim) : true) && sim.curSteps < simInfo.maxSteps) {
sim.runSteps(500)
resultsTable.addWeightsPoint(sim.curSteps, sim.robots[0].weights)
// take a break for the gui to stay responsive
await promiseTimeout(50)
}
// save data
const clusters = countclusters(sim)
const rv = {
perc_in_clusters: (clusters.reduce((p,c) => c > 2 ? p+c : p, 0) / sum(clusters)) * 100,
steps: sim.curSteps,
clusters: countclusters(sim),
robots: sim.robots.length
}
sim.destroy()
sim = null
return rv
}
// run a number of experiments
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
this.canvas = document.getElementById('weightschart')
this.chart = new Chart(this.canvas.getContext('2d'), {
type: 'line',
data: {
labels: [],
datasets: [
]
},
options: {
responsive: false
},
})
}
addWeightsPoint(step, weights) {
this.chart.data.labels.push(step)
this.chart.data.datasets[0].data.push(weights[0][0])
this.chart.data.datasets[1].data.push(weights[0][1])
this.chart.data.datasets[2].data.push(weights[1][0])
this.chart.data.datasets[3].data.push(weights[1][1])
this.chart.update()
}
clear() {
this.elem.innerHTML = "<tr><th colspan=4>Experiment Results</th></tr>"
const tr = document.createElement('tr')
const headers = ["robots", "steps", "clusters", "cluster%"]
headers.forEach(txt => {
tr.appendChild(document.createElement('th')).appendChild(document.createTextNode(txt))
})
this.elem.appendChild(tr)
this.clearChart()
}
clearChart() {
this.chart.data.labels = []
this.chart.data.datasets = [
{label: "w00", data: [], fill: false, borderColor: 'red', backgroundColor: 'red'},
{label: "w01", data: [], fill: false, borderColor: 'blue', backgroundColor: 'blue'},
{label: "w10", data: [], fill: false, borderColor: 'green', backgroundColor: 'green'},
{label: "w11", data: [], fill: false, borderColor: 'yellow', backgroundColor: 'yellow'},
]
this.chart.update()
}
add({steps, clusters, robots, perc_in_clusters}) {
const tr = document.createElement('tr')
;[robots, steps, clusters.toString(), perc_in_clusters].forEach(txt => {
tr.appendChild(document.createElement('td')).appendChild(document.createTextNode(txt))
})
this.elem.appendChild(tr)
}
}
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);
}
destroy() {
this.elem.removeMouseTracker()
}
load(robot) {
this.robot = robot
robot.sensors.forEach(sensor => {
makeInteractiveElement(sensor, this.elem)
})
const bayDisplay = document.getElementById('bayDisplay')
bayDisplay.innerHTML = ""
bayDisplay.appendChild(robot.display)
this.repaint()
}
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;
robot.updateDisplay()
}
}
transformMouse({x,y}) {
// scale, translate, rotate by 90 degrees,
return { y: (x / simInfo.bayScale) - this.center.x,
x: -((y / simInfo.bayScale) - this.center.y) }
}
}
function loadFromSVG() {
var vertexSets = [];
const svg = document.getElementById('robotbodySVG'),
data = svg.contentDocument;
for (const path of data.getElementsByTagName('path')) {
const points = Matter.Svg.pathToVertices(path, 30);
vertexSets.push(Matter.Vertices.scale(points, 0.2, 0.2));
}
return vertexSets;
};
function Robot(robotInfo) {
this.body = this.makeBody(robotInfo)
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;
this.display = document.createElement('span')
}
Object.assign(Robot.prototype, {
makeBody(robotInfo) {
const nSides = 20,
circle = Matter.Bodies.circle;
return circle(robotInfo.init.x, robotInfo.init.y, simInfo.robotSize,
{
frictionAir: simInfo.airDrag,
mass: simInfo.robotMass,
color: [255, 255, 255],
role: 'robot'
}, nSides)
},
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;
},
getDisplay() {
return ""
},
updateDisplay() {
this.display.innerHTML = this.getDisplay()
},
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
},
getSensors() {
return this.sensors.reduce((p,{id, value}) => (p[id] = value, p), {})
},
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();
}
})
function padnumber(number, size) {
if (number == 'Infinity') return 'inf'
return (''+number).padStart(size, '0')
}
function format(number) {
// prevent HTML elements to jump around at sign flips etc
return (number >= 0 ? '+' : '') + Math.abs(number).toFixed(1);
}
function toggleSimulation() {
sim.toggle()
}