Skip to content

Instantly share code, notes, and snippets.

@ppham27
Last active August 4, 2016 19:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ppham27/da743212b0969e6a30824203cd4daef7 to your computer and use it in GitHub Desktop.
Save ppham27/da743212b0969e6a30824203cd4daef7 to your computer and use it in GitHub Desktop.
Nim: The Game

I wrote this game primarily to supplement my blog post Sequential Game of Perfect Information: Nim and More. However, it was a good way to learn some of the nuances of D3 v4. In particular, some of the nuances with the brush were tricky to work out. I thought that it ended up being a great example of how one can use the enter and exit methods in D3 to reflect changes in the UI, too.

<!DOCTYPE html>
<html>
<head>
<title>Nim: The Game</title>
<meta charset="utf-8">
<link rel="stylesheet" type="text/css" href="style.css">
<script src="https://d3js.org/d3.v4.min.js"></script>
</head>
<body>
<div id="canvas"></div>
<script src="nim.js"></script>
</body>
</html>
class NimModel {
constructor(numHeaps, maxHeapSize) {
this.numHeaps = numHeaps;
this.maxHeapSize = maxHeapSize;
this.currentPlayer = 0;
this.makeHeaps(this.numHeaps, this.maxHeapSize);
this.isActive = true;
}
makeHeaps(numHeaps, maxHeapSize) {
this.heaps = new Array();
for (var i = 0; i < this.numHeaps; ++i) {
this.heaps.push(new Array());
var heapSize = Math.round(0.5 + Math.random()*this.maxHeapSize);
for (var j = 0; j < heapSize; ++j) {
this.heaps[i].push(j);
}
}
}
reset() {
this.currentPlayer = 0;
this.makeHeaps(this.numHeaps, this.maxHeapSize);
this.isActive = true;
}
move(selection) {
if (this.isActive && selection) {
this.heaps[selection.y].splice(selection.x[0], selection.x[1] - selection.x[0]);
this.isActive = !this.heaps.every(function(heap) {
return heap.length === 0;
});
if (this.isActive) {
++this.currentPlayer; this.currentPlayer %= 2;
return {status: 'next_move', state: this.currentPlayer }
}
} else if (this.isActive) {
return {status: 'error', message: 'Error: selection is empty!'};
}
if (!this.isActive) {
return {status: 'game_over', state: this.currentPlayer };
}
}
}
class NimView {
constructor(mountNode, width, height) {
this.mountNode = d3.select(mountNode).append('svg');
this.width = width; this.height = height;
this.currentSelection = null;
this.mountNode
.attr('width', this.width)
.attr('height', this.height);
this.x = d3.scaleBand().padding(0.15).range([0, this.width]);
this.y = d3.scaleBand().range([0, this.height]);
}
initialize(nimModel) {
var self = this;
this.nimModel = nimModel;
this.mountNode.selectAll('*').remove();
// data-driven domains
var maxHeapSize = d3.max(this.nimModel.heaps, function(heap) { return d3.max(heap); }) + 1;
var xDomain = new Array();
for (var i = 0; i < maxHeapSize; ++i) xDomain.push(i);
this.x.domain(xDomain);
var yDomain = new Array();
for (var i = 0; i < this.nimModel.numHeaps; ++i) yDomain.push(i);
this.y.domain(yDomain);
var x = this.x;
var y = this.y;
var heaps = this.nimModel.heaps;
var heapGroups = new Array();
var heapBrushes = new Array();
var brushIsActive = false;
this.mountNode.selectAll('g.heap')
.data(yDomain).enter()
.append('g').attr('class', 'heap')
.attr('width', this.width)
.attr('height', this.y.bandwidth())
.attr('transform', function(d) {
return 'translate(0,' + y(d) + ')';
}).each(function(d, i) {
heapGroups.push(d3.select(this));
var heapBrush = d3.brushX()
.extent([[0, 0], [self.width, y.bandwidth()]])
.on('end', function(d) {
if (brushIsActive) return;
brushIsActive = true;
for (var i = 0; i < heapGroups.length; ++i) {
if (i != d) {
heapBrushes[i].move(heapGroups[i], null);
}
}
if (d3.event.selection) {
var l = -1, r = -1;
for (var i = 0; i < heaps[d].length; ++i) {
if (l == -1 && x(i) >= d3.event.selection[0]) l = i;
if (l != -1 && x(i) + x.bandwidth() <= d3.event.selection[1]) r = i + 1;
}
// selected range is [l,r)
if (l != -1 && r != -1 && l < r) {
heapBrushes[d].move(heapGroups[d], [x(l) - x.padding()*x.step(), x(r-1) + x.bandwidth() + x.padding()*x.step()]);
self.currentSelection = {x: [l, r], y: d};
} else {
heapBrushes[d].move(heapGroups[d], null);
self.currentSelection = null;
}
} else {
self.currentSelection = null;
}
brushIsActive = false;
});
heapBrushes.push(heapBrush);
});
this.heapGroups = heapGroups;
this.heapBrushes = heapBrushes;
}
render() {
var x = this.x;
var y = this.y;
var data = new Array();
for (var i = 0; i < y.domain().length; ++i) {
var matchSelection = this.heapGroups[i].selectAll('rect.match')
.data(this.nimModel.heaps[i], function(d) { return d; });
// match selection exit
matchSelection.exit().style('opacity', 1)
.transition().duration(1000)
.style('opacity', 0)
.remove();
// update
matchSelection
.transition().duration(1000)
.attr('x', function(d, idx) { return x(idx); })
// enter
matchSelection
.enter()
.append('rect')
.attr('x', function(d, idx) { return x(idx); })
.attr('y', 5)
.attr('width', x.bandwidth())
.attr('height', y.bandwidth()-10)
.attr('class', 'match')
.style('opacity', 0)
.transition().duration(1000)
.style('opacity', 1);
this.heapGroups[i].call(this.heapBrushes[i]);
}
}
clearSelection() {
for (var i = 0; i < this.y.domain().length; ++i) {
this.heapBrushes[i].move(this.heapGroups[i], null);
}
this.currentSelection = null;
}
}
class NimController {
constructor(nimModel, nimView, mountNode, width, height) {
this.mountNode = d3.select(mountNode).append('div')
.attr('id', 'nim-controller')
.style('display', 'inline-block')
.style('width', width + 'px')
.style('height', height + 'px');
this.mountNode.append('h1')
.text("Nim");
this.mountNode.append('p')
.html("This is the classic game of <a target=\"_blank\" href=\"https://en.wikipedia.org/wiki/Nim\">Nim</a>. Two players take turns removing objects from heaps, which are rows in this case. In my version, the player that makes the last move wins. Or to think from a different perspective, the player that cannot make another move loses.");
this.mountNode.append('p')
.html("To play, highlight the objects that you would like to remove, and click move.");
var controls = this.mountNode.append('div').style('text-align', 'center');
var status = controls.append("h2")
.attr("id", "status")
.html("It's player " + (nimModel.currentPlayer + 1) + "'s move!");
controls.append("button").text("Move")
.attr("type", "button")
.on('click', function() {
var moveStatus = nimModel.move(nimView.currentSelection);
nimView.clearSelection();
nimView.render();
if (moveStatus.status === 'error') {
status.classed('error', true);
status.html(moveStatus.message);
} else {
status.classed('error', false);
}
if (moveStatus.status === 'next_move') {
status.html("It's player " + (moveStatus.state + 1) + "'s move!");
}
if (moveStatus.status === 'game_over') {
status.classed('success', true);
status.html("Player " + (moveStatus.state + 1) + " has won!");
this.disabled = true;
var moveButton = this;
controls.append("button")
.attr("type", "button")
.text("Play again")
.on('click', function() {
nimModel.reset();
nimView.initialize(nimModel);
nimView.render();
d3.select(this).remove();
moveButton.disabled = false;
status.html("It's player " + (nimModel.currentPlayer + 1) + "'s move!");
status.classed("success", false);
});
}
});
controls.append('br');
}
}
var nimModel = new NimModel(5, 30);
var nimView = new NimView(document.getElementById('canvas'), 660, 500);
nimView.initialize(nimModel);
nimView.render();
var nimController = new NimController(nimModel, nimView,
document.getElementById('canvas'), 290, 500);
body {
font-family: Helvetica Neue,Helvetica,Arial,sans-serif;
}
#canvas {
width: 960px;
height: 500px;
}
#nim-controller {
margin-left: 10px;
}
svg {
float: left;
}
rect.match {
fill: #fbb4ae;
stroke: #377eb8;
rx: 10;
ry: 10;
}
.heap rect.selection {
fill: #4daf4a;
}
.error {
color: #e41a1c;
}
.success {
color: #4daf4a;
}
button {
display: block;
margin: auto;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment