var DIAGONAL_2 = 4; var DIAGONAL_1 = 3; var HORIZONTAL = 2; var VERTICAL = 1; var NONE = 0; // TODO when game is over disable moving of blocks // setup var game = new Game(); // TODO replace by user actions game.addHumanPlayer('Suzan'); game.addHumanPlayer('Kim'); function Game() { var minCol = minRow = 0; var maxCol = maxRow = 4; var players = (function() { var data = []; var current = 0; return { add: function(player) { data.push(player); }, next: function() { current = (current + 1) % data.length; }, current: function() { return data[current]; }, all: function() { return data; } } })(); var symbols = (function() { var CROSS = 'M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z'; var CIRCLE = 'M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z'; var current = -1; var symbols = [CROSS, CIRCLE]; return { next: function() { return symbols.pop(); } } })(); var board = new Board('#board', moveEnded, gameOver); function addHumanPlayer(name) { var player = new Player(name, symbols.next()); players.add(player); return player; } function start() { board.start(players); } function moveEnded() { players.next(); } function gameOver(winningLines) { console.log('game over'); } function Player(name, symbol) { function canSelectBlock(d) { return (d.row == maxRow || d.row == minRow || d.col == maxCol || d.col == minCol) && (d.symbol == -1 || d.symbol == symbol); } function hasSymbol(d) { return d.symbol == symbol; } return { name: (function() { return name; })(), symbol: (function() { return symbol; })(), canSelectBlock: canSelectBlock, hasSymbol: hasSymbol }; } function Board(selector, endMoveListener, gameOverListener) { var svg, players, blocks, menu, movingBlock, data; var width = 500, height = 500, statusHeight = 100, statusMarginTop = 50, padding = 10, blockCount = 5, blockSize = width / blockCount - padding, rounding = 20, innerSymbolPadding = 10; init(); function init() { svg = d3.selectAll(selector).append('svg') .attr('width', width) .attr('height', height + statusHeight + statusMarginTop); initBoard(); initMenu(); function initBoard() { data = d3.range(blockCount * blockCount).map(function(d,i) { var column = i % blockCount; var row = Math.floor(i / blockCount); return { col: column, row: row, symbol: -1 }; }); blocks = svg .selectAll('.block') .data(data) .enter() .append('g') .attr('class', 'block') .attr('transform', translateBlock); blocks .append('rect') .attr('rx', rounding) .attr('ry', rounding) .attr('width', blockSize) .attr('height', blockSize); movingBlock = svg.append('g') .attr('class', 'moving-block'); movingBlock.append('rect') .attr('width', blockSize) .attr('height', blockSize) .attr('rx', rounding) .attr('ry', rounding); } function initMenu() { var menuMargin = blockSize + 1.5 * padding; menu = svg.append('g') .classed('menu', true) .attr('transform', 'translate(' + menuMargin + ',' + menuMargin + ')'); var menuWidth = width - menuMargin * 2; var menuHeight = height - menuMargin * 2; menu.append('rect') .attr('rx', rounding) .attr('ry', rounding) .attr('width', menuWidth) .attr('height', menuHeight); var buttonMargin = 2 * padding; var buttonWidth = 100; var buttonHeight = 50; var menuButton = menu.append('g') .classed('button', true) .attr('transform', 'translate(' + (menuWidth - buttonWidth - buttonMargin) + ',' + (menuHeight - buttonHeight - buttonMargin) + ')'); menuButton.append('rect') .attr('rx', rounding) .attr('ry', rounding) .attr('width', buttonWidth) .attr('height', buttonHeight); menuButton.append('text') .attr('x', buttonWidth / 2) .attr('y', buttonHeight / 2 + 10) .text('start') .on('click', start); } } function hideMenu() { // TODO animation menu.remove(); } function translateBlock(d) { var xPos = d.col * (blockSize + padding) + padding / 2; var yPos = d.row * (blockSize + padding) + padding / 2; return 'translate(' + xPos + ',' + yPos + ')'; } function startGame(playerList) { hideMenu(); initPlayers(playerList); refresh(playerList); } function refresh(playersList) { var hPath, vPath, gameOver, winningLines = []; var currentPlayer = playersList.current(); players.classed('active-player', currentPlayer.hasSymbol); blocks.classed('selectable', currentPlayer.canSelectBlock); var drag = d3.behavior.drag() .origin(function(d) { return d; }) .on('dragstart', dragstart) .on('drag', dragmove) .on('dragend', dragend); svg.selectAll('.selectable') .on('mousedown', showPath) .call(drag); function dragstart(d) { movingBlock.classed('selected', true) .attr('transform', function() { return translateBlock(d); }); movingBlock.selectAll(".symbol").remove(); appendSymbol(movingBlock, currentPlayer.symbol); } function dragmove(d) { var onBlock = closestBlock(d, d3.mouse(this)); var xTransform = onBlock.x - blockSize / 2 + padding / 2; var yTransform = onBlock.y - blockSize / 2 + padding / 2; movingBlock.attr('transform', 'translate(' + xTransform + ',' + yTransform + ')'); var direction = determineDirection(d, onBlock); var attr = direction == HORIZONTAL ? 'row' : 'col'; var oppositeAttr = direction == HORIZONTAL ? 'col' : 'row'; svg.selectAll('.path') .attr('transform', function(innerD) { var xPos = innerD.col * (blockSize + padding) + padding / 2; var yPos = innerD.row * (blockSize + padding) + padding / 2; var yTransform = direction == VERTICAL ? (blockSize + padding) : 0; var xTransform = direction == HORIZONTAL ? (blockSize + padding) : 0; var factor = innerD[oppositeAttr] > d[oppositeAttr] && innerD[oppositeAttr] <= onBlock[oppositeAttr] ? -1 : (innerD[oppositeAttr] < d[oppositeAttr] && innerD[oppositeAttr] >= onBlock[oppositeAttr]) ? 1 : 0; innerD.moved = factor * direction; d3.select(this).classed('moved', factor != 0); return ('translate(' + (xPos + xTransform * factor) + ',' + (yPos + yTransform * factor) + ')'); }); } function dragend(d) { var onBlock = closestBlock(d, d3.mouse(this)); var endpoint = getIfEndPoint(onBlock); if (endpoint && !(d.col == onBlock.col && d.row == onBlock.row)) { d.col = onBlock.col; d.row = onBlock.row; keepNewPositions(); if (gameOver) { gameOverListener(winningLines); } else { endMoveListener(); } } clearStates(); refresh(playersList); } function determineDirection(original, current) { return original.row == current.row && !(original.col == current.col) ? HORIZONTAL : original.col == current.col && !(original.row == current.row) ? VERTICAL : NONE } function keepNewPositions() { svg.selectAll('.moved') .each(function(d) { if (d.moved > NONE) { if (d.moved == HORIZONTAL) { d.col ++; } else if (d.moved == VERTICAL) { d.row ++; } } else if (d.moved < NONE) { if (Math.abs(d.moved) == HORIZONTAL) { d.col --; } else if (Math.abs(d.moved) == VERTICAL) { d.row --; } } d.moved = NONE; }) .each(function(d) { checkGameOver(d); }); svg.selectAll('.block.selected') .each(function(d) { if (d && d.symbol == -1) { d.symbol = currentPlayer.symbol; appendSymbol(d3.select(this), d.symbol); checkGameOver(d); } }); gameOver = winningLines.length > 0; } function checkGameOver(d) { if (d.symbol == -1) { return false; } if (d.col == d.row && areAllTheSame(getUpLeftDiagonalBlocks())) { addWinningLine(DIAGONAL_1, d.symbol); } if (d.col + d.row == maxCol && areAllTheSame(getUpRightDiagonalBlocks())) { addWinningLine(DIAGONAL_2, d.symbol); } if (Math.abs(d.moved) == HORIZONTAL && areAllTheSame(getColumnBlocks(d.col))) { addWinningLine(VERTICAL, d.symbol, d.col); } else if (Math.abs(d.moved) == VERTICAL && areAllTheSame(getRowBlocks(d.row))) { addWinningLine(HORIZONTAL, d.symbol, d.row); } else if (d.moved == NONE) { if (areAllTheSame(getColumnBlocks(d.col))) { addWinningLine(VERTICAL, d.symbol, d.col); } if (areAllTheSame(getRowBlocks(d.row))) { addWinningLine(HORIZONTAL, d.symbol, d.row); } } function areAllTheSame(lineBlocks) { var symbol = lineBlocks[0].symbol; var result = lineBlocks.filter(function(d) { return d.symbol != symbol; }); return result.length == 0; } function addWinningLine(type, symbol, index) { winningLines.push({ type: type, index: index, symbol: symbol }); } function getUpLeftDiagonalBlocks() { return data.filter(function(d) { return d.col == d.row; }); } function getUpRightDiagonalBlocks() { return data.filter(function(d) { return d.col + d.row == maxCol; }); } function getColumnBlocks(column) { return data.filter(function(d) { return d.col == column; }); } function getRowBlocks(row) { return data.filter(function(d) { return d.row == row; }); } } // TODO show feedback on (failed) moves function clearStates() { blocks.on('mousedown', null) .on('mousedown.drag', null); blocks.classed('not-selected', false); svg.selectAll('.block.selected').attr('transform', translateBlock); blocks.classed('selected', false); movingBlock.classed('selected', false); blocks.classed('path', false); blocks.classed('endpoint', false); svg.selectAll('.block.moved').attr('transform', translateBlock); blocks.classed('moved', false); if (gameOver) { showWinningLines(); showWinningPlayer(); } } function showWinningLines() { blocks.classed('winning-line', function(d) { return isWinningLine(d.row, d.col);; }); } function showWinningPlayer() { players.classed('winning-player', function(d) { return isWinningPlayer(d.symbol); }); } function isWinningLine(row, col) { var winningLine = false; winningLines.forEach(function(d) { if ((d.type == DIAGONAL_1 && row == col) || (d.type == DIAGONAL_2 && row + col == maxCol) || (d.type == HORIZONTAL && row == d.index) || (d.type == VERTICAL && col == d.index)) { winningLine = true; } }); return winningLine; } function isWinningPlayer(symbol) { if (winningLines.length == 1) { return symbol == winningLines[0].symbol; } return false; } function getIfEndPoint(onBlock) { var endpoints = svg.selectAll('.endpoint') .filter(function(d,i) { return d.row == onBlock.row && d.col == onBlock.col; })[0]; return endpoints.length == 1 ? endpoints[0] : false; } function closestBlock(d, mouse) { var coordinates = [d.col * (blockSize + padding) + mouse[0], d.row * (blockSize + padding) + mouse[1]]; var vp = closestPoint(vPath.node(), coordinates); var hp = closestPoint(hPath.node(), coordinates); var p = vp.distance < hp.distance ? vp : hp; var row = Math.min(maxRow, Math.floor((p[1]) / (blockSize + padding))); var col = Math.min(maxCol, Math.floor((p[0]) / (blockSize + padding))); return { x: p[0], y: p[1], row: row, col: col }; function closestPoint(pathNode, point) { var pathLength = pathNode.getTotalLength(), precision = pathLength / pathNode.pathSegList.numberOfItems * .125, best, bestLength, bestDistance = Infinity; // linear scan for coarse approximation for (var scan, scanLength = 0, scanDistance; scanLength <= pathLength; scanLength += precision) { if ((scanDistance = distance2(scan = pathNode.getPointAtLength(scanLength))) < bestDistance) { best = scan, bestLength = scanLength, bestDistance = scanDistance; } } // binary search for precise estimate precision *= .5; while (precision > .5) { var before, after, beforeLength, afterLength, beforeDistance, afterDistance; if ((beforeLength = bestLength - precision) >= 0 && (beforeDistance = distance2(before = pathNode.getPointAtLength(beforeLength))) < bestDistance) { best = before, bestLength = beforeLength, bestDistance = beforeDistance; } else if ((afterLength = bestLength + precision) <= pathLength && (afterDistance = distance2(after = pathNode.getPointAtLength(afterLength))) < bestDistance) { best = after, bestLength = afterLength, bestDistance = afterDistance; } else { precision *= .5; } } best = [best.x, best.y]; best.distance = Math.sqrt(bestDistance); return best; function distance2(p) { var dx = p.x - point[0], dy = p.y - point[1]; return dx * dx + dy * dy; } } } function appendSymbol(block, symbol) { block .append('svg') .classed('symbol', true) .attr('x', innerSymbolPadding) .attr('y', innerSymbolPadding) .attr('width', blockSize - 2 * innerSymbolPadding) .attr('height', blockSize - 2 * innerSymbolPadding) .attr('viewBox', '0 0 24 24') .append('path') .attr('d', symbol); } function showPath(d) { blocks.classed('path', isOnPath); blocks.classed('endpoint', isEndPoint); blocks.classed('not-selected', true); d3.select(this).classed('selected', true); var line = d3.svg.line(); hPath = svg.append('path') .datum([ [blockSize / 2, d.row * (blockSize + padding) + (blockSize) / 2], [4.5 * (blockSize + padding) - padding / 2, d.row * (blockSize + padding) + (blockSize) / 2] ]) .attr('d', line); vPath = svg.append('path') .datum([ [d.col * (blockSize + padding) + blockSize / 2, blockSize / 2], [d.col * (blockSize + padding) + blockSize / 2, 4.5 * (blockSize + padding) - padding / 2] ]) .attr('d', line); function isOnPath(innerD) { return innerD.col == d.col || innerD.row == d.row; } function isEndPoint(innerD, i) { return (innerD.col == d.col && (innerD.row == maxRow || innerD.row == minRow)) || (innerD.row == d.row && (innerD.col == maxCol || innerD.col == minCol)); } } } function end() { } function initPlayers(playersData) { var playersList = playersData.all(); players = svg.selectAll('.player') .data(playersList) .enter() .append('g') .attr('class', 'player') .attr('transform', function(d,i) { var xPos = padding / 2 + (width / playersList.length) * i + padding * i; var yPos = height + statusMarginTop; return 'translate(' + xPos + ',' + yPos + ')'; }); players.append('rect') .attr('width', function(d, i) { return width / playersList.length - 2 *padding; }) .attr('height', statusHeight - padding / 2) .attr('rx', rounding) .attr('ry', rounding); players.append('text') .attr('x', blockSize) .attr('y', (statusHeight - padding / 2) / 2 + innerSymbolPadding) .text(function(d) { return d.name; }); players.append('svg') .classed('symbol', true) .attr('x', innerSymbolPadding) .attr('y', innerSymbolPadding) .attr('width', blockSize - 2 * innerSymbolPadding) .attr('height', blockSize - 2 * innerSymbolPadding) .attr('viewBox', '0 0 24 24') .append('path') .attr('d', function(d) { return d.symbol; }); } return { start: startGame, refresh: refresh, end: end }; } return { addHumanPlayer: addHumanPlayer, start: start }; }