206 lines
8.0 KiB
HTML
206 lines
8.0 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Snake Dashboard</title>
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/root.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/styles.css') }}">
|
|
</head>
|
|
<body>
|
|
<div class="page">
|
|
<header class="topbar">
|
|
<div>
|
|
<h1 class="title">Battlesnake Replay Dashboard</h1>
|
|
<p class="subtitle">Full-screen replay with turn-by-turn move reasoning and snake state.</p>
|
|
</div>
|
|
<div class="stats" id="stats"></div>
|
|
</header>
|
|
|
|
<main class="main">
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h2>Games</h2>
|
|
<p>Pick a game to inspect the match, move timeline, and why your snake chose each turn.</p>
|
|
</div>
|
|
<div class="games">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Game</th>
|
|
<th>Status</th>
|
|
<th>W/L</th>
|
|
<th>Turns</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="games-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel right">
|
|
<div class="controls">
|
|
<button id="prev-btn">Prev</button>
|
|
<button id="play-btn" class="primary" title="Play" aria-label="Play">▶</button>
|
|
<button id="next-btn">Next</button>
|
|
<label>
|
|
<span>Speed</span>
|
|
<select id="speed">
|
|
<option value="900">0.75x</option>
|
|
<option value="650" selected>1x</option>
|
|
<option value="400">1.5x</option>
|
|
<option value="250">2x</option>
|
|
<option value="160">2.5x</option>
|
|
</select>
|
|
</label>
|
|
<input type="range" min="0" max="0" step="1" id="turn-slider" value="0">
|
|
<strong class="turn-badge" id="turn-label">Turn -</strong>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<div class="board-wrap">
|
|
<div class="board" id="board"></div>
|
|
</div>
|
|
|
|
<div class="thinking" id="thinking"></div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
</div>
|
|
|
|
<script src="{{ url_for('static', filename='js/Utils.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/Snake.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/MoveTable.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/SnakeTable.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/GameBoard.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/OverallStats.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/GamesTable.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/Thinking.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/DashboardWebSocket.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/GameState.js') }}"></script>
|
|
<script>
|
|
const initialGameId = {{ initial_game_id|tojson }};
|
|
const initialSummary = {{ initial_summary|tojson }};
|
|
const initialGamesPayload = {{ initial_games|tojson }};
|
|
let dashboardSummary = (initialSummary && typeof initialSummary === "object") ? initialSummary : {};
|
|
let dashboardGamesPayload = (initialGamesPayload && typeof initialGamesPayload === "object") ? initialGamesPayload : { games: [] };
|
|
|
|
const overallStats = new OverallStats(document.getElementById("stats"));
|
|
const gameBoard = new GameBoard(document.getElementById("board"));
|
|
const thinkingPanel = new ThinkingPanel(document.getElementById("thinking"));
|
|
const sliderEl = document.getElementById("turn-slider");
|
|
const turnLabelEl = document.getElementById("turn-label");
|
|
|
|
const gameState = new GameState({ gameBoard, thinkingPanel, sliderEl, turnLabelEl });
|
|
gameState.activeGameId = String(initialGameId || "");
|
|
|
|
const gamesTable = new GamesTable(
|
|
document.getElementById("games-body"),
|
|
"{{ battlesnake_url }}",
|
|
(gameId) => gameState.loadReplay(gameId),
|
|
);
|
|
gameState._gamesTable = gamesTable;
|
|
|
|
const dashboardWS = new DashboardWebSocket({
|
|
onGamesUpdate: (payload) => {
|
|
if (payload.summary) {
|
|
dashboardSummary = payload.summary;
|
|
overallStats.render(dashboardSummary);
|
|
}
|
|
if (payload.games) {
|
|
dashboardGamesPayload = payload.games;
|
|
const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : [];
|
|
gamesTable.render(games, gameState.activeGameId);
|
|
}
|
|
},
|
|
});
|
|
gameState.setWebSocket(dashboardWS);
|
|
|
|
document.getElementById("play-btn").addEventListener("click", () => {
|
|
if (gameState.isPlaying) gameState.stopPlayback();
|
|
else gameState.startPlayback();
|
|
});
|
|
|
|
document.getElementById("prev-btn").addEventListener("click", () => {
|
|
gameState.stepBackward();
|
|
});
|
|
|
|
document.getElementById("next-btn").addEventListener("click", () => {
|
|
gameState.stepForward();
|
|
});
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
const target = event.target;
|
|
if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return;
|
|
if (event.key === "ArrowLeft") { event.preventDefault(); gameState.stepBackward(); return; }
|
|
if (event.key === "ArrowRight") { event.preventDefault(); gameState.stepForward(); return; }
|
|
if (event.key === "ArrowUp") { event.preventDefault(); gameState.adjustSpeed(1); return; }
|
|
if (event.key === "ArrowDown") { event.preventDefault(); gameState.adjustSpeed(-1); return; }
|
|
if (event.key === " " || event.key === "Spacebar") {
|
|
event.preventDefault();
|
|
if (gameState.isPlaying) gameState.stopPlayback();
|
|
else gameState.startPlayback();
|
|
}
|
|
});
|
|
|
|
sliderEl.addEventListener("input", () => {
|
|
gameState.stopPlayback();
|
|
gameState.turnIndex = Number(sliderEl.value || 0);
|
|
gameState.renderTurn();
|
|
});
|
|
|
|
document.getElementById("speed").addEventListener("change", () => {
|
|
if (gameState.isPlaying) gameState.startPlayback();
|
|
});
|
|
|
|
document.getElementById("thinking").addEventListener("click", (event) => {
|
|
const row = event.target.closest(".snake-row");
|
|
if (!row) return;
|
|
const section = row.closest(".snakes-section");
|
|
if (!section) return;
|
|
const clickedId = row.dataset.snakeId || null;
|
|
if (row.classList.contains("highlighted")) {
|
|
row.classList.remove("highlighted");
|
|
section.classList.remove("has-highlight");
|
|
gameState.setSelectedSnakeId(null);
|
|
} else {
|
|
section.querySelectorAll(".snake-row.highlighted").forEach((r) => r.classList.remove("highlighted"));
|
|
row.classList.add("highlighted");
|
|
section.classList.add("has-highlight");
|
|
gameState.setSelectedSnakeId(clickedId);
|
|
}
|
|
const game = gameState.replay && gameState.replay.game ? gameState.replay.game : {};
|
|
const turns = gameState.replay && Array.isArray(gameState.replay.turns) ? gameState.replay.turns : [];
|
|
if (turns[gameState.turnIndex]) {
|
|
gameBoard.paintBoard(turns[gameState.turnIndex], game.width, game.height, gameState.selectedSnakeId, gameState.replay);
|
|
}
|
|
});
|
|
|
|
window.addEventListener("resize", () => {
|
|
thinkingPanel.syncMonoOffset();
|
|
});
|
|
|
|
window.addEventListener("beforeunload", () => {
|
|
dashboardWS.shutdown();
|
|
});
|
|
|
|
async function boot() {
|
|
thinkingPanel.render(null, null);
|
|
overallStats.render(dashboardSummary);
|
|
dashboardWS.connect();
|
|
await dashboardWS.waitForOpen(2500);
|
|
const games = Array.isArray(dashboardGamesPayload.games) ? dashboardGamesPayload.games : [];
|
|
gamesTable.render(games, gameState.activeGameId);
|
|
if (gameState.activeGameId) {
|
|
await gameState.loadReplay(gameState.activeGameId);
|
|
} else if (games.length > 0) {
|
|
await gameState.loadReplay(games[0].game_id);
|
|
}
|
|
thinkingPanel.syncMonoOffset();
|
|
}
|
|
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|