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.
Last active
August 4, 2016 19:47
-
-
Save ppham27/da743212b0969e6a30824203cd4daef7 to your computer and use it in GitHub Desktop.
Nim: The Game
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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