<li>
<a href="scribe">Scribe</a>
</li>
+ <li>
+ <a href="loa">Lines of Action</a>
+ </li>
</ul>
</div>
--- /dev/null
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8"/>
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+
+ <title>Lines of Action</title>
+
+ <link rel="stylesheet" href="/reset.css" type="text/css" />
+ <link rel="stylesheet" href="/style.css" type="text/css" />
+
+ <script src="/lmno.js"></script>
+ </head>
+ <body>
+
+ <div id="page">
+
+ <h1>Lines of Action</h1>
+
+ <p>
+ Connect all of your pieces into a single group. Move a piece
+ in a straight line (row, column, or diagonal) exactly as many
+ squares as there are pieces (of either color) along that line.
+ You may jump your own pieces but not your opponent's, and you
+ may capture by landing on an opponent's piece.
+ </p>
+
+ <div id="message-area">
+ </div>
+
+ <form onsubmit="lmno_new('loa'); return false;">
+ <button type="submit">
+ Host a new game
+ </button>
+ </form>
+
+ <form onsubmit="lmno_join(this); return false">
+
+ <div class="form-field large">
+ <label for="id">Or join an existing game:</label>
+ <input type="text" id="id" maxlength="4"
+ placeholder="Enter a 4-letter Game Code"
+ oninput="this.value = this.value.toUpperCase()"
+ autocomplete="off"
+ required>
+ </div>
+ </form>
+
+ </div>
+
+ </body>
+</html>
--- /dev/null
+/* Lines of Action - shared game logic (server and client)
+ *
+ * In the browser this file is loaded via <script> so all symbols
+ * become globals. In Node the conditional module.exports at the
+ * bottom makes them available to require().
+ */
+
+const BOARD_SIZE = 8;
+const DIAG_ARRAY_SIZE = 2 * BOARD_SIZE - 1;
+
+const CELL_EMPTY = 2;
+const CELL_BLACK = 0;
+const CELL_WHITE = 1;
+
+const PLAYER_BLACK = 0;
+const PLAYER_WHITE = 1;
+
+class LoaBoard {
+ constructor() {
+ this.cells = [];
+ this.numPieces = [0, 0];
+ this.rowPieces = new Array(BOARD_SIZE).fill(0);
+ this.colPieces = new Array(BOARD_SIZE).fill(0);
+ this.diagGravePieces = new Array(DIAG_ARRAY_SIZE).fill(0);
+ this.diagAcutePieces = new Array(DIAG_ARRAY_SIZE).fill(0);
+ this.player = PLAYER_BLACK;
+ this.init();
+ }
+
+ init() {
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ this.cells[x] = [];
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ this.cells[x][y] = CELL_EMPTY;
+ }
+ }
+ this.numPieces[0] = 0;
+ this.numPieces[1] = 0;
+ this.rowPieces.fill(0);
+ this.colPieces.fill(0);
+ this.diagGravePieces.fill(0);
+ this.diagAcutePieces.fill(0);
+
+ for (let i = 1; i < BOARD_SIZE - 1; i++) {
+ this._addPiece(i, 0, CELL_BLACK);
+ this._addPiece(i, BOARD_SIZE - 1, CELL_BLACK);
+ this._addPiece(0, i, CELL_WHITE);
+ this._addPiece(BOARD_SIZE - 1, i, CELL_WHITE);
+ }
+
+ this.player = PLAYER_BLACK;
+ }
+
+ _graveIndex(x, y) { return x - y + BOARD_SIZE - 1; }
+ _acuteIndex(x, y) { return x + y; }
+
+ _addPiece(x, y, cell) {
+ this.colPieces[x]++;
+ this.rowPieces[y]++;
+ this.diagGravePieces[this._graveIndex(x, y)]++;
+ this.diagAcutePieces[this._acuteIndex(x, y)]++;
+ this.numPieces[cell]++;
+ this.cells[x][y] = cell;
+ }
+
+ _removePiece(x, y) {
+ const cell = this.cells[x][y];
+ if (cell === CELL_EMPTY) return CELL_EMPTY;
+ this.colPieces[x]--;
+ this.rowPieces[y]--;
+ this.diagGravePieces[this._graveIndex(x, y)]--;
+ this.diagAcutePieces[this._acuteIndex(x, y)]--;
+ this.numPieces[cell]--;
+ this.cells[x][y] = CELL_EMPTY;
+ return cell;
+ }
+
+ _groupSize(x, y) {
+ const cell = this.cells[x][y];
+ if (cell === CELL_EMPTY) return 0;
+ const visited = new Set();
+ return this._groupSizeRecursive(x, y, cell, visited);
+ }
+
+ _groupSizeRecursive(x, y, cell, visited) {
+ if (x < 0 || y < 0 || x >= BOARD_SIZE || y >= BOARD_SIZE) return 0;
+ const key = x * BOARD_SIZE + y;
+ if (visited.has(key)) return 0;
+ visited.add(key);
+ if (this.cells[x][y] !== cell) return 0;
+ let count = 1;
+ for (let dx = -1; dx <= 1; dx++) {
+ for (let dy = -1; dy <= 1; dy++) {
+ if (dx === 0 && dy === 0) continue;
+ count += this._groupSizeRecursive(x + dx, y + dy, cell, visited);
+ }
+ }
+ return count;
+ }
+
+ isWon(x, y) {
+ const cell = this.cells[x][y];
+ if (cell === CELL_EMPTY) return false;
+ return this._groupSize(x, y) === this.numPieces[cell];
+ }
+
+ /* Check for a winner after a move to (x2, y2).
+ *
+ * In LOA a capture can connect the opponent's remaining pieces,
+ * so we must check both players. If both are connected (rare),
+ * the player who just moved wins.
+ *
+ * Returns the winning player constant (PLAYER_BLACK / PLAYER_WHITE)
+ * or null if no winner yet.
+ */
+ checkWinner(x2, y2) {
+ /* The mover's piece is at (x2, y2). board.player has already
+ * been toggled, so the mover is the *other* player. */
+ const mover = this.player === PLAYER_BLACK ? PLAYER_WHITE : PLAYER_BLACK;
+ const opponent = this.player;
+
+ /* Did the mover win? */
+ if (this.isWon(x2, y2))
+ return mover;
+
+ /* Did the capture connect all of the opponent's pieces? */
+ if (this.numPieces[opponent] > 0) {
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ if (this.cells[x][y] === opponent) {
+ if (this.isWon(x, y))
+ return opponent;
+ /* Only need to check one opponent piece --
+ * if it's not in a group equal to numPieces
+ * then no opponent piece is. */
+ return null;
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ moveLegal(x1, y1, x2, y2) {
+ if (x1 < 0 || y1 < 0 || x1 >= BOARD_SIZE || y1 >= BOARD_SIZE ||
+ x2 < 0 || y2 < 0 || x2 >= BOARD_SIZE || y2 >= BOARD_SIZE) {
+ return { legal: false, error: "Invalid coordinates (not on board)" };
+ }
+ if (this.cells[x1][y1] === CELL_EMPTY) {
+ return { legal: false, error: "There is no piece there to move" };
+ }
+ if (this.cells[x1][y1] !== this.player) {
+ return { legal: false, error: "You cannot move your opponent's piece" };
+ }
+ if (this.cells[x2][y2] === this.cells[x1][y1]) {
+ return { legal: false, error: "You cannot capture your own piece" };
+ }
+
+ const dx = x2 - x1;
+ const dy = y2 - y1;
+
+ if (dx === 0) {
+ if (Math.abs(dy) !== this.colPieces[x1]) {
+ return { legal: false, error: "The move distance does not match the number of pieces in that column" };
+ }
+ } else if (dy === 0) {
+ if (Math.abs(dx) !== this.rowPieces[y1]) {
+ return { legal: false, error: "The move distance does not match the number of pieces in that row" };
+ }
+ } else {
+ if (Math.abs(dx) !== Math.abs(dy)) {
+ return { legal: false, error: "That move is not in a row, column, or diagonal" };
+ }
+ if ((dx > 0) === (dy > 0)) {
+ if (Math.abs(dx) !== this.diagGravePieces[this._graveIndex(x1, y1)]) {
+ return { legal: false, error: "The move distance does not match the number of pieces in that diagonal" };
+ }
+ } else {
+ if (Math.abs(dx) !== this.diagAcutePieces[this._acuteIndex(x1, y1)]) {
+ return { legal: false, error: "The move distance does not match the number of pieces in that diagonal" };
+ }
+ }
+ }
+
+ const stepX = dx ? (dx < 0 ? -1 : 1) : 0;
+ const stepY = dy ? (dy < 0 ? -1 : 1) : 0;
+ let x = x1 + stepX, y = y1 + stepY;
+ while (x !== x2 || y !== y2) {
+ if (this.cells[x][y] !== CELL_EMPTY &&
+ this.cells[x][y] !== this.cells[x1][y1]) {
+ return { legal: false, error: "You cannot jump an opponent's piece" };
+ }
+ x += stepX;
+ y += stepY;
+ }
+
+ return { legal: true };
+ }
+
+ move(x1, y1, x2, y2) {
+ const result = this.moveLegal(x1, y1, x2, y2);
+ if (!result.legal) return result;
+
+ const captured = this.cells[x2][y2] !== CELL_EMPTY;
+ const cell = this._removePiece(x1, y1);
+ this._removePiece(x2, y2);
+ this._addPiece(x2, y2, cell);
+ this.player = this.player === PLAYER_BLACK ? PLAYER_WHITE : PLAYER_BLACK;
+
+ return { legal: true, captured };
+ }
+
+ toJSON() {
+ return {
+ cells: this.cells.map(col => [...col]),
+ numPieces: [...this.numPieces],
+ player: this.player
+ };
+ }
+
+ static fromJSON(data) {
+ const board = new LoaBoard();
+ /* Clear the default init state */
+ for (let x = 0; x < BOARD_SIZE; x++)
+ for (let y = 0; y < BOARD_SIZE; y++)
+ board._removePiece(x, y);
+ /* Rebuild from serialized cells */
+ for (let x = 0; x < BOARD_SIZE; x++)
+ for (let y = 0; y < BOARD_SIZE; y++)
+ if (data.cells[x][y] !== CELL_EMPTY)
+ board._addPiece(x, y, data.cells[x][y]);
+ board.player = data.player;
+ return board;
+ }
+}
+
+if (typeof module !== 'undefined' && module.exports) {
+ module.exports = {
+ LoaBoard, BOARD_SIZE, DIAG_ARRAY_SIZE,
+ CELL_EMPTY, CELL_BLACK, CELL_WHITE,
+ PLAYER_BLACK, PLAYER_WHITE
+ };
+}
--- /dev/null
+/* Lines of Action - client controller and SSE communication */
+
+(function () {
+
+let events = null;
+let board = null;
+let view = null;
+
+let gameInfo = {};
+let playerInfo = {};
+let otherPlayers = [];
+let gameOver = false;
+let animating = false;
+let winner = null;
+
+function init() {
+ board = new LoaBoard();
+ view = new LoaView(document.getElementById("loa"), {
+ onMove: handleMove
+ });
+ view.renderPieces(board);
+
+ events = new ReconnectingEventSource("events", {
+ onclose: () => add_message("danger", "Connection to server lost.")
+ });
+ setupEventHandlers();
+
+ updateStatus();
+}
+
+/* Map team name to cell value. */
+function teamToCell(teamName) {
+ if (teamName === "Black") return CELL_BLACK;
+ if (teamName === "White") return CELL_WHITE;
+ return null;
+}
+
+function myCell() {
+ return teamToCell(playerInfo.team);
+}
+
+function isMyTurn(state) {
+ if (!playerInfo.team) return false;
+ return state.team_to_play.name === playerInfo.team;
+}
+
+function isFirstMove() {
+ return !playerInfo.team && otherPlayers.every(p => !p.team);
+}
+
+function updateInteractive() {
+ const firstMove = isFirstMove();
+ const canMove = !gameOver && !animating &&
+ (firstMove || (playerInfo.team && board.player === myCell()));
+ view.setInteractive(canMove, firstMove ? board.player : myCell());
+}
+
+function updateStatus() {
+ const el = document.getElementById("loa");
+
+ /* Find or create the status bar. */
+ let status = document.getElementById("loa-status");
+ if (!status) {
+ status = document.createElement("div");
+ status.id = "loa-status";
+ el.parentNode.insertBefore(status, el);
+ }
+
+ let html = '';
+
+ if (gameOver && winner) {
+ const color = winner === "Black" ? "#222" : "#eee";
+ html = `<span class="turn-dot" style="background:${color}"></span> `;
+ html += `${winner} wins!`;
+ } else if (!playerInfo.team) {
+ /* Not yet on a team — first move assigns teams */
+ if (otherPlayers.length === 0) {
+ html = "You can move or wait for another player to join.";
+ } else {
+ html = "Either player can make the first move.";
+ }
+ } else {
+ const turnTeam = board.player === PLAYER_BLACK ? "Black" : "White";
+ const color = board.player === PLAYER_BLACK ? "#222" : "#eee";
+ html = `<span class="turn-dot" style="background:${color}"></span> `;
+ if (board.player === myCell()) {
+ html += `Your turn (${turnTeam})`;
+ } else {
+ html += `Waiting for ${turnTeam} to move...`;
+ }
+ }
+
+ status.innerHTML = html;
+ updateInteractive();
+}
+
+async function handleMove(x1, y1, x2, y2) {
+ if (gameOver || animating) return;
+
+ const body = { move: [x1, y1, x2, y2] };
+ if (isFirstMove()) {
+ body.assert_first_move = true;
+ }
+
+ try {
+ const response = await fetch("move", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body)
+ });
+ const result = await response.json();
+ if (!result.legal) {
+ add_message("warning", result.message);
+ }
+ } catch (err) {
+ add_message("danger", "Failed to send move.");
+ }
+}
+
+/* ── SSE event handlers ── */
+
+function setupEventHandlers() {
+
+events.addEventListener("game-info", (e) => {
+ gameInfo = JSON.parse(e.data);
+
+ const el = document.getElementById("game-info");
+ el.innerHTML =
+ `<span class="game-id">Game: <strong>${gameInfo.id}</strong></span>` +
+ ` — <span class="game-share">Share link: ` +
+ `<a href="${gameInfo.url}">${gameInfo.url}</a></span>`;
+});
+
+events.addEventListener("player-info", (e) => {
+ playerInfo = JSON.parse(e.data);
+ updateStatus();
+});
+
+events.addEventListener("player-enter", (e) => {
+ const p = JSON.parse(e.data);
+ const idx = otherPlayers.findIndex(op => op.id === p.id);
+ if (idx >= 0)
+ otherPlayers[idx] = p;
+ else
+ otherPlayers.push(p);
+ updateStatus();
+});
+
+events.addEventListener("player-update", (e) => {
+ const p = JSON.parse(e.data);
+ if (p.id === playerInfo.id) {
+ playerInfo = p;
+ } else {
+ const idx = otherPlayers.findIndex(op => op.id === p.id);
+ if (idx >= 0)
+ otherPlayers[idx] = p;
+ }
+ updateStatus();
+});
+
+events.addEventListener("player-exit", (e) => {
+ const p = JSON.parse(e.data);
+ otherPlayers = otherPlayers.filter(op => op.id !== p.id);
+});
+
+events.addEventListener("game-state", (e) => {
+ const state = JSON.parse(e.data);
+
+ board = LoaBoard.fromJSON(state.board);
+ gameOver = !!state.winner;
+ winner = state.winner;
+
+ view.renderPieces(board);
+
+ if (gameOver && winner) {
+ const winnerCell = teamToCell(winner);
+ view.showWin(winnerCell, board);
+ }
+
+ updateStatus();
+});
+
+events.addEventListener("move", (e) => {
+ const move = JSON.parse(e.data);
+ const [x1, y1, x2, y2] = move;
+
+ const result = board.move(x1, y1, x2, y2);
+ if (!result.legal) return; /* shouldn't happen */
+
+ animating = true;
+ updateInteractive();
+
+ view.animateMove(x1, y1, x2, y2, result.captured, () => {
+ animating = false;
+
+ const w = board.checkWinner(x2, y2);
+ if (w !== null) {
+ gameOver = true;
+ winner = w === PLAYER_BLACK ? "Black" : "White";
+ view.showWin(w, board);
+ }
+
+ updateStatus();
+ });
+});
+
+} /* setupEventHandlers */
+
+/* ── Start ── */
+init();
+
+})();
--- /dev/null
+/* Lines of Action - SVG rendering and interaction handling
+ *
+ * Supports click-to-select/click-to-move, mouse drag, and touch drag
+ * via unified pointer events.
+ */
+
+class LoaView {
+ constructor(container, callbacks) {
+ this.callbacks = callbacks; /* { onMove(x1,y1,x2,y2) } */
+ this.pieceElements = new Map(); /* "x,y" -> <g> element */
+ this.interactive = false;
+ this.currentPlayer = null; /* which color can be moved */
+
+ /* Selection state */
+ this.hasSelected = false;
+ this.selectedX = -1;
+ this.selectedY = -1;
+
+ /* Drag state: 'idle' | 'pending' | 'dragging' */
+ this.dragState = 'idle';
+ this.dragSourceX = -1;
+ this.dragSourceY = -1;
+ this.dragStartClientX = 0;
+ this.dragStartClientY = 0;
+ this.ghostPiece = null;
+
+ const NS = "http://www.w3.org/2000/svg";
+ this.svg = document.createElementNS(NS, "svg");
+ this.svg.setAttribute("viewBox", "-30 -30 860 860");
+ container.appendChild(this.svg);
+
+ /* Defs: gradients, filters */
+ const defs = this._el("defs");
+
+ const gBlack = this._el("radialGradient", { id: "grad-black", cx: "40%", cy: "35%", r: "55%" });
+ gBlack.appendChild(this._el("stop", { offset: "0%", "stop-color": "#555" }));
+ gBlack.appendChild(this._el("stop", { offset: "100%", "stop-color": "#111" }));
+ defs.appendChild(gBlack);
+
+ const gWhite = this._el("radialGradient", { id: "grad-white", cx: "40%", cy: "35%", r: "55%" });
+ gWhite.appendChild(this._el("stop", { offset: "0%", "stop-color": "#fff" }));
+ gWhite.appendChild(this._el("stop", { offset: "100%", "stop-color": "#ccc" }));
+ defs.appendChild(gWhite);
+
+ const shadow = this._el("filter", { id: "piece-shadow", x: "-20%", y: "-20%", width: "140%", height: "140%" });
+ shadow.appendChild(this._el("feDropShadow", { dx: "1.5", dy: "2", stdDeviation: "2.5", "flood-color": "#000", "flood-opacity": "0.35" }));
+ defs.appendChild(shadow);
+
+ const glow = this._el("filter", { id: "sel-glow", x: "-50%", y: "-50%", width: "200%", height: "200%" });
+ const blur = this._el("feGaussianBlur", { in: "SourceGraphic", stdDeviation: "4", result: "blur" });
+ glow.appendChild(blur);
+ const merge = this._el("feMerge");
+ merge.appendChild(this._el("feMergeNode", { in: "blur" }));
+ merge.appendChild(this._el("feMergeNode", { in: "SourceGraphic" }));
+ glow.appendChild(merge);
+ defs.appendChild(glow);
+
+ /* Lift shadow for dragged pieces */
+ const liftShadow = this._el("filter", { id: "drag-shadow", x: "-30%", y: "-30%", width: "160%", height: "160%" });
+ liftShadow.appendChild(this._el("feDropShadow", { dx: "3", dy: "5", stdDeviation: "5", "flood-color": "#000", "flood-opacity": "0.45" }));
+ defs.appendChild(liftShadow);
+
+ this.svg.appendChild(defs);
+
+ /* Layer groups */
+ this.squaresLayer = this._el("g");
+ this.piecesLayer = this._el("g");
+ this.selectionLayer = this._el("g");
+ this.dragLayer = this._el("g");
+ this.svg.appendChild(this.squaresLayer);
+ this.svg.appendChild(this.piecesLayer);
+ this.svg.appendChild(this.selectionLayer);
+ this.svg.appendChild(this.dragLayer);
+
+ this._drawBoard();
+ this._setupPointerEvents();
+ }
+
+ _el(tag, attrs) {
+ const el = document.createElementNS("http://www.w3.org/2000/svg", tag);
+ if (attrs) for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, v);
+ return el;
+ }
+
+ _drawBoard() {
+ const cols = "ABCDEFGH";
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ const rect = this._el("rect", {
+ x: x * 100, y: y * 100,
+ width: 100, height: 100,
+ fill: (x + y) % 2 === 0 ? "#e3b366" : "#420503"
+ });
+ this.squaresLayer.appendChild(rect);
+ }
+ }
+ for (let i = 0; i < BOARD_SIZE; i++) {
+ const colLabel = this._el("text", {
+ x: i * 100 + 50, y: 820,
+ "text-anchor": "middle", fill: "#d4b896",
+ "font-size": "18", "font-family": "sans-serif"
+ });
+ colLabel.textContent = cols[i];
+ this.squaresLayer.appendChild(colLabel);
+
+ const rowLabel = this._el("text", {
+ x: -12, y: i * 100 + 56,
+ "text-anchor": "middle", fill: "#d4b896",
+ "font-size": "18", "font-family": "sans-serif"
+ });
+ rowLabel.textContent = String(BOARD_SIZE - i);
+ this.squaresLayer.appendChild(rowLabel);
+ }
+ }
+
+ _svgCoords(e) {
+ const pt = this.svg.createSVGPoint();
+ pt.x = e.clientX;
+ pt.y = e.clientY;
+ return pt.matrixTransform(this.svg.getScreenCTM().inverse());
+ }
+
+ _boardCoords(svgPt) {
+ const x = Math.floor(svgPt.x / 100);
+ const y = Math.floor(svgPt.y / 100);
+ if (x < 0 || x >= BOARD_SIZE || y < 0 || y >= BOARD_SIZE)
+ return null;
+ return { x, y };
+ }
+
+ _isOwnPiece(x, y) {
+ if (this.currentPlayer === null) return false;
+ /* currentPlayer is the board cell value (CELL_BLACK or CELL_WHITE)
+ * that the local player controls. */
+ const key = `${x},${y}`;
+ /* We check the actual board state via the piece elements and
+ * the data attribute we set on them. */
+ const el = this.pieceElements.get(key);
+ return el && el.dataset.cell === String(this.currentPlayer);
+ }
+
+ _setupPointerEvents() {
+ this.svg.style.touchAction = "none";
+
+ this.svg.addEventListener("pointerdown", (e) => {
+ if (!this.interactive) return;
+ e.preventDefault();
+
+ const svgPt = this._svgCoords(e);
+ const bc = this._boardCoords(svgPt);
+ if (!bc) return;
+ const { x, y } = bc;
+
+ if (this.hasSelected) {
+ /* Clicking the selected piece deselects. */
+ if (x === this.selectedX && y === this.selectedY) {
+ this._deselect();
+ return;
+ }
+ /* Clicking another own piece switches selection. */
+ if (this._isOwnPiece(x, y)) {
+ this._select(x, y);
+ /* Start pending drag on the newly selected piece. */
+ this.dragState = 'pending';
+ this.dragSourceX = x;
+ this.dragSourceY = y;
+ this.dragStartClientX = e.clientX;
+ this.dragStartClientY = e.clientY;
+ this.svg.setPointerCapture(e.pointerId);
+ return;
+ }
+ /* Clicking empty/opponent square: attempt move. */
+ this._attemptMove(this.selectedX, this.selectedY, x, y);
+ return;
+ }
+
+ /* Nothing selected yet. Start interaction on own piece. */
+ if (this._isOwnPiece(x, y)) {
+ this.dragState = 'pending';
+ this.dragSourceX = x;
+ this.dragSourceY = y;
+ this.dragStartClientX = e.clientX;
+ this.dragStartClientY = e.clientY;
+ this.svg.setPointerCapture(e.pointerId);
+ }
+ });
+
+ this.svg.addEventListener("pointermove", (e) => {
+ if (this.dragState === 'idle') return;
+ e.preventDefault();
+
+ if (this.dragState === 'pending') {
+ const dx = e.clientX - this.dragStartClientX;
+ const dy = e.clientY - this.dragStartClientY;
+ if (dx * dx + dy * dy < 25) return; /* 5px threshold */
+ this._startDrag(this.dragSourceX, this.dragSourceY);
+ }
+
+ if (this.dragState === 'dragging') {
+ const svgPt = this._svgCoords(e);
+ this.ghostPiece.style.transform =
+ `translate(${svgPt.x}px, ${svgPt.y}px)`;
+ }
+ });
+
+ this.svg.addEventListener("pointerup", (e) => {
+ if (this.dragState === 'idle') return;
+ e.preventDefault();
+
+ if (this.dragState === 'dragging') {
+ this._endDrag(e);
+ } else if (this.dragState === 'pending') {
+ /* No drag happened — treat as a click to select. */
+ this._select(this.dragSourceX, this.dragSourceY);
+ }
+
+ this.dragState = 'idle';
+ try { this.svg.releasePointerCapture(e.pointerId); }
+ catch (_) { /* ignore if not captured */ }
+ });
+
+ this.svg.addEventListener("pointercancel", (e) => {
+ if (this.dragState === 'dragging') {
+ this._cleanupDrag();
+ }
+ this.dragState = 'idle';
+ });
+ }
+
+ _select(x, y) {
+ this.hasSelected = true;
+ this.selectedX = x;
+ this.selectedY = y;
+ this.showSelection(x, y);
+ }
+
+ _deselect() {
+ this.hasSelected = false;
+ this.clearSelection();
+ }
+
+ _attemptMove(x1, y1, x2, y2) {
+ this._deselect();
+ this.callbacks.onMove(x1, y1, x2, y2);
+ }
+
+ _startDrag(x, y) {
+ this.dragState = 'dragging';
+ this._select(x, y);
+
+ /* Dim the original piece. */
+ const key = `${x},${y}`;
+ const piece = this.pieceElements.get(key);
+ if (piece) piece.style.opacity = "0.35";
+
+ /* Create ghost piece in the drag layer. */
+ const cell = parseInt(piece.dataset.cell);
+ const isBlack = cell === CELL_BLACK;
+ const g = this._el("g", { class: "piece ghost" });
+ g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+
+ const circle = this._el("circle", {
+ r: 40,
+ fill: isBlack ? "url(#grad-black)" : "url(#grad-white)",
+ stroke: isBlack ? "#6b4a2a" : "#8b7355",
+ "stroke-width": "1.5",
+ filter: "url(#drag-shadow)"
+ });
+ g.appendChild(circle);
+ this.dragLayer.appendChild(g);
+ this.ghostPiece = g;
+ }
+
+ _endDrag(e) {
+ const svgPt = this._svgCoords(e);
+ const bc = this._boardCoords(svgPt);
+
+ this._cleanupDrag();
+
+ if (bc && (bc.x !== this.dragSourceX || bc.y !== this.dragSourceY)) {
+ this._attemptMove(this.dragSourceX, this.dragSourceY, bc.x, bc.y);
+ }
+ }
+
+ _cleanupDrag() {
+ /* Remove ghost. */
+ if (this.ghostPiece) {
+ this.ghostPiece.remove();
+ this.ghostPiece = null;
+ }
+ /* Restore original piece opacity. */
+ const key = `${this.dragSourceX},${this.dragSourceY}`;
+ const piece = this.pieceElements.get(key);
+ if (piece) piece.style.opacity = "";
+ }
+
+ /* Set which player color the local user controls and whether
+ * interaction is enabled. */
+ setInteractive(canMove, playerCell) {
+ this.interactive = canMove;
+ this.currentPlayer = playerCell;
+ }
+
+ renderPieces(board) {
+ const newKeys = new Set();
+
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ const cell = board.cells[x][y];
+ if (cell === CELL_EMPTY) continue;
+ const key = `${x},${y}`;
+ newKeys.add(key);
+ if (!this.pieceElements.has(key)) {
+ const g = this._createPiece(x, y, cell);
+ this.piecesLayer.appendChild(g);
+ this.pieceElements.set(key, g);
+ }
+ }
+ }
+
+ for (const [key, el] of this.pieceElements) {
+ if (!newKeys.has(key)) {
+ el.remove();
+ this.pieceElements.delete(key);
+ }
+ }
+ }
+
+ _createPiece(x, y, cell) {
+ const g = this._el("g", { class: "piece" });
+ g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+ g.dataset.cell = String(cell);
+
+ const isBlack = cell === CELL_BLACK;
+ const circle = this._el("circle", {
+ r: 40,
+ fill: isBlack ? "url(#grad-black)" : "url(#grad-white)",
+ stroke: isBlack ? "#6b4a2a" : "#8b7355",
+ "stroke-width": "1.5",
+ filter: "url(#piece-shadow)"
+ });
+ g.appendChild(circle);
+ return g;
+ }
+
+ animateMove(x1, y1, x2, y2, captured, callback) {
+ const fromKey = `${x1},${y1}`;
+ const toKey = `${x2},${y2}`;
+ const piece = this.pieceElements.get(fromKey);
+ if (!piece) { if (callback) callback(); return; }
+
+ if (captured) {
+ const target = this.pieceElements.get(toKey);
+ if (target) {
+ target.classList.add("captured");
+ setTimeout(() => target.remove(), 300);
+ this.pieceElements.delete(toKey);
+ }
+ }
+
+ this.pieceElements.delete(fromKey);
+ this.pieceElements.set(toKey, piece);
+
+ piece.style.transform = `translate(${x2 * 100 + 50}px, ${y2 * 100 + 50}px)`;
+
+ setTimeout(() => { if (callback) callback(); }, 310);
+ }
+
+ showSelection(x, y) {
+ this.clearSelection();
+
+ const g = this._el("g", { id: "selection-ring" });
+ g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+
+ const ring = this._el("circle", {
+ r: 42, fill: "none",
+ stroke: "#4488ff", "stroke-width": "3",
+ filter: "url(#sel-glow)", opacity: "0.8"
+ });
+ g.appendChild(ring);
+ this.selectionLayer.appendChild(g);
+
+ const key = `${x},${y}`;
+ const piece = this.pieceElements.get(key);
+ if (piece) piece.classList.add("selected");
+ }
+
+ clearSelection() {
+ this.selectionLayer.innerHTML = "";
+ for (const el of this.pieceElements.values()) {
+ el.classList.remove("selected");
+ }
+ }
+
+ showWin(winner, board) {
+ const rings = [];
+ for (let x = 0; x < BOARD_SIZE; x++) {
+ for (let y = 0; y < BOARD_SIZE; y++) {
+ if (board.cells[x][y] === winner) {
+ const g = this._el("g", { class: "win-ring" });
+ g.style.transform = `translate(${x * 100 + 50}px, ${y * 100 + 50}px)`;
+ const ring = this._el("circle", {
+ r: 46, fill: "none",
+ stroke: "#4488ff", "stroke-width": "4",
+ filter: "url(#sel-glow)"
+ });
+ g.appendChild(ring);
+ this.selectionLayer.appendChild(g);
+ rings.push(g);
+ }
+ }
+ }
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ rings.forEach(g => g.classList.add("visible"));
+ });
+ });
+ }
+}
--- /dev/null
+#loa {
+ width: min(90vw, 80vh);
+ aspect-ratio: 1;
+ margin: 0 auto;
+}
+
+#loa svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+ touch-action: none;
+}
+
+#loa-status {
+ text-align: center;
+ margin-bottom: 8px;
+ font-size: 16px;
+ min-height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+}
+
+.turn-dot {
+ display: inline-block;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 1px solid #888;
+ vertical-align: middle;
+}
+
+.piece {
+ transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out;
+ cursor: pointer;
+}
+
+.piece.captured {
+ opacity: 0;
+ pointer-events: none;
+}
+
+.piece.selected circle {
+ transform: scale(1.08);
+ transform-origin: 0px 0px;
+}
+
+.piece.ghost {
+ pointer-events: none;
+ opacity: 0.75;
+ transition: none;
+}
+
+.win-ring {
+ opacity: 0;
+ transition: opacity 0.6s ease-out;
+}
+
+.win-ring.visible {
+ opacity: 0.8;
+}
+
+#game-info {
+ text-align: center;
+ margin-bottom: 12px;
+ font-size: 14px;
+}
+
+#game-info a {
+ color: inherit;
+}