Public
Edited
Sep 21, 2023
10 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
function initializeRotaGame({
playWithComputer,
size = 640,
margin = 20,
strokeWidth = 2,
slotRadius = 30
} = {}) {
const rotaMachine = xs.createMachine(
{
...machineSpec,
context: {
playingWithComputer: playWithComputer,
player: undefined,
computersPiece: undefined,
pieceIndex: undefined,
positions: nextPositions(),
won: false
}
},
{
actions: {
resetPieces: xs.assign(({ context, event }) => {
return {
player: undefined,
pieceIndex: undefined,
positions: nextPositions(),
won: false
};
}),
setPlayer: xs.assign(({ context, event }) => {
const { playingWithComputer } = context;
const { piece } = event;

return {
player: piece,
computersPiece: piece === "a" ? "b" : "a"
};
}),
setNextPlayer: xs.assign(({ context }) => {
const { player } = context;
return {
player: player === "a" ? "b" : "a"
};
}),
setPiece: xs.assign(({ context, event }) => {
const { index } = event;
return {
pieceIndex: index
};
}),
movePiece: xs.assign(({ context, event }) => {
const { pieceIndex, positions } = context;
const { index } = event;

return {
positions: nextPositions(positions, pieceIndex, index)
};
}),
moveComputersPiece: xs.assign(({ context }) => {
const { positions, computersPiece } = context;

const [src, dest] = findComputersMove(positions, computersPiece);

return {
positions: nextPositions(positions, src, dest)
};
}),
setWinner: xs.assign(({ context }) => {
return {
player: findWinner(context.positions),
won: true
};
})
},
guards: {
checkWin: ({ context }) => findWinner(context.positions),
playersPiece: ({ context, event }) => {
const { player } = context;
const { piece } = event;

return piece === player;
},
isComputersTurn: ({ context }) => {
const { playingWithComputer, player, computersPiece } = context;
return playingWithComputer && player === computersPiece;
}
}
}
);
const actor = xs.createActor(rotaMachine);

const maxRadius = (size - 2 * margin - 2 * slotRadius) / 2;
const radius = maxRadius - strokeWidth / 2;

const root = d3
.create("svg")
.attr("width", size)
.attr("height", size)
.attr("viewBox", [-size / 2, -size / 2, size, size])
.attr("class", "rota-game-board");

// Symbols
const pieceRadius = slotRadius;
const pieceSymbols = root
.selectAll(".piece-symbol")
.data(["a", "b", "target"])
.join("symbol")
.attr("class", "piece-symbol")
.attr("id", (d) => `piece-${d}`)
.attr("viewBox", [
-pieceRadius,
-pieceRadius,
2 * pieceRadius,
2 * pieceRadius
]);

pieceSymbols.each(function (d) {
const el = d3.select(this);
let fill = palette[d] ?? "#ff0";
const fillAccent = d3.color(fill).brighter(1.25);

const bgRadius = pieceRadius - 2 * strokeWidth;
const bg = el.append("circle").attr("r", bgRadius).attr("fill", fill);

if (d === "a") {
el.append("circle")
.attr("r", bgRadius / 3)
.attr("fill", fillAccent);
} else if (d === "b") {
const rectSize = 0.8 * (2 / 3) * bgRadius;
el.append("rect")
.attr("width", rectSize)
.attr("height", rectSize)
.attr("x", -rectSize / 2)
.attr("y", -rectSize / 2)
.attr("fill", fillAccent)
.attr("transform", "rotate(45)");
} else if (d === "target") {
// bg.attr("r", 0.8 * bgRadius);
}
});

// Background
root
.append("rect")
.attr("width", size)
.attr("height", size)
.attr("x", -size / 2)
.attr("y", -size / 2)
.attr("fill", palette.bg);

const svg = root.append("g").attr("class", "canvas");
const board = svg.append("g").attr("class", "board");
const pieces = svg.append("g").attr("class", "pieces");
const legend = svg.append("g").attr("class", "legend");

const numOfOuterSlots = 8;
const slotAngles = d3
.range(numOfOuterSlots)
.map((d) => (2 * Math.PI * d) / numOfOuterSlots);

const lines = 4;
const lineAngles = d3.range(lines).map((d) => (Math.PI * d) / lines);

// Inner slot
board.append("circle").attr("r", slotRadius).attr("fill", palette.fg);

// Outline
board
.append("circle")
.attr("r", radius)
.attr("fill", "none")
.attr("stroke", palette.fg)
.attr("stroke-width", 3 * strokeWidth);

// Inner lines
board
.selectAll(".inner-line")
.data(lineAngles)
.join("line")
.attr("class", "inner-line")
.attr("x1", -radius)
.attr("x2", radius)
.attr("stroke", palette.fg)
.attr("stroke-width", strokeWidth * 3)
.attr("transform", (d) => `rotate(${radians2degree(d)})`);

const outerSlots = board
.selectAll(".outer-slot")
.data(slotAngles)
.join("g")
.attr("class", "outer-slot");

// Outer slots
outerSlots
.append("circle")
.attr("cx", (d) => radius * Math.cos(d))
.attr("cy", (d) => radius * Math.sin(d))
.attr("r", slotRadius)
.attr("fill", palette.fg);

// Outline - notch
board
.append("circle")
.attr("r", radius)
.attr("fill", "none")
.attr("stroke", palette.bg)
.attr("stroke-width", strokeWidth);

// Outer slots - notch
outerSlots
.append("circle")
.attr("cx", (d) => radius * Math.cos(d))
.attr("cy", (d) => radius * Math.sin(d))
.attr("r", slotRadius - strokeWidth)
.attr("fill", palette.bg);

board
.selectAll(".inner-line-notch")
.data(lineAngles)
.join("line")
.attr("class", "inner-line-notch")
.attr("x1", -radius)
.attr("x2", radius)
.attr("stroke", palette.bg)
.attr("stroke-width", strokeWidth)
.attr("transform", (d) => `rotate(${radians2degree(d)})`);

// Inner slot - notch
board
.append("circle")
.attr("r", slotRadius - strokeWidth)
.attr("fill", palette.bg);

// Legend
const legendPieceRadius = pieceRadius / 2;
const legendPieceX = -(maxRadius + 2 * legendPieceRadius);
const legendTextX = legendPieceX + legendPieceRadius * 2.25;
const legendTextXOnlyText = legendPieceX;
const legendPiece = legend
.append("use")
.attr("class", "piece")
.attr("width", 2 * legendPieceRadius)
.attr("height", 2 * legendPieceRadius)
.attr("x", legendPieceX)
.attr("y", maxRadius);
// .attr("href", `#piece-target`);
const legendText = legend
.append("text")
.attr("class", "legend-text")
.attr("x", legendTextX)
.attr("y", maxRadius + legendPieceRadius)
.attr("dominant-baseline", "central")
.attr("fill", palette.text)
.attr("font-size", "18px")
.attr("font-weight", "600");
// .text("hello");

function updateBoard(context, showTargets) {
const { pieceIndex, positions } = context;
const positionsFlat = (
showTargets ? addTargetPositions(positions, pieceIndex) : positions
).flat();

const angles = [
[(Math.PI / 4) * 5, (Math.PI / 4) * 6, (Math.PI / 4) * 7], // row 1
[(Math.PI / 4) * 4, undefined, 0], // row 2
[(Math.PI / 4) * 3, (Math.PI / 4) * 2, Math.PI / 4] // row 3
].flat();

pieces
.selectAll(".piece")
.data(angles)
.join("use")
.attr("class", "piece")
.attr("width", 2 * pieceRadius)
.attr("height", 2 * pieceRadius)
.each(function (angle, i) {
const piece = d3.select(this);
let x = 0;
let y = 0;
let type = positionsFlat[i];

if (angle !== undefined) {
x = radius * Math.cos(angle);
y = radius * Math.sin(angle);
}

piece
.attr("x", x - pieceRadius)
.attr("y", y - pieceRadius)
.attr("data-position-index", i)
.attr("data-piece-type", type)
.attr("href", type ? `#piece-${type}` : null);
});
}

function updateSVGAttributes(context) {
const { player, won } = context;
root.attr("data-player", player);
root.attr("data-won", won);
}

function updateLegend(state, context) {
const { player } = context;

if (state === "start") {
legendText.text("Pick a piece to start").attr("x", legendTextXOnlyText);
} else if (state === "pieces") {
legendText.text("Plays").attr("x", legendTextX);
} else if (state === "targets") {
legendText.text("Pick a position to move").attr("x", legendTextX);
} else if (state === "won") {
legendText.text("Wins!").attr("x", legendTextX);
}

if (state !== "start") {
legendText.attr("x", legendTextX);
}

legendPiece.attr("href", `#piece-${player}`);
}

actor.subscribe((s) => {
const { context, value } = s;
console.log(`Rota Machine: ${JSON.stringify(value)}`, s.context);

updateBoard(context, value === "targets");
updateSVGAttributes(context);
updateLegend(value, context);
});
actor.start();

root.on(
"click",
function (e) {
for (
var target = e.target;
target && target !== this;
target = target.parentNode
) {
if (target.matches(".pieces .piece")) {
const pIndex = target?.dataset.positionIndex;
const pType = target?.dataset.pieceType;
if (["a", "b"].includes(pType)) {
actor.send({ type: "PICK_PIECE", index: pIndex, piece: pType });
} else if (["target"].includes(pType)) {
actor.send({ type: "PICK_TARGET", index: pIndex });
}
// break;
return;
}
}
actor.send({ type: "CANCEL" });
},
false
);

return Object.assign(root.node(), { actor });
}
Insert cell
nextPositions(
[
["a", "b", "a"],
["b", "a", undefined],
[undefined, undefined, "b"]
],
4,
7
)
Insert cell
function nextPositions(currentPositions, srcIndex, destIndex) {
const starting = [
["a", undefined, "b"],
["a", undefined, "b"],
["a", undefined, "b"]
];

if (currentPositions === undefined) {
return starting;
}

const positions = _.cloneDeep(currentPositions);

const src = indexToCoords(srcIndex, 3);
const dest = indexToCoords(destIndex, 3);

if (
positions[dest.y][dest.x] === undefined ||
positions[dest.y][dest.x] === "target"
) {
positions[dest.y][dest.x] = positions[src.y][src.x];
positions[src.y][src.x] = undefined;
}

return positions;
}
Insert cell
function addTargetPositions(positions, index, targetValue = "target") {
const { x, y } = indexToCoords(index, 3);

// Not sure if this is necessary
const nextPositions = _.cloneDeep(positions);

const nextIndices = validNextPositions(positions, index);

nextIndices.forEach((index) => {
const { x, y } = indexToCoords(index, 3);
nextPositions[y][x] = targetValue;
});

return nextPositions;
}
Insert cell
validNextPositions(
[
["a", undefined, "b"],
["a", "a", "b"],
[undefined, undefined, "b"]
],
4
)
Insert cell
function validNextPositions(positions, index) {
const { x, y } = indexToCoords(index, 3);
const nextPositionIndices = [];

// Center
if ((x !== 1 || y !== 1) && positions[1][1] === undefined) {
nextPositionIndices.push(coordsToIndex(1, 1, 3));
}

if (x === 1 && y === 1) {
// Diagonals targets, if current is center
// Top Left
if (positions[0][0] === undefined) {
nextPositionIndices.push(coordsToIndex(0, 0, 3));
}
if (positions[0][2] === undefined) {
nextPositionIndices.push(coordsToIndex(2, 0, 3));
}
if (positions[2][0] === undefined) {
nextPositionIndices.push(coordsToIndex(0, 2, 3));
}
if (positions[2][2] === undefined) {
nextPositionIndices.push(coordsToIndex(2, 2, 3));
}
}

// Top
const xt = x;
const yt = y - 1;
if (yt >= 0 && positions[yt][xt] === undefined) {
nextPositionIndices.push(coordsToIndex(xt, yt, 3));
}

// Right
const xr = x + 1;
const yr = y;
if (xr <= 2 && positions[yr][xr] === undefined) {
nextPositionIndices.push(coordsToIndex(xr, yr, 3));
}

// Bottom
const xb = x;
const yb = y + 1;
if (yb <= 2 && positions[yb][xb] === undefined) {
nextPositionIndices.push(coordsToIndex(xb, yb, 3));
}

// Left
const xl = x - 1;
const yl = y;
if (xl >= 0 && positions[yl][xl] === undefined) {
nextPositionIndices.push(coordsToIndex(xl, yl, 3));
}

return _.uniq(nextPositionIndices);
}
Insert cell
findWinner([
["a", "a", "a"],
[undefined, undefined, undefined],
["b", "b", "b"]
])
Insert cell
findWinner([
["a", undefined, "b"],
[undefined, "a", undefined],
["b", "b", "a"]
])
Insert cell
function findWinner(positions, size = 3) {
let lines = Array.from({ length: 4 }).map(() => []);
for (let j = 0; j < size; j++) {
for (let i = 0; i < size; i++) {
if (i === j) {
// Diagonal
lines[0].push(positions[j][i]);
}
if (i === size - 1 - j) {
// Diagonal 2
lines[1].push(positions[j][i]);
}
if (j === Math.floor(size / 2)) {
// Middle Row
lines[2].push(positions[j][i]);
}
if (i === Math.floor(size / 2)) {
// Middle Column
lines[3].push(positions[j][i]);
}
}
}

lines = lines.map((l) => l.filter(Boolean));

lines = lines.flatMap((l) => d3.groups(l, (d) => d));

const key = lines.find(([_, arr]) => arr.length === size);
return key !== undefined ? key[0] : key;
}
Insert cell
getCurrentPiecePositions(nextPositions(), 'a')
Insert cell
getCurrentPiecePositions(nextPositions(), "b")
Insert cell
function getCurrentPiecePositions(positions, piece) {
return positions
.flat()
.map((p, index) => (p === piece ? index : undefined))
.filter((d) => d !== undefined);
}
Insert cell
getPossibleMovesFor("a", nextPositions())
Insert cell
function getPossibleMovesFor(piece, positions) {
const indices = getCurrentPiecePositions(positions, piece);
return indices
.map((index) => {
const targets = validNextPositions(positions, index);
return targets.map((t) => [index, t]);
})
.flat();
}
Insert cell
findComputersMove(nextPositions(), "a")
Insert cell
function findComputersMove(positions, computersPiece) {
const opponentsPiece = computersPiece === "a" ? "b" : "a";
const possibleMoves = getPossibleMovesFor(computersPiece, positions);

// 1. If next move can win, play that move
const winnableMoves = possibleMoves.filter((move) =>
isTheMoveWinnableFor(computersPiece, positions, move)
);

// console.log("winnableMoves", winnableMoves);
if (winnableMoves.length) {
return random.pick(winnableMoves);
}

// 2. If any of the next moves of opponent wins, block
const possibleOpponentMoves = getPossibleMovesFor(opponentsPiece, positions);
const opponentWinnableMoves = possibleOpponentMoves.filter((move) =>
isTheMoveWinnableFor(opponentsPiece, positions, move)
);

const blockingMoves = possibleMoves.filter(([_, dest]) =>
opponentWinnableMoves.find(([_, oppDest]) => dest === oppDest)
);

// console.log("blockingMoves", blockingMoves);
if (blockingMoves.length) {
return random.pick(blockingMoves);
}

// 3. If move to center, play
const centreMoves = possibleMoves.filter(([_, dest]) => dest === 4);
if (centreMoves.length) {
return random.pick(centreMoves);
}

// 4. Make a random move, with lower weightage for move from centre
const weightedSet = possibleMoves.map((move) => ({
value: move,
// Lower weight for moves from the centre
weight:
move[0] === 4 ? 0.125 / possibleMoves.length : 1 / possibleMoves.length
}));
console.log(weightedSet);

// 5. Play a random move
return random.weightedSet(weightedSet);
}
Insert cell
isTheMoveWinnableFor(
"a",
[
["a", undefined, "b"],
[undefined, "a", "a"],
["b", "b", undefined]
],
[5, 8]
)
Insert cell
isTheMoveWinnableFor(
"a",
[
["a", undefined, "b"],
[undefined, "a", "a"],
["b", "b", undefined]
],
[4, 1]
)
Insert cell
function isTheMoveWinnableFor(piece, positions, move) {
const [src, dest] = move;
const next = nextPositions(positions, src, dest);
const winner = findWinner(next);
return piece === winner;
}
Insert cell
machineSpec = ({
context: {
lastPlayedBy: ""
},
id: "Roto",
initial: "start",
states: {
start: {
entry: {
type: "resetPieces",
params: {}
},
on: {
PICK_PIECE: {
target: "targets",
actions: [
{
type: "setPlayer",
params: {}
},
{
type: "setPiece",
params: {}
}
]
}
}
},
targets: {
on: {
CANCEL: {
target: "pieces"
},
PICK_TARGET: {
target: "pieces",
actions: [
{
type: "movePiece",
params: {}
},
{
type: "setNextPlayer",
params: {}
}
]
},
PICK_PIECE: {
target: "targets",
guard: "playersPiece",
actions: {
type: "setPiece",
params: {}
}
}
}
},
pieces: {
always: [
{
target: "won",
guard: "checkWin",
actions: {
type: "setWinner"
},
reenter: false
},
{
target: "pieces",
guard: "isComputersTurn",
actions: [
{
type: "moveComputersPiece"
},
{
type: "setNextPlayer"
}
],
reenter: false
}
],
on: {
PICK_PIECE: {
target: "targets",
guard: "playersPiece",
actions: {
type: "setPiece",
params: {}
}
}
}
},
won: {}
},
on: {
RESTART: {
target: ".start"
}
}
})
Insert cell
palette = ({
fg: "hsl(0, 0%, 70%)",
text: "hsl(0, 0%, 35%)",
bg: "hsl(0, 0%, 95%)",
a: "hsl(215, 47%, 48%)",
b: "hsl(6, 68%, 51%)",
target: "hsl(153, 40%, 41%)"
})
Insert cell
coordsToIndex(1, 1, 3)
Insert cell
function coordsToIndex(x, y, size) {
if (size === undefined) {
throw new Error("`size` is not provided");
}

return y * size + x;
}
Insert cell
function indexToCoords(index, size) {
if (size === undefined) {
throw new Error("`size` is not provided");
}

const x = index % size;
const y = Math.floor(index / size);
return { x, y };
}
Insert cell
indexToCoords(4, 3)
Insert cell
function radians2degree(radians) {
return (radians * 180) / Math.PI;
}
Insert cell
<style>
:root {
--rota-border-radius: 0.5rem;
}
.rota-game-board {
border-radius: var(--rota-border-radius);
}

[data-piece-type] {
cursor: pointer;
}
[data-player=a] [data-piece-type=b],
[data-player=b] [data-piece-type=a]{
opacity: 0.5;
cursor: not-allowed;
}

[data-won="true"][data-player=a] [data-piece-type=b],
[data-won="true"][data-player=b] [data-piece-type=a] {
opacity: 0;
pointer-events: none;
}

.rules {
box-sizing: border-box;
background-color: hsl(37, 52%, 94%);
padding: 0.5rem 1rem;
border-radius: var(--rota-border-radius);
max-width: 640px;
}

.rules summary {
font-weight: bold;
}
</style>
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more