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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user