feat: add playable Blokus web UI with replay system

- Add blokus_ui package: Flask web app for human-vs-bot Blokus
- GameSession: wraps BlokusGame for human play, records move history, serializes
- RunStore: file-based JSON replay storage with save/load/list/delete
- Flask app: routes for new game, play, save, browse runs, replay
- Templates: index (new game + run browser), game (playable board), replay (step controls)
- Static: CSS (dark theme, colored cells, placement overlays) + JS (click handling, API calls)
- Add blokus_ui tests (19 tests for GameSession and RunStore)
- Update pyproject.toml with flask dependency and blokus-ui entry point
- Fix: remove unreachable dead code in BlokusGame.valid_move
- All 102 tests pass, ruff lint clean
This commit is contained in:
mattlamb227@gmail.com
2026-08-09 20:08:18 -04:00
parent 123513e32f
commit 70672e9fef
13 changed files with 1888 additions and 2 deletions
+399
View File
@@ -0,0 +1,399 @@
document.addEventListener('DOMContentLoaded', function() {
if (document.getElementById('game-container')) {
initGame();
} else if (document.getElementById('replay-container')) {
initReplay();
}
});
function initGame() {
const sessionId = window.GAME_SESSION_ID;
const boardSize = window.BOARD_SIZE;
const cellSize = window.CELL_SIZE || 30;
let selectedPieceId = null;
let currentPlacements = [];
async function refresh() {
const response = await fetch(`/api/game/${sessionId}/state`);
const state = await response.json();
renderBoard(state.board, boardSize, cellSize);
renderSidebar(state);
renderStatusBar(state);
if (state.game_over) {
handleGameOver(state);
}
}
function renderBoard(board, size, cs) {
const boardEl = document.getElementById('board');
boardEl.innerHTML = '';
boardEl.style.gridTemplateColumns = `repeat(${size}, ${cs}px)`;
boardEl.style.gridTemplateRows = `repeat(${size}, ${cs}px)`;
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const cell = document.createElement('div');
cell.className = `cell player-${board[y][x]}`;
boardEl.appendChild(cell);
}
}
}
function renderSidebar(state) {
const sidebar = document.getElementById('sidebar');
sidebar.innerHTML = '';
const playerInfo = document.createElement('div');
playerInfo.id = 'player-info';
state.players.forEach(function(player) {
const el = document.createElement('div');
el.className = 'player-info';
el.innerHTML =
'<span class="player-name ' + player.color + '">' + player.name + '</span>' +
'<span class="player-meta">' +
'<span class="score">Score: ' + player.score + '</span>' +
'<span class="pieces">' + player.pieces_remaining + '/' + player.total_pieces + '</span>' +
'</span>';
playerInfo.appendChild(el);
});
sidebar.appendChild(playerInfo);
const piecesEl = document.createElement('div');
piecesEl.id = 'pieces';
const piecesTitle = document.createElement('h3');
piecesTitle.textContent = 'Your Pieces';
piecesEl.appendChild(piecesTitle);
if (state.available_pieces.length === 0) {
const emptyMsg = document.createElement('p');
emptyMsg.className = 'empty';
emptyMsg.textContent = 'No pieces remaining';
piecesEl.appendChild(emptyMsg);
} else {
state.available_pieces.forEach(function(piece) {
piecesEl.appendChild(renderPiece(piece, state.players[state.human_player].color));
});
}
sidebar.appendChild(piecesEl);
const controls = document.createElement('div');
controls.id = 'controls';
controls.innerHTML =
'<button id="save-btn">Save Replay</button>' +
'<a href="/">Back to Menu</a>';
sidebar.appendChild(controls);
document.getElementById('save-btn').addEventListener('click', saveReplay);
}
function renderPiece(piece, playerColor) {
const container = document.createElement('div');
container.className = 'piece';
container.dataset.pieceId = piece.id;
const width = Math.max.apply(null, piece.squares.map(function(s) { return s[0]; })) + 1;
const height = Math.max.apply(null, piece.squares.map(function(s) { return s[1]; })) + 1;
container.style.gridTemplateColumns = 'repeat(' + width + ', 1fr)';
container.style.gridTemplateRows = 'repeat(' + height + ', 1fr)';
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const cell = document.createElement('div');
cell.className = 'piece-cell';
const isFilled = piece.squares.some(function(s) { return s[0] === x && s[1] === y; });
if (isFilled) {
cell.classList.add('filled');
cell.style.background = getPlayerColor(playerColor);
}
container.appendChild(cell);
}
}
container.addEventListener('click', function() { selectPiece(piece.id); });
return container;
}
function getPlayerColor(name) {
var colors = { red: '#ff6b6b', blue: '#4dabf7', yellow: '#ffd43b', green: '#51cf66' };
return colors[name] || '#ff6b6b';
}
async function selectPiece(pieceId) {
if (selectedPieceId === pieceId) {
selectedPieceId = null;
clearPlacements();
document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); });
return;
}
selectedPieceId = pieceId;
document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); });
var selectedEl = document.querySelector('.piece[data-piece-id="' + pieceId + '"]');
if (selectedEl) { selectedEl.classList.add('selected'); }
const response = await fetch(`/api/game/${sessionId}/placements?piece_id=${pieceId}`);
const data = await response.json();
currentPlacements = data.placements;
renderPlacements(currentPlacements);
}
function renderPlacements(placements) {
const overlay = document.getElementById('placement-overlay');
overlay.innerHTML = '';
if (placements.length === 0) {
return;
}
placements.forEach(function(placement) {
placement.cells.forEach(function(cell) {
const x = cell[0], y = cell[1];
const el = document.createElement('div');
el.className = 'placement-cell';
el.style.left = (x * cellSize) + 'px';
el.style.top = (y * cellSize) + 'px';
el.dataset.action = placement.action;
el.addEventListener('click', function() { makeMove(placement.action); });
overlay.appendChild(el);
});
});
}
function clearPlacements() {
currentPlacements = [];
document.getElementById('placement-overlay').innerHTML = '';
}
async function makeMove(action) {
const response = await fetch(`/api/game/${sessionId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: action }),
});
const result = await response.json();
if (result.success) {
selectedPieceId = null;
clearPlacements();
document.querySelectorAll('.piece').forEach(function(p) { p.classList.remove('selected'); });
await refresh();
} else {
console.error('Move failed:', result.error);
}
}
async function saveReplay() {
const response = await fetch(`/api/game/${sessionId}/save`, { method: 'POST' });
const result = await response.json();
if (result.success) {
alert('Replay saved!');
} else {
alert('Failed to save: ' + result.error);
}
}
function renderStatusBar(state) {
const statusBar = document.getElementById('status-bar');
if (state.game_over) {
if (state.winner !== null) {
statusBar.textContent = 'Game Over! Winner: ' + state.players[state.winner].name;
} else {
statusBar.textContent = 'Game Over! Tie!';
}
} else {
statusBar.textContent = 'Current player: ' + state.players[state.current_player].name;
}
}
function handleGameOver(state) {
const statusBar = document.getElementById('status-bar');
let text = 'Game Over! ';
if (state.winner !== null) {
text += 'Winner: ' + state.players[state.winner].name;
} else {
text += 'Tie!';
}
text += ' | Scores: ' + state.players.map(function(p) {
return p.name + ': ' + p.score;
}).join(', ');
statusBar.textContent = text;
document.querySelectorAll('.piece').forEach(function(p) {
p.style.pointerEvents = 'none';
p.style.opacity = '0.5';
});
}
refresh();
}
function initReplay() {
const boardSize = window.BOARD_SIZE;
const totalMoves = window.TOTAL_MOVES;
const replayData = window.REPLAY_DATA;
const cellSize = window.CELL_SIZE || 30;
const states = replayData.states;
const moves = replayData.moves;
const config = replayData.config;
const statistics = replayData.statistics;
let currentStep = 0;
let isPlaying = false;
let playInterval = null;
function renderStep(step) {
currentStep = step;
const state = states[step];
renderBoard(state.board, boardSize, cellSize);
renderMoveList(moves, step, config.human_player);
renderStatistics(statistics);
const slider = document.getElementById('step-slider');
slider.value = step;
slider.max = totalMoves;
document.getElementById('step-indicator').textContent = step + ' / ' + totalMoves;
document.getElementById('prev-btn').disabled = step === 0;
document.getElementById('next-btn').disabled = step >= totalMoves;
if (step >= totalMoves && isPlaying) {
stopPlay();
}
}
function renderBoard(board, size, cs) {
const boardEl = document.getElementById('board');
boardEl.innerHTML = '';
boardEl.style.gridTemplateColumns = 'repeat(' + size + ', ' + cs + 'px)';
boardEl.style.gridTemplateRows = 'repeat(' + size + ', ' + cs + 'px)';
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const cell = document.createElement('div');
cell.className = 'cell player-' + board[y][x];
boardEl.appendChild(cell);
}
}
}
function renderMoveList(moves, currentStep, humanPlayer) {
const listEl = document.getElementById('move-list');
listEl.innerHTML = '';
moves.forEach(function(move, index) {
const el = document.createElement('div');
el.className = 'move-entry';
if (index < currentStep) {
el.classList.add('played');
}
if (index === currentStep - 1) {
el.classList.add('current');
}
var playerColor = ['red', 'blue', 'yellow', 'green'][move.player_idx];
var isHuman = move.player_idx === humanPlayer ? ' (you)' : '';
el.innerHTML =
'<span class="move-num">' + (index + 1) + '</span>' +
'<span class="move-player ' + playerColor + '">P' + (move.player_idx + 1) + isHuman + '</span>' +
'<span class="move-piece">' + move.piece_name + '</span>' +
'<span class="move-pos">(' + move.x + ', ' + move.y + ')</span>';
listEl.appendChild(el);
});
}
function renderStatistics(stats) {
const statsEl = document.getElementById('statistics');
statsEl.innerHTML = '';
const title = document.createElement('h3');
title.textContent = 'Statistics';
statsEl.appendChild(title);
var rows = [
['Coverage', stats.coverage + '%'],
['Total Moves', stats.total_moves]
];
stats.squares_placed.forEach(function(score, i) {
rows.push(['P' + (i + 1) + ' Score', score]);
});
stats.pieces_placed.forEach(function(placed, i) {
rows.push(['P' + (i + 1) + ' Pieces', placed]);
});
rows.push(['Winner Margin', stats.winner_margin]);
rows.forEach(function(row) {
var r = document.createElement('div');
r.className = 'stat-row';
r.innerHTML = '<span>' + row[0] + '</span><span>' + row[1] + '</span>';
statsEl.appendChild(r);
});
}
function togglePlay() {
if (isPlaying) {
stopPlay();
} else {
startPlay();
}
}
function startPlay() {
if (currentStep >= totalMoves) {
currentStep = 0;
}
isPlaying = true;
document.getElementById('play-btn').textContent = '\u23F8 Pause';
var speed = parseInt(document.getElementById('speed-select').value);
playInterval = setInterval(function() {
if (currentStep < totalMoves) {
renderStep(currentStep + 1);
} else {
stopPlay();
}
}, speed);
}
function stopPlay() {
isPlaying = false;
document.getElementById('play-btn').textContent = '\u25B6 Play';
if (playInterval) {
clearInterval(playInterval);
playInterval = null;
}
}
document.getElementById('prev-btn').addEventListener('click', function() {
if (currentStep > 0) {
renderStep(currentStep - 1);
}
});
document.getElementById('next-btn').addEventListener('click', function() {
if (currentStep < totalMoves) {
renderStep(currentStep + 1);
}
});
document.getElementById('play-btn').addEventListener('click', togglePlay);
document.getElementById('step-slider').addEventListener('input', function(e) {
var step = parseInt(e.target.value);
if (isPlaying) {
stopPlay();
}
renderStep(step);
});
renderStep(0);
}