Commits

catseye  committed 37f3564

First (exceptionally hamfisted) cut at Javascript implementation.

  • Participants
  • Parent commits 4411e08

Comments (0)

Files changed (5)

File impl/etcha.js/demo/etcha.html

+<!DOCTYPE html>
+<head>
+  <meta charset="utf-8">
+  <title>Etcha</title>
+  <style>
+#canvas { border: 1px solid blue; }
+#canvas_viewport {
+    width: 420px; height: 420px; overflow: scroll; border: 1px solid black;
+}
+#info { float: right; }
+#program {
+  display: none;
+}
+#load {
+  display: none;
+}
+  </style>
+</head>
+<body>
+
+<p id="info">
+Etcha on:
+<a href="http://catseye.tc/node/Etcha.html">catseye.tc</a> |
+<a href="https://github.com/catseye/Etcha">github</a> |
+<a href="http://esolangs.org/wiki/Etcha">esolangs wiki</a>
+</p>
+
+<h1>Etcha</h1>
+
+<button id="load">Load</button>
+<button id="edit">Edit</button>
+<button id="start">Start</button>
+<button id="stop">Stop</button>
+<button id="step">Step</button>
+Speed: <input id="speed" type="range" min="0" max="200" value="0" />
+
+<div>
+  example source:
+  <span id="select_source_container">
+    <select id="select_source">
+      <option>(select one...)</option>
+      <option>silly-little-thing</option>
+    </select>
+  </span>
+</div>
+
+<div id="canvas_viewport">
+  <canvas id="canvas" width="400" height="400">
+    Your browser doesn't support displaying an HTML5 canvas.
+  </canvas>
+</div>
+
+<textarea id="program" rows="25" cols="40">
+</textarea>
+
+<div id="silly-little-thing" style="display: none;"
+>&gt;+++&gt;+++&gt;+++&gt;+++&gt;[+]&gt;&gt;&gt;&gt;+</div>
+
+</body>
+<script src="../src/yoob/controller.js"></script>
+<script src="../src/yoob/playfield.js"></script>
+<script src="../src/yoob/cursor.js"></script>
+<script src="../src/etcha.js"></script>
+<script>
+  var c = new EtchaController();
+  c.init(document.getElementById('canvas'));
+  c.connect({
+    'start': 'start',
+    'stop': 'stop',
+    'step': 'step',
+    'load': 'load',
+    'edit': 'edit',
+    'speed': 'speed',
+    'select': 'select_source',
+    'source': 'program',
+    'display': 'canvas_viewport'
+  });
+  c.click_load();
+</script>

File impl/etcha.js/src/etcha.js

+/*
+ * An EtchaController implements the semantics of Etcha.
+ * ALTHOUGH IT SHOULD BE NOTED, THAT, CURRENTLY THE SEMANTICS
+ * IMPLEMENTED, BY ETCHACONTROLLER ARE SUSPICIOUSLY SIMILAR TO
+ * THE SMENATICS OF GEMOOY AND, NOT, AT ALL RESEMBLE ETCHA YET.
+ * The source code in this file has been placed into the public domain.
+ */
+
+/*
+class Etcha extends TextBasedLanguage<EtchaState> {
+    public String getName() {
+        return "Etcha";
+    }
+
+    public int numPlayfields() {
+        return 1;
+    }
+
+    public int numTapes() {
+        return 0;
+    }
+
+    public boolean hasInput() {
+        return false;
+    }
+
+    public boolean hasOutput() {
+        return false;
+    }
+
+    public EtchaState importFromText(String text) {
+        EtchaState s = new EtchaState();
+        s.setProgramText(text);
+        return s;
+    }
+
+    private static final String[][] properties = {
+        {"Author", "Chris Pressey"},
+        {"Implementer", "Chris Pressey"},
+        {"Implementation notes",
+         "This implementation uses a yoob playfield as data " +
+         "store/output."},
+    };
+
+    public String[][] getProperties() {
+        return properties;
+    }
+}
+
+class EtchaPlayfield extends BasicPlayfield<BitElement> {
+    protected BasicCursor<BitElement> turtle;
+    public EtchaPlayfield() {
+        super(BitElement.ZERO);
+        this.turtle = new BasicCursor<BitElement>(this);
+        turtle.setDelta(0, -1);
+        clear();
+    }
+
+    public EtchaPlayfield clone() {
+        EtchaPlayfield c = new EtchaPlayfield();
+        c.copyBackingStoreFrom(this);
+        c.turtle = turtle.clone();
+        c.turtle.setPlayfield(c);
+        return c;
+    }
+
+    public int numCursors() {
+        return 1;
+    }
+
+    public BasicCursor<BitElement> getCursor(int index) {
+        if (index == 0)
+            return turtle;
+        return null;
+    }
+}
+
+class EtchaPlayfieldView extends BasicPlayfieldView {
+    public void render(Graphics g, Element e, int x, int y, int w, int h) {
+        BitElement be = (BitElement)e;
+        if (be.getBoolean()) {
+            g.setColor(Color.black);
+        } else {
+            g.setColor(Color.white);
+        }
+        g.fillRect(x, y, w, h);
+    }
+
+    public void render(Graphics g, Cursor c, int x, int y, int w, int h) {
+        g.setColor(Color.blue);
+        g.drawRoundRect(x - 1, y - 1, w + 2, h + 2, w / 4, h / 4);
+        if (c instanceof BasicCursor) {
+            BasicCursor bc = (BasicCursor)c;
+            int cx = x + (w/2);
+            int cy = y + (h/2);
+            int dx = bc.getDeltaX().intValue();
+            int dy = bc.getDeltaY().intValue();
+            int ex = cx + (dx * w);
+            int ey = cy + (dy * h);
+            g.drawLine(cx, cy, ex, ey);
+        }
+    }
+}
+
+public class EtchaState implements State {
+    protected EtchaPlayfield pf;
+    protected EtchaPlayfieldView pfView;
+    protected int pencounter = 0;
+    protected boolean pendown = true;
+    protected boolean halted = false;
+    protected String program;
+    protected int pc = 0;
+    private static final Etcha language = new Etcha();
+
+    public EtchaState() {
+        pf = new EtchaPlayfield();
+        BasicCursor<BitElement> ip = (BasicCursor<BitElement>)pf.getCursor(0);
+        ip.setDelta(0, -1);
+        pfView = new EtchaPlayfieldView();
+    }
+    
+    public Language getLanguage() {
+        return language;
+    }
+
+    public EtchaState clone() {
+        EtchaState c = new EtchaState();
+        c.pf = pf.clone();
+        c.program = program;
+        c.pc = pc;
+        c.halted = halted;
+        c.pencounter = pencounter;
+        c.pendown = pendown;
+        return c;
+    }
+
+    public List<Error> step(World world) {
+        ArrayList<Error> errors = new ArrayList<Error>();
+        BasicCursor<BitElement> ip = (BasicCursor<BitElement>)pf.getCursor(0);
+        char instruction = program.charAt(pc);
+        switch (instruction) {
+            case '+':
+                // + -- equivalent to FD 1
+                if (pendown) {
+                    ip.set(ip.get().invert());
+                }
+                ip.advance();
+                break;
+            case '>':
+                // > -- equivalent to RT 90; toggles PU/PD every 4 executions
+                ip.rotate(90);
+                pencounter++;
+                pencounter %= 4;
+                if (pencounter == 0) {
+                    pendown = !pendown;
+                }
+                break;
+            case '[':
+                // [ WHILE Begin a while loop
+                if (ip.get().isZero()) {
+                    // skip forwards to matching ]
+                    int depth = 0;
+                    for (;;) {
+                        if (program.charAt(pc) == '[') {
+                            depth++;
+                        } else if (program.charAt(pc) == ']') {
+                            depth--;
+                            if (depth == 0)
+                                break;
+                        }
+                        pc++;
+                        if (pc >= program.length()) {
+                            halted = true;
+                            return errors;
+                        }
+                    }
+                }
+                break;
+            case ']':
+                // ] END End a while loop
+                // skip backwards to matching ]
+                int depth = 0;
+                for (;;) {
+                    if (program.charAt(pc) == '[') {
+                        depth--;
+                    } else if (program.charAt(pc) == ']') {
+                        depth++;
+                    }
+                    pc--;
+                    if (depth == 0 || pc < 0)
+                        break;
+                }
+                break;
+            default:
+                // NOP
+                break;
+        }
+
+        pc++;
+        if (pc >= program.length()) {
+            halted = true;
+        }
+
+        return errors;
+    }
+
+    public Playfield getPlayfield(int index) {
+        if (index == 0)
+            return pf;
+        return null;
+    }
+
+    public Tape getTape(int index) {
+        return null;
+    }
+
+    public String getProgramText() {
+        return program;
+    }
+
+    public int getProgramPosition() {
+        return pc;
+    }
+
+    public List<Error> setProgramText(String text) {
+        ArrayList<Error> errors = new ArrayList<Error>();
+        program = text;
+        return errors;
+    }
+
+    public View getPlayfieldView(int index) {
+        if (index == 0)
+            return pfView;
+        return null;
+    }
+
+    public View getTapeView(int index) {
+        return null;
+    }
+
+    public String exportToText() {
+        return program;
+    }
+
+    public boolean hasHalted() {
+        return halted;
+    }
+
+    public boolean needsInput() {
+        return false;
+    }
+
+    public void setOption(String name, boolean value) {
+    }
+}
+*/
+
+
+/*
+ * requires yoob.Controller
+ * requires yoob.Playfield
+ * requires yoob.Cursor
+ */
+function EtchaPlayfield() {
+    this.setDefault(' ');
+
+    this.increment = function(x, y) {
+        var data = this.get(x, y);
+        if (data === ' ') {
+            data = '#';
+        } else if (data === '#') {
+            data = '@';
+        } else if (data === '@') {
+            data = ' ';
+        }
+        this.put(x, y, data);
+    };
+
+    this.decrement = function(x, y) {
+        var data = this.get(x, y);
+        if (data === ' ') {
+            data = '@';
+        } else if (data === '@') {
+            data = '#';
+        } else if (data === '#') {
+            data = ' ';
+        }
+        this.put(x, y, data);
+    };
+};
+EtchaPlayfield.prototype = new yoob.Playfield();
+
+
+function EtchaController() {
+    var intervalId;
+    var canvas;
+    var ctx;
+
+    var p;
+    var ip;
+    var dp;
+
+    this.init = function(c) {
+        p = new EtchaPlayfield();
+
+        ip = new yoob.Cursor(0, 0, 1, 1);
+        ip.drawContext = function(ctx, x, y, cellWidth, cellHeight) {
+            ctx.fillStyle = "#ff5080";
+            ctx.fillRect(x, y, cellWidth, cellHeight);
+        };
+
+        dp = new yoob.Cursor(0, 0, 0, 0);
+        dp.drawContext = function(ctx, x, y, cellWidth, cellHeight) {
+            ctx.fillStyle = "#50ff80";
+            ctx.fillRect(x, y, cellWidth, cellHeight);
+        };
+        canvas = c;
+        ctx = canvas.getContext('2d');
+    };
+
+    this.draw = function() {
+        p.drawCanvas(canvas, undefined, 20, [ip, dp]);
+    };
+
+    this.step = function() {
+        var instr = p.get(ip.x, ip.y);
+
+        if (instr === '@') {
+            var data = p.get(dp.x, dp.y);
+            if (data === ' ') {
+                ip.rotateClockwise();
+            } else if (data == '#') {
+                ip.rotateCounterclockwise();
+            }
+        } else if (instr === '#') {
+            if (ip.isHeaded(0, -1)) {
+                dp.y--;
+                ip.advance();
+            } else if (ip.isHeaded(0, 1)) {
+                dp.y++;
+                ip.advance();
+            } else if (ip.isHeaded(1, 0)) {
+                dp.x++;
+                ip.advance();
+            } else if (ip.isHeaded(-1, 0)) {
+                dp.x--;
+                ip.advance();
+            } else if (ip.isHeaded(-1, -1) || ip.isHeaded(1, -1)) {
+                p.increment(dp.x, dp.y);
+            } else if (ip.isHeaded(-1, 1) || ip.isHeaded(1, 1)) {
+                p.decrement(dp.x, dp.y);
+            }
+        }
+
+        ip.advance();
+        this.draw();
+    };
+
+    this.load = function(text) {
+        p.clear();
+        p.load(0, 0, text);
+        p.foreach(function (x, y, value) {
+            if (value === '$') {
+                ip.x = x;
+                ip.y = y;
+                return ' ';
+            } else if (value === '%') {
+                dp.x = x;
+                dp.y = y;
+                return ' ';
+            }
+        });
+        ip.dx = 1;
+        ip.dy = 1;
+        this.draw();
+    };
+};
+EtchaController.prototype = new yoob.Controller();

File impl/etcha.js/src/yoob/controller.js

+/*
+ * This file is part of yoob.js version 0.3
+ * Available from https://github.com/catseye/yoob.js/
+ * This file is in the public domain.  See http://unlicense.org/ for details.
+ */
+if (window.yoob === undefined) yoob = {};
+
+/*
+ * A controller for executing(/animating/evolving) states
+ * (such as esolang program states or cellular automaton
+ * configurations.)
+ *
+ * Can be connected to a UI in the DOM.
+ *
+ * Subclass this and override the following methods:
+ * - make it evolve the state by one tick in the step() method
+ * - make it load the state from a multiline string in the load() method
+ */
+yoob.Controller = function() {
+    this.intervalId = undefined;
+    this.delay = 100;
+    this.source = undefined;
+    this.speed = undefined;
+    this.controls = {};
+
+    this.makeEventHandler = function(control, key) {
+        if (this['click_' + key] !== undefined) {
+            key = 'click_' + key;
+        }
+        var self = this;
+        return function(e) {
+          self[key](control); 
+        };
+    };
+
+    /*
+     * Single argument is a dictionary (object) where the keys
+     * are the actions a controller can undertake, and the values
+     * are either DOM elements or strings; if strings, DOM elements
+     * with those ids will be obtained from the document and used.
+     */
+    this.connect = function(dict) {
+        var self = this;
+        var keys = ["start", "stop", "step", "load", "edit", "select"];
+        for (var i in keys) {
+            var key = keys[i];
+            var value = dict[key];
+            if (typeof value === 'string') {
+                value = document.getElementById(value);
+            }
+            if (value !== undefined) {
+                if (key === 'select') {
+                    value.onchange = this.makeEventHandler(value, key);
+                } else {
+                    value.onclick = this.makeEventHandler(value, key);
+                }
+                this.controls[key] = value;
+            }
+        }
+
+        var keys = ["source", "display"];
+        for (var i in keys) {
+            var key = keys[i];
+            var value = dict[key];
+            if (typeof value === 'string') {
+                value = document.getElementById(value);
+            }
+            if (value !== undefined) {
+                this[key] = value;
+            }
+        }
+
+        var speed = dict.speed;
+        if (typeof speed === 'string') {
+            speed = document.getElementById(speed);
+        }
+        if (speed !== undefined) {
+            this.speed = speed;
+            speed.value = self.delay;
+            speed.onchange = function(e) {
+                self.delay = speed.value;
+                if (self.intervalId !== undefined) {
+                    self.stop();
+                    self.start();
+                }
+            }
+        }        
+    };
+
+    this.click_step = function(e) {
+        this.stop();
+        this.step();
+    };
+
+    this.step = function() {
+        alert("step() NotImplementedError");
+    };
+
+    this.click_load = function(e) {
+        this.stop();
+        this.load(this.source.value);
+        if (this.controls.edit) this.controls.edit.style.display = "inline";
+        if (this.controls.load) this.controls.load.style.display = "none";
+        if (this.controls.start) this.controls.start.disabled = false;
+        if (this.controls.step) this.controls.step.disabled = false;
+        if (this.controls.stop) this.controls.stop.disabled = false;
+        if (this.display) this.display.style.display = "block";
+        if (this.source) this.source.style.display = "none";
+    };
+
+    this.load = function(text) {
+        alert("load() NotImplementedError");
+    };
+
+    this.click_edit = function(e) {
+        this.stop();
+        if (this.controls.edit) this.controls.edit.style.display = "none";
+        if (this.controls.load) this.controls.load.style.display = "inline";
+        if (this.controls.start) this.controls.start.disabled = true;
+        if (this.controls.step) this.controls.step.disabled = true;
+        if (this.controls.stop) this.controls.stop.disabled = true;
+        if (this.display) this.display.style.display = "none";
+        if (this.source) this.source.style.display = "block";
+    };
+
+    this.click_select = function(control) {
+        this.stop();
+        var source = document.getElementById(
+          control.options[control.selectedIndex].value
+        );
+        var text = source.innerHTML;
+        text = text.replace(/\&lt;/g, '<');
+        text = text.replace(/\&gt;/g, '>');
+        text = text.replace(/\&amp;/g, '&');
+        if (this.source) this.source.value = text;
+        this.load(text);
+    };
+
+    this.start = function() {
+        if (this.intervalId !== undefined)
+            return;
+        this.step();
+        var self = this;
+        this.intervalId = setInterval(function() { self.step(); }, this.delay);
+    };
+
+    this.stop = function() {
+        if (this.intervalId === undefined)
+            return;
+        clearInterval(this.intervalId);
+        this.intervalId = undefined;
+    };
+};

File impl/etcha.js/src/yoob/cursor.js

+/*
+ * This file is part of yoob.js version 0.3
+ * Available from https://github.com/catseye/yoob.js/
+ * This file is in the public domain.  See http://unlicense.org/ for details.
+ */
+if (window.yoob === undefined) yoob = {};
+
+/*
+ * An object representing a pointer (position vector) into two-dimensional
+ * Cartesian space (possibly a yoob.Playfield) with a direction vector
+ * (that need not be used).
+ */
+yoob.Cursor = function(x, y, dx, dy) {
+    this.x = x;
+    this.y = y;
+    this.dx = dx;
+    this.dy = dy;
+
+    this.isHeaded = function(dx, dy) {
+        return this.dx === dx && this.dy === dy;
+    };
+
+    this.advance = function() {
+        this.x += this.dx;
+        this.y += this.dy;
+    };
+
+    this.rotateClockwise = function() {
+        if (this.dx === 0 && this.dy === -1) {
+            this.dx = 1; this.dy = -1;
+        } else if (this.dx === 1 && this.dy === -1) {
+            this.dx = 1; this.dy = 0;
+        } else if (this.dx === 1 && this.dy === 0) {
+            this.dx = 1; this.dy = 1;
+        } else if (this.dx === 1 && this.dy === 1) {
+            this.dx = 0; this.dy = 1;
+        } else if (this.dx === 0 && this.dy === 1) {
+            this.dx = -1; this.dy = 1;
+        } else if (this.dx === -1 && this.dy === 1) {
+            this.dx = -1; this.dy = 0;
+        } else if (this.dx === -1 && this.dy === 0) {
+            this.dx = -1; this.dy = -1;
+        } else if (this.dx === -1 && this.dy === -1) {
+            this.dx = 0; this.dy = -1;
+        }
+    };
+
+    this.rotateCounterclockwise = function() {
+        if (this.dx === 0 && this.dy === -1) {
+            this.dx = -1; this.dy = -1;
+        } else if (this.dx === -1 && this.dy === -1) {
+            this.dx = -1; this.dy = 0;
+        } else if (this.dx === -1 && this.dy === 0) {
+            this.dx = -1; this.dy = 1;
+        } else if (this.dx === -1 && this.dy === 1) {
+            this.dx = 0; this.dy = 1;
+        } else if (this.dx === 0 && this.dy === 1) {
+            this.dx = 1; this.dy = 1;
+        } else if (this.dx === 1 && this.dy === 1) {
+            this.dx = 1; this.dy = 0;
+        } else if (this.dx === 1 && this.dy === 0) {
+            this.dx = 1; this.dy = -1;
+        } else if (this.dx === 1 && this.dy === -1) {
+            this.dx = 0; this.dy = -1;
+        }
+    };
+
+    this.drawContext = function(ctx, x, y, cellWidth, cellHeight) {
+        ctx.fillStyle = "#50ff50";
+        ctx.fillRect(x, y, cellWidth, cellHeight);
+    };
+}

File impl/etcha.js/src/yoob/playfield.js

+/*
+ * This file is part of yoob.js version 0.4
+ * Available from https://github.com/catseye/yoob.js/
+ * This file is in the public domain.  See http://unlicense.org/ for details.
+ */
+if (window.yoob === undefined) yoob = {};
+
+/*
+ * A two-dimensional Cartesian grid of values.
+ */
+yoob.Playfield = function() {
+    this._store = {};
+    this.minX = undefined;
+    this.minY = undefined;
+    this.maxX = undefined;
+    this.maxY = undefined;
+    this._default = undefined;
+
+    /*
+     * Set the default value for this Playfield.  This
+     * value is returned by get() for any cell that was
+     * never written to, or had `undefined` put() into it.
+     */
+    this.setDefault = function(v) {
+        this._default = v;
+        return this;
+    };
+
+    /*
+     * Obtain the value at (x, y).  The default value will
+     * be returned if the cell was never written to.
+     */
+    this.get = function(x, y) {
+        var v = this._store[x+','+y];
+        if (v === undefined) return this._default;
+        return v;
+    };
+
+    /*
+     * Write a new value into (x, y).  Note that writing
+     * `undefined` into a cell has the semantics of deleting
+     * the value at that cell; a subsequent get() for that
+     * location will return this Playfield's default value.
+     */
+    this.put = function(x, y, value) {
+        var key = x+','+y;
+        if (value === undefined || value === this._default) {
+            delete this._store[key];
+            return;
+        }
+        if (this.minX === undefined || x < this.minX) this.minX = x;
+        if (this.maxX === undefined || x > this.maxX) this.maxX = x;
+        if (this.minY === undefined || y < this.minY) this.minY = y;
+        if (this.maxY === undefined || y > this.maxY) this.maxY = y;
+        this._store[key] = value;
+    };
+
+    /*
+     * Like put(), but does not update the playfield bounds.  Do
+     * this if you must do a batch of put()s in a more efficient
+     * manner; after doing so, call recalculateBounds().
+     */
+    this.putDirty = function(x, y, value) {
+        var key = x+','+y;
+        if (value === undefined || value === this._default) {
+            delete this._store[key];
+            return;
+        }
+        this._store[key] = value;
+    };
+
+    /*
+     * Recalculate the bounds (min/max X/Y) which are tracked
+     * internally to support methods like foreach().  This is
+     * not needed *unless* you've used putDirty() at some point.
+     * (In which case, call this immediately after your batch
+     * of putDirty()s.)
+     */
+    this.recalculateBounds = function() {
+        this.minX = undefined;
+        this.minY = undefined;
+        this.maxX = undefined;
+        this.maxX = undefined;
+
+        for (var cell in this._store) {
+            var pos = cell.split(',');
+            var x = parseInt(pos[0], 10);
+            var y = parseInt(pos[1], 10);
+            if (this.minX === undefined || x < this.minX) this.minX = x;
+            if (this.maxX === undefined || x > this.maxX) this.maxX = x;
+            if (this.minY === undefined || y < this.minY) this.minY = y;
+            if (this.maxY === undefined || y > this.maxY) this.maxY = y;
+        }
+    };
+
+    /*
+     * Clear the contents of this Playfield.
+     */
+    this.clear = function() {
+        this._store = {};
+        this.minX = undefined;
+        this.minY = undefined;
+        this.maxX = undefined;
+        this.maxX = undefined;
+    };
+
+    /*
+     * Scroll a rectangular subrectangle of this Playfield, up.
+     * TODO: support other directions.
+     */
+    this.scrollRectangleY = function(dy, minX, minY, maxX, maxY) {
+        if (dy < 1) {
+            for (var y = minY; y <= (maxY + dy); y++) {
+                for (var x = minX; x <= maxX; x++) {
+                    this.put(x, y, this.get(x, y - dy));
+                }
+            }
+        } else { alert("scrollRectangleY(" + dy + ") notImplemented"); }
+    };
+
+    this.clearRectangle = function(minX, minY, maxX, maxY) {
+        // Could also do this with a foreach that checks
+        // each position.  Would be faster on sparser playfields.
+        for (var y = minY; y <= maxY; y++) {
+            for (var x = minX; x <= maxX; x++) {
+                this.put(x, y, undefined);
+            }
+        }
+    };
+
+    /*
+     * Load a string into this Playfield.
+     * The string may be multiline, with newline (ASCII 10)
+     * characters delimiting lines.  ASCII 13 is ignored.
+     *
+     * If transformer is given, it should be a one-argument
+     * function which accepts a character and returns the
+     * object you wish to write into the playfield upon reading
+     * that character.
+     */
+    this.load = function(x, y, string, transformer) {
+        var lx = x;
+        var ly = y;
+        if (transformer === undefined) {
+            transformer = function(c) {
+                if (c === ' ') {
+                    return undefined;
+                } else {
+                    return c;
+                }
+            }
+        }
+        for (var i = 0; i < string.length; i++) {
+            var c = string.charAt(i);
+            if (c === '\n') {
+                lx = x;
+                ly++;
+            } else if (c === '\r') {
+            } else {
+                this.putDirty(lx, ly, transformer(c));
+                lx++;
+            }
+        }
+        this.recalculateBounds();
+    };
+
+    /*
+     * Convert this Playfield to a multi-line string.  Each row
+     * is a line, delimited with a newline (ASCII 10).
+     *
+     * If transformer is given, it should be a one-argument
+     * function which accepts a playfield element and returns a
+     * character (or string) you wish to place in the resulting
+     * string for that element.
+     */
+    this.dump = function(transformer) {
+        var text = "";
+        if (transformer === undefined) {
+            transformer = function(c) { return c; }
+        }
+        for (var y = this.minY; y <= this.maxY; y++) {
+            var row = "";
+            for (var x = this.minX; x <= this.maxX; x++) {
+                row += transformer(this.get(x, y));
+            }
+            text += row + "\n";
+        }
+        return text;
+    };
+
+    /*
+     * Iterate over every defined cell in the Playfield.
+     * fun is a callback which takes three parameters:
+     * x, y, and value.  If this callback returns a value,
+     * it is written into the Playfield at that position.
+     * This function ensures a particular order.
+     */
+    this.foreach = function(fun) {
+        for (var y = this.minY; y <= this.maxY; y++) {
+            for (var x = this.minX; x <= this.maxX; x++) {
+                var key = x+','+y;
+                var value = this._store[key];
+                if (value === undefined)
+                    continue;
+                var result = fun(x, y, value);
+                if (result !== undefined) {
+                    if (result === ' ') {
+                        result = undefined;
+                    }
+                    this.put(x, y, result);
+                }
+            }
+        }
+    };
+
+    /*
+     * Analogous to (monoid) map in functional languages,
+     * iterate over this Playfield, transform each value using
+     * a supplied function, and write the transformed value into
+     * a destination Playfield.
+     *
+     * Supplied function should take a Playfield (this Playfield),
+     * x, and y, and return a value.
+     *
+     * The map source may extend beyond the internal bounds of
+     * the Playfield, by giving the min/max Dx/Dy arguments
+     * (which work like margin offsets.)
+     *
+     * Useful for evolving a cellular automaton playfield.  In this
+     * case, min/max Dx/Dy should be computed from the neighbourhood.
+     */
+    this.map = function(destPf, fun, minDx, minDy, maxDx, maxDy) {
+        if (minDx === undefined) minDx = 0;
+        if (minDy === undefined) minDy = 0;
+        if (maxDx === undefined) maxDx = 0;
+        if (maxDy === undefined) maxDy = 0;
+        for (var y = this.minY + minDy; y <= this.maxY + maxDy; y++) {
+            for (var x = this.minX + minDx; x <= this.maxX + maxDx; x++) {
+                destPf.putDirty(x, y, fun(pf, x, y));
+            }
+        }
+        destPf.recalculateBounds();
+    };
+
+    /*
+     * Accessors for the minimum (resp. maximum) x (resp. y) values of
+     * occupied (non-default-valued) cells in this Playfield.  If there are
+     * no cells in this Playfield, these will refturn undefined.  Note that
+     * these are not guaranteed to be tight bounds; if values in cells
+     * are deleted, these bounds may still be considered to be outside them.
+     */
+    this.getMinX = function() {
+        return this.minX;
+    };
+    this.getMaxX = function() {
+        return this.maxX;
+    };
+    this.getMinY = function() {
+        return this.minY;
+    };
+    this.getMaxY = function() {
+        return this.maxY;
+    };
+
+    /*
+     * Returns the number of occupied cells in the x direction.
+     */
+    this.getExtentX = function() {
+        if (this.maxX === undefined || this.minX === undefined) {
+            return 0;
+        } else {
+            return this.maxX - this.minX + 1;
+        }
+    };
+
+    /*
+     * Returns the number of occupied cells in the y direction.
+     */
+    this.getExtentY = function() {
+        if (this.maxY === undefined || this.minY === undefined) {
+            return 0;
+        } else {
+            return this.maxY - this.minY + 1;
+        }
+    };
+};