Spaces:
Sleeping
Sleeping
Create frontend.html
Browse files- frontend.html +884 -0
frontend.html
ADDED
|
@@ -0,0 +1,884 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
| 6 |
+
<title>LCM Chess</title>
|
| 7 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/chessboard-js/1.0.0/chessboard-1.0.0.min.css">
|
| 8 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
|
| 9 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/chess.js/0.10.3/chess.min.js"></script>
|
| 10 |
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/chessboard-js/1.0.0/chessboard-1.0.0.min.js"></script>
|
| 11 |
+
<style>
|
| 12 |
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
| 13 |
+
|
| 14 |
+
:root {
|
| 15 |
+
--bg: #1a1a1a;
|
| 16 |
+
--bg-side: #141210;
|
| 17 |
+
--bg-card: #1e1c1a;
|
| 18 |
+
--border: #2e2820;
|
| 19 |
+
--border2: #3a3530;
|
| 20 |
+
--text: #e8e0d0;
|
| 21 |
+
--dim: #8a7a6a;
|
| 22 |
+
--faint: #6a5a4a;
|
| 23 |
+
--accent: #b58863;
|
| 24 |
+
--accent-h: #c9956c;
|
| 25 |
+
--sq-light: #f0d9b5;
|
| 26 |
+
--sq-dark: #b58863;
|
| 27 |
+
--mono: 'Courier New', monospace;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
html, body {
|
| 31 |
+
width: 100%; height: 100%;
|
| 32 |
+
background: var(--bg);
|
| 33 |
+
color: var(--text);
|
| 34 |
+
font-family: Georgia, serif;
|
| 35 |
+
overflow: hidden;
|
| 36 |
+
touch-action: manipulation;
|
| 37 |
+
-webkit-user-select: none;
|
| 38 |
+
user-select: none;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
#app { display: flex; width: 100vw; height: 100vh; overflow: hidden; }
|
| 42 |
+
|
| 43 |
+
/* ── Sidebar ── */
|
| 44 |
+
#sidebar {
|
| 45 |
+
width: 220px; min-width: 220px;
|
| 46 |
+
background: var(--bg-side);
|
| 47 |
+
border-right: 1px solid var(--border);
|
| 48 |
+
display: flex; flex-direction: column;
|
| 49 |
+
padding: 18px 14px; gap: 16px;
|
| 50 |
+
overflow: hidden;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
.lbl {
|
| 54 |
+
font-size: 0.68rem; color: var(--faint);
|
| 55 |
+
text-transform: uppercase; letter-spacing: 0.09em;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.side-btns { display: flex; gap: 4px; }
|
| 59 |
+
.side-btn {
|
| 60 |
+
flex: 1; padding: 7px 2px;
|
| 61 |
+
background: #252220; border: 1px solid var(--border2);
|
| 62 |
+
color: var(--dim); font-size: 0.78rem; border-radius: 4px;
|
| 63 |
+
cursor: pointer; transition: all 0.12s; font-family: Georgia, serif;
|
| 64 |
+
-webkit-tap-highlight-color: transparent;
|
| 65 |
+
}
|
| 66 |
+
.side-btn:hover { background: #2e2820; color: var(--text); }
|
| 67 |
+
.side-btn.active { background: var(--accent); border-color: var(--accent); color: #1a1a1a; font-weight: bold; }
|
| 68 |
+
|
| 69 |
+
.sg { display: flex; flex-direction: column; gap: 5px; }
|
| 70 |
+
.sr { display: flex; align-items: center; gap: 8px; }
|
| 71 |
+
input[type=range] { flex: 1; accent-color: var(--accent); cursor: pointer; }
|
| 72 |
+
.sv {
|
| 73 |
+
font-size: 0.76rem; color: var(--text);
|
| 74 |
+
font-family: var(--mono); min-width: 32px; text-align: right;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
#new-game-btn {
|
| 78 |
+
width: 100%; padding: 9px;
|
| 79 |
+
background: var(--accent); border: none; border-radius: 6px;
|
| 80 |
+
color: #1a1a1a; font-size: 0.88rem; font-weight: bold;
|
| 81 |
+
font-family: Georgia, serif; cursor: pointer;
|
| 82 |
+
transition: background 0.12s;
|
| 83 |
+
-webkit-tap-highlight-color: transparent;
|
| 84 |
+
}
|
| 85 |
+
#new-game-btn:hover { background: var(--accent-h); }
|
| 86 |
+
#new-game-btn:disabled { background: #3a2e22; color: #6a5a4a; cursor: not-allowed; }
|
| 87 |
+
|
| 88 |
+
hr.div { border: none; border-top: 1px solid #252220; flex-shrink: 0; }
|
| 89 |
+
|
| 90 |
+
#hw { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 5px; overflow: hidden; }
|
| 91 |
+
#mh {
|
| 92 |
+
flex: 1; min-height: 0; overflow-y: auto;
|
| 93 |
+
font-family: var(--mono); font-size: 0.74rem; color: #c8b89a; line-height: 1.9;
|
| 94 |
+
}
|
| 95 |
+
#mh::-webkit-scrollbar { width: 3px; }
|
| 96 |
+
#mh::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
| 97 |
+
|
| 98 |
+
/* ── Main ── */
|
| 99 |
+
#main {
|
| 100 |
+
flex: 1; min-width: 0;
|
| 101 |
+
display: flex; flex-direction: column;
|
| 102 |
+
align-items: center; justify-content: center;
|
| 103 |
+
gap: 10px; padding: 16px; overflow: hidden;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
#title { text-align: center; flex-shrink: 0; }
|
| 107 |
+
#title h1 { font-size: clamp(1.1rem, 3vw, 1.5rem); color: var(--sq-light); letter-spacing: 0.02em; }
|
| 108 |
+
#title p { font-size: clamp(0.68rem, 1.5vw, 0.8rem); color: var(--faint); font-style: italic; margin-top: 2px; }
|
| 109 |
+
|
| 110 |
+
#board-wrap { flex-shrink: 0; position: relative; }
|
| 111 |
+
#board { width: 100% !important; }
|
| 112 |
+
|
| 113 |
+
/* board square colors */
|
| 114 |
+
#board .white-1e1d7 { background-color: var(--sq-light) !important; }
|
| 115 |
+
#board .square-55d63 { cursor: default; }
|
| 116 |
+
#board.my-turn .square-55d63 { cursor: grab; }
|
| 117 |
+
#board .black-3c85d { background-color: var(--sq-dark) !important; }
|
| 118 |
+
|
| 119 |
+
/* highlights — inset box-shadow overlays the square without fighting chessboard.js bg */
|
| 120 |
+
.hl-from { box-shadow: inset 0 0 0 1000px rgba(205,210,106,0.65) !important; }
|
| 121 |
+
.hl-to { box-shadow: inset 0 0 0 1000px rgba(205,210,106,0.85) !important; }
|
| 122 |
+
.hl-sel { box-shadow: inset 0 0 0 1000px rgba(205,210,106,0.65) !important; }
|
| 123 |
+
.hl-check { box-shadow: inset 0 0 0 1000px rgba(210,50,50,0.65) !important; }
|
| 124 |
+
.hl-legal { background-image: radial-gradient(circle, rgba(0,0,0,0.22) 28%, transparent 30%) !important; }
|
| 125 |
+
.hl-capture { background-image: radial-gradient(circle, transparent 57%, rgba(0,0,0,0.22) 58%, rgba(0,0,0,0.22) 72%, transparent 74%) !important; }
|
| 126 |
+
/* drag hover — outline so it layers on top of box-shadow yellow fill */
|
| 127 |
+
.highlight-hover { outline: 4px solid rgba(255,255,255,0.5) !important; outline-offset: -4px !important; }
|
| 128 |
+
|
| 129 |
+
#status-bar {
|
| 130 |
+
flex-shrink: 0; width: 100%; max-width: 480px;
|
| 131 |
+
background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px;
|
| 132 |
+
padding: 8px 14px;
|
| 133 |
+
font-family: var(--mono); font-size: clamp(0.7rem, 1.5vw, 0.8rem);
|
| 134 |
+
color: var(--text); text-align: center;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#thinking {
|
| 138 |
+
flex-shrink: 0; height: 18px;
|
| 139 |
+
display: none; align-items: center; gap: 6px;
|
| 140 |
+
font-size: 0.78rem; color: var(--accent); font-style: italic;
|
| 141 |
+
}
|
| 142 |
+
#thinking.on { display: flex; }
|
| 143 |
+
.dots span {
|
| 144 |
+
display: inline-block; width: 4px; height: 4px;
|
| 145 |
+
background: var(--accent); border-radius: 50%;
|
| 146 |
+
animation: bop 1.2s infinite;
|
| 147 |
+
}
|
| 148 |
+
.dots span:nth-child(2) { animation-delay: 0.2s; }
|
| 149 |
+
.dots span:nth-child(3) { animation-delay: 0.4s; }
|
| 150 |
+
@keyframes bop { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-5px)} }
|
| 151 |
+
|
| 152 |
+
/* ── Time controls ── */
|
| 153 |
+
.tc-btns { display: flex; gap: 4px; }
|
| 154 |
+
.tc-btn {
|
| 155 |
+
flex: 1; padding: 6px 2px;
|
| 156 |
+
background: #252220; border: 1px solid var(--border2);
|
| 157 |
+
color: var(--dim); font-size: 0.75rem; border-radius: 4px;
|
| 158 |
+
cursor: pointer; transition: all 0.12s; font-family: var(--mono);
|
| 159 |
+
-webkit-tap-highlight-color: transparent;
|
| 160 |
+
}
|
| 161 |
+
.tc-btn:hover { background: #2e2820; color: var(--text); }
|
| 162 |
+
.tc-btn.active { background: #2a3a2a; border-color: #4a7a4a; color: #8aba8a; font-weight: bold; }
|
| 163 |
+
|
| 164 |
+
/* ── Clock display (shows below board when active) ── */
|
| 165 |
+
#clocks {
|
| 166 |
+
display: none;
|
| 167 |
+
flex-shrink: 0;
|
| 168 |
+
width: 100%; max-width: 480px;
|
| 169 |
+
gap: 8px;
|
| 170 |
+
}
|
| 171 |
+
#clocks.on { display: flex; }
|
| 172 |
+
.clock-box {
|
| 173 |
+
flex: 1; padding: 8px 12px;
|
| 174 |
+
background: var(--bg-card); border: 1px solid var(--border);
|
| 175 |
+
border-radius: 6px; text-align: center;
|
| 176 |
+
font-family: var(--mono); font-size: 1.3rem; font-weight: bold;
|
| 177 |
+
color: var(--dim); transition: all 0.2s;
|
| 178 |
+
}
|
| 179 |
+
.clock-box.active { color: var(--sq-light); border-color: #5a5040; background: #252215; }
|
| 180 |
+
.clock-box.low { color: #e05050; border-color: #7a2020; background: #251515; animation: pulse-red 1s infinite; }
|
| 181 |
+
.clock-box .clk-lbl {
|
| 182 |
+
font-size: 0.62rem; color: var(--faint); letter-spacing: 0.08em;
|
| 183 |
+
text-transform: uppercase; display: block; margin-bottom: 2px;
|
| 184 |
+
}
|
| 185 |
+
@keyframes pulse-red { 0%,100%{opacity:1} 50%{opacity:0.65} }
|
| 186 |
+
|
| 187 |
+
/* ── PGN button ── */
|
| 188 |
+
#pgn-btn {
|
| 189 |
+
width: 100%; padding: 7px;
|
| 190 |
+
background: #1e1c1a; border: 1px solid var(--border2); border-radius: 5px;
|
| 191 |
+
color: var(--dim); font-size: 0.78rem; font-family: Georgia, serif;
|
| 192 |
+
cursor: pointer; transition: all 0.12s; margin-top: 2px;
|
| 193 |
+
-webkit-tap-highlight-color: transparent;
|
| 194 |
+
}
|
| 195 |
+
#pgn-btn:hover { background: #252220; color: var(--text); border-color: #5a4a3a; }
|
| 196 |
+
#pgn-btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
/* ── Mobile ── */
|
| 200 |
+
#mob-fab {
|
| 201 |
+
display: none;
|
| 202 |
+
position: fixed; bottom: 14px; right: 14px;
|
| 203 |
+
width: 46px; height: 46px;
|
| 204 |
+
background: var(--accent); border: none; border-radius: 50%;
|
| 205 |
+
font-size: 1.3rem; cursor: pointer; z-index: 50;
|
| 206 |
+
align-items: center; justify-content: center;
|
| 207 |
+
box-shadow: 0 2px 12px rgba(0,0,0,0.5);
|
| 208 |
+
-webkit-tap-highlight-color: transparent;
|
| 209 |
+
}
|
| 210 |
+
#mob-overlay {
|
| 211 |
+
display: none; position: fixed; inset: 0;
|
| 212 |
+
background: rgba(0,0,0,0.55); z-index: 98; opacity: 0;
|
| 213 |
+
pointer-events: none; transition: opacity 0.25s;
|
| 214 |
+
}
|
| 215 |
+
#mob-overlay.on { opacity: 1; pointer-events: auto; }
|
| 216 |
+
#mob-panel {
|
| 217 |
+
display: none; position: fixed;
|
| 218 |
+
bottom: 0; left: 0; right: 0;
|
| 219 |
+
background: var(--bg-side);
|
| 220 |
+
border-top: 1px solid var(--border);
|
| 221 |
+
border-radius: 14px 14px 0 0;
|
| 222 |
+
padding: 18px 16px 30px;
|
| 223 |
+
flex-direction: column; gap: 16px;
|
| 224 |
+
z-index: 99;
|
| 225 |
+
transform: translateY(100%); transition: transform 0.28s ease;
|
| 226 |
+
}
|
| 227 |
+
#mob-panel.on { transform: translateY(0); }
|
| 228 |
+
|
| 229 |
+
@media (max-width: 680px) {
|
| 230 |
+
#sidebar { display: none; }
|
| 231 |
+
#mob-fab { display: flex; }
|
| 232 |
+
#mob-panel, #mob-overlay { display: flex; }
|
| 233 |
+
#mob-panel { display: flex; }
|
| 234 |
+
#main { padding: 10px; gap: 8px; }
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
/* ── Promotion modal ── */
|
| 238 |
+
#promo-modal {
|
| 239 |
+
display: none; position: fixed; inset: 0;
|
| 240 |
+
background: rgba(0,0,0,0.8); z-index: 200;
|
| 241 |
+
align-items: center; justify-content: center;
|
| 242 |
+
}
|
| 243 |
+
#promo-modal.on { display: flex; }
|
| 244 |
+
#promo-box {
|
| 245 |
+
background: #252220; border: 1px solid #4a4035;
|
| 246 |
+
border-radius: 10px; padding: 22px 28px; text-align: center;
|
| 247 |
+
}
|
| 248 |
+
#promo-box h3 { color: var(--sq-light); margin-bottom: 14px; font-size: 0.92rem; letter-spacing: 0.04em; }
|
| 249 |
+
.pb { display: flex; gap: 10px; }
|
| 250 |
+
.pb button {
|
| 251 |
+
width: 58px; height: 58px;
|
| 252 |
+
background: #1a1a1a; border: 1px solid #4a4035; border-radius: 6px;
|
| 253 |
+
cursor: pointer; font-size: 2rem;
|
| 254 |
+
display: flex; align-items: center; justify-content: center;
|
| 255 |
+
color: var(--text); transition: background 0.12s;
|
| 256 |
+
-webkit-tap-highlight-color: transparent;
|
| 257 |
+
}
|
| 258 |
+
.pb button:hover { background: var(--border2); }
|
| 259 |
+
</style>
|
| 260 |
+
</head>
|
| 261 |
+
<body>
|
| 262 |
+
<div id="app">
|
| 263 |
+
|
| 264 |
+
<!-- Desktop sidebar -->
|
| 265 |
+
<div id="sidebar">
|
| 266 |
+
<div>
|
| 267 |
+
<div class="lbl" style="margin-bottom:7px">Play as</div>
|
| 268 |
+
<div class="side-btns">
|
| 269 |
+
<button class="side-btn" data-side="white" onclick="setSide('white')">White</button>
|
| 270 |
+
<button class="side-btn active" data-side="random" onclick="setSide('random')">Rnd</button>
|
| 271 |
+
<button class="side-btn" data-side="black" onclick="setSide('black')">Black</button>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
<div class="sg">
|
| 275 |
+
<div class="lbl">Temperature</div>
|
| 276 |
+
<div class="sr">
|
| 277 |
+
<input type="range" id="td" min="0.1" max="2.0" step="0.05" value="1.0" oninput="syncS('t',false)">
|
| 278 |
+
<span class="sv" id="tv">1.00</span>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
<div class="sg">
|
| 282 |
+
<div class="lbl">Top-K <span style="color:#4a3a2a;font-size:0.66rem">(0=off)</span></div>
|
| 283 |
+
<div class="sr">
|
| 284 |
+
<input type="range" id="kd" min="0" max="20" step="1" value="0" oninput="syncS('k',false)">
|
| 285 |
+
<span class="sv" id="kv">0</span>
|
| 286 |
+
</div>
|
| 287 |
+
</div>
|
| 288 |
+
<div>
|
| 289 |
+
<div class="lbl" style="margin-bottom:7px">Time Control</div>
|
| 290 |
+
<div class="tc-btns">
|
| 291 |
+
<button class="tc-btn active" data-tc="0" onclick="setTC(0)">∞</button>
|
| 292 |
+
<button class="tc-btn" data-tc="60" onclick="setTC(60)">1m</button>
|
| 293 |
+
<button class="tc-btn" data-tc="300" onclick="setTC(300)">5m</button>
|
| 294 |
+
<button class="tc-btn" data-tc="600" onclick="setTC(600)">10m</button>
|
| 295 |
+
</div>
|
| 296 |
+
</div>
|
| 297 |
+
<button id="new-game-btn" onclick="newGame()">New Game</button>
|
| 298 |
+
<hr class="div">
|
| 299 |
+
<div id="hw">
|
| 300 |
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px"><span class="lbl">Move History</span><button id="pgn-btn" style="width:auto;padding:3px 8px;margin-top:0;font-size:0.68rem" onclick="copyPGN()" disabled>PGN ↓</button></div>
|
| 301 |
+
<div id="mh"></div>
|
| 302 |
+
</div>
|
| 303 |
+
</div>
|
| 304 |
+
|
| 305 |
+
<!-- Board -->
|
| 306 |
+
<div id="main">
|
| 307 |
+
<div id="title">
|
| 308 |
+
<h1>♟ Liquid Chess Model</h1>
|
| 309 |
+
<p>29.2M parameter hybrid transformer</p>
|
| 310 |
+
</div>
|
| 311 |
+
<div id="board-wrap"><div id="board"></div></div>
|
| 312 |
+
<div id="clocks">
|
| 313 |
+
<div class="clock-box" id="clk-top"><span class="clk-lbl" id="clk-top-lbl">Model</span><span id="clk-top-val">—</span></div>
|
| 314 |
+
<div class="clock-box active" id="clk-bot"><span class="clk-lbl" id="clk-bot-lbl">You</span><span id="clk-bot-val">—</span></div>
|
| 315 |
+
</div>
|
| 316 |
+
<div id="status-bar">Loading…</div>
|
| 317 |
+
</div>
|
| 318 |
+
</div>
|
| 319 |
+
|
| 320 |
+
<!-- Mobile FAB + panel -->
|
| 321 |
+
<button id="mob-fab" onclick="mobToggle()">⚙</button>
|
| 322 |
+
<div id="mob-overlay" onclick="mobClose()"></div>
|
| 323 |
+
<div id="mob-panel">
|
| 324 |
+
<div style="display:flex;justify-content:space-between;align-items:center">
|
| 325 |
+
<span style="font-size:0.9rem;color:var(--sq-light)">Settings</span>
|
| 326 |
+
<button onclick="mobClose()" style="background:none;border:none;color:var(--dim);font-size:1.3rem;cursor:pointer">✕</button>
|
| 327 |
+
</div>
|
| 328 |
+
<div>
|
| 329 |
+
<div class="lbl" style="margin-bottom:7px">Play as</div>
|
| 330 |
+
<div class="side-btns">
|
| 331 |
+
<button class="side-btn" data-side="white" onclick="setSide('white')">White</button>
|
| 332 |
+
<button class="side-btn active" data-side="random" onclick="setSide('random')">Rnd</button>
|
| 333 |
+
<button class="side-btn" data-side="black" onclick="setSide('black')">Black</button>
|
| 334 |
+
</div>
|
| 335 |
+
</div>
|
| 336 |
+
<div class="sg">
|
| 337 |
+
<div class="lbl">Temperature</div>
|
| 338 |
+
<div class="sr">
|
| 339 |
+
<input type="range" id="tm" min="0.1" max="2.0" step="0.05" value="1.0" oninput="syncS('t',true)">
|
| 340 |
+
<span class="sv" id="tvm">1.00</span>
|
| 341 |
+
</div>
|
| 342 |
+
</div>
|
| 343 |
+
<div class="sg">
|
| 344 |
+
<div class="lbl">Top-K <span style="color:#4a3a2a;font-size:0.66rem">(0=off)</span></div>
|
| 345 |
+
<div class="sr">
|
| 346 |
+
<input type="range" id="km" min="0" max="20" step="1" value="0" oninput="syncS('k',true)">
|
| 347 |
+
<span class="sv" id="kvm">0</span>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
<div>
|
| 351 |
+
<div class="lbl" style="margin-bottom:7px">Time Control</div>
|
| 352 |
+
<div class="tc-btns">
|
| 353 |
+
<button class="tc-btn active" data-tc="0" onclick="setTC(0)">∞</button>
|
| 354 |
+
<button class="tc-btn" data-tc="60" onclick="setTC(60)">1m</button>
|
| 355 |
+
<button class="tc-btn" data-tc="300" onclick="setTC(300)">5m</button>
|
| 356 |
+
<button class="tc-btn" data-tc="600" onclick="setTC(600)">10m</button>
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
<button onclick="newGame();mobClose()" style="width:100%;padding:10px;background:var(--accent);border:none;border-radius:6px;color:#1a1a1a;font-size:0.9rem;font-weight:bold;font-family:Georgia,serif;cursor:pointer">New Game</button>
|
| 360 |
+
<button onclick="copyPGN();mobClose()" style="width:100%;padding:8px;background:#1e1c1a;border:1px solid var(--border2);border-radius:5px;color:var(--dim);font-size:0.82rem;font-family:Georgia,serif;cursor:pointer">Copy PGN</button>
|
| 361 |
+
</div>
|
| 362 |
+
|
| 363 |
+
<!-- Promotion modal -->
|
| 364 |
+
<div id="promo-modal">
|
| 365 |
+
<div id="promo-box">
|
| 366 |
+
<h3>Promote to…</h3>
|
| 367 |
+
<div class="pb">
|
| 368 |
+
<button onclick="pickPromo('q')">♛</button>
|
| 369 |
+
<button onclick="pickPromo('r')">♜</button>
|
| 370 |
+
<button onclick="pickPromo('b')">♝</button>
|
| 371 |
+
<button onclick="pickPromo('n')">♞</button>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
</div>
|
| 375 |
+
|
| 376 |
+
<script>
|
| 377 |
+
// ── State ─────────────────────────────��────────────────────────────────────────
|
| 378 |
+
let game = new Chess();
|
| 379 |
+
let board = null;
|
| 380 |
+
let playerWhite = true;
|
| 381 |
+
let moveHistory = [];
|
| 382 |
+
let chosenSide = 'random';
|
| 383 |
+
let pendingPromo = null;
|
| 384 |
+
let gameOver = false;
|
| 385 |
+
let selectedSq = null;
|
| 386 |
+
|
| 387 |
+
const el = id => document.getElementById(id);
|
| 388 |
+
|
| 389 |
+
// ── Gradio SSE API ─────────────────────────────────────────────────────────────
|
| 390 |
+
// Gradio 4+ two-step: POST → get event_id, then GET stream → parse last data line
|
| 391 |
+
async function apiPost(endpoint, body) {
|
| 392 |
+
const r = await fetch('/api/' + endpoint, {
|
| 393 |
+
method: 'POST',
|
| 394 |
+
headers: { 'Content-Type': 'application/json' },
|
| 395 |
+
body: JSON.stringify(body),
|
| 396 |
+
});
|
| 397 |
+
if (!r.ok) throw new Error('Request failed: ' + r.status);
|
| 398 |
+
return r.json();
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// ── Board size ─────────────────────────────────────────────────────────────────
|
| 402 |
+
function calcSize() {
|
| 403 |
+
const sideW = window.innerWidth > 680 ? 220 : 0;
|
| 404 |
+
const availW = window.innerWidth - sideW - 32;
|
| 405 |
+
const availH = window.innerHeight - 180;
|
| 406 |
+
return Math.floor(Math.min(availW, availH, 480));
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
// ── Board init ─────────────────────────────────────────────────────────────────
|
| 410 |
+
function initBoard(orientation) {
|
| 411 |
+
const size = calcSize();
|
| 412 |
+
el('board-wrap').style.width = size + 'px';
|
| 413 |
+
|
| 414 |
+
if (board) board.destroy();
|
| 415 |
+
|
| 416 |
+
board = Chessboard('board', {
|
| 417 |
+
position: game.fen(),
|
| 418 |
+
orientation: orientation,
|
| 419 |
+
draggable: true,
|
| 420 |
+
onDragStart: onDragStart,
|
| 421 |
+
onDrop: onDrop,
|
| 422 |
+
onSnapEnd: () => { board.position(game.fen()); applyHL(); },
|
| 423 |
+
onMouseoverSquare: onMouseOver,
|
| 424 |
+
onMouseoutSquare: onMouseOut,
|
| 425 |
+
onDragMove: onDragMoveBoard,
|
| 426 |
+
pieceTheme: 'https://chessboardjs.com/img/chesspieces/wikipedia/{piece}.png',
|
| 427 |
+
moveSpeed: 150,
|
| 428 |
+
snapbackSpeed: 200,
|
| 429 |
+
});
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
window.addEventListener('resize', () => {
|
| 433 |
+
if (!board) return;
|
| 434 |
+
const size = calcSize();
|
| 435 |
+
el('board-wrap').style.width = size + 'px';
|
| 436 |
+
board.resize();
|
| 437 |
+
});
|
| 438 |
+
|
| 439 |
+
// ── Highlight state ────────────────────────────────────────────────────────────
|
| 440 |
+
// Single source of truth. Call applyHL() after any state change or board.position().
|
| 441 |
+
let lastFrom = null;
|
| 442 |
+
let lastTo = null;
|
| 443 |
+
let isDrag = false;
|
| 444 |
+
let suppressNextClick = false;
|
| 445 |
+
|
| 446 |
+
function applyHL() {
|
| 447 |
+
clearHL();
|
| 448 |
+
if (lastFrom) hl(lastFrom, 'hl-from');
|
| 449 |
+
if (lastTo) hl(lastTo, 'hl-to');
|
| 450 |
+
if (selectedSq) {
|
| 451 |
+
hl(selectedSq, 'hl-sel');
|
| 452 |
+
legalDots(selectedSq);
|
| 453 |
+
}
|
| 454 |
+
if (game.in_check()) {
|
| 455 |
+
const t = game.turn();
|
| 456 |
+
for (let r = 1; r <= 8; r++) for (const f of 'abcdefgh') {
|
| 457 |
+
const sq = f + r, p = game.get(sq);
|
| 458 |
+
if (p && p.type === 'k' && p.color === t) hl(sq, 'hl-check');
|
| 459 |
+
}
|
| 460 |
+
}
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
// ── Drag ───────────────────────────────────────────────────────────────────────
|
| 464 |
+
function onDragStart(source, piece) {
|
| 465 |
+
if (!canMove()) return false;
|
| 466 |
+
if (!myPiece(piece)) return false;
|
| 467 |
+
isDrag = false;
|
| 468 |
+
// show legal dots for the piece being dragged
|
| 469 |
+
selectedSq = source;
|
| 470 |
+
applyHL();
|
| 471 |
+
return true;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
function onDragMoveBoard(newSq) {
|
| 475 |
+
isDrag = true;
|
| 476 |
+
// hover border — outline, independent of box-shadow yellow
|
| 477 |
+
document.querySelectorAll('.highlight-hover').forEach(e => e.classList.remove('highlight-hover'));
|
| 478 |
+
if (newSq !== 'offboard') hl(newSq, 'highlight-hover');
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
function onDrop(source, target) {
|
| 482 |
+
document.querySelectorAll('.highlight-hover').forEach(e => e.classList.remove('highlight-hover'));
|
| 483 |
+
// Suppress the DOM click/touchend that always fires after mouseup/touchend,
|
| 484 |
+
// because the drag system (onDragStart/onDrop) already handled this interaction.
|
| 485 |
+
suppressNextClick = true;
|
| 486 |
+
setTimeout(() => { suppressNextClick = false; }, 100);
|
| 487 |
+
|
| 488 |
+
if (source === target || !isDrag) {
|
| 489 |
+
// User clicked without dragging — onDragStart already set selectedSq, keep it.
|
| 490 |
+
isDrag = false;
|
| 491 |
+
return 'snapback';
|
| 492 |
+
}
|
| 493 |
+
isDrag = false;
|
| 494 |
+
return tryMove(source, target) ? undefined : 'snapback';
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
function onMouseOver() {}
|
| 498 |
+
function onMouseOut() {}
|
| 499 |
+
|
| 500 |
+
// ── Click to move ──────────────────────────────────────────────────────────────
|
| 501 |
+
function onSquareClick(sq, pStr) {
|
| 502 |
+
if (!canMove()) return;
|
| 503 |
+
|
| 504 |
+
if (selectedSq) {
|
| 505 |
+
if (sq === selectedSq) {
|
| 506 |
+
selectedSq = null;
|
| 507 |
+
applyHL();
|
| 508 |
+
return;
|
| 509 |
+
}
|
| 510 |
+
// Reselect another own piece
|
| 511 |
+
const p = game.get(sq);
|
| 512 |
+
if (p && myPieceObj(p)) {
|
| 513 |
+
selectedSq = sq;
|
| 514 |
+
applyHL();
|
| 515 |
+
return;
|
| 516 |
+
}
|
| 517 |
+
// Attempt move
|
| 518 |
+
if (!tryMove(selectedSq, sq)) {
|
| 519 |
+
selectedSq = null;
|
| 520 |
+
applyHL();
|
| 521 |
+
}
|
| 522 |
+
return;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
// Select piece
|
| 526 |
+
if (!pStr || !myPiece(pStr)) return;
|
| 527 |
+
selectedSq = sq;
|
| 528 |
+
applyHL();
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
// ── Move logic ─────────────────────────────────────────────────────────────────
|
| 532 |
+
function tryMove(from, to) {
|
| 533 |
+
const p = game.get(from);
|
| 534 |
+
const isPromo = p && p.type === 'p' &&
|
| 535 |
+
((p.color === 'w' && to[1] === '8') || (p.color === 'b' && to[1] === '1'));
|
| 536 |
+
|
| 537 |
+
if (isPromo) {
|
| 538 |
+
// Check it's actually a legal move before showing modal
|
| 539 |
+
const test = game.move({ from, to, promotion: 'q' });
|
| 540 |
+
if (!test) return false;
|
| 541 |
+
game.undo();
|
| 542 |
+
pendingPromo = { from, to };
|
| 543 |
+
selectedSq = null;
|
| 544 |
+
clearHL();
|
| 545 |
+
el('promo-modal').classList.add('on');
|
| 546 |
+
return true;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
const fenBefore = game.fen();
|
| 550 |
+
const mv = game.move({ from, to });
|
| 551 |
+
if (!mv) return false;
|
| 552 |
+
|
| 553 |
+
board.position(game.fen(), false);
|
| 554 |
+
hlLast(from, to);
|
| 555 |
+
sendMove(mv.from + mv.to + (mv.promotion || ''), fenBefore);
|
| 556 |
+
return true;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
function pickPromo(piece) {
|
| 560 |
+
el('promo-modal').classList.remove('on');
|
| 561 |
+
if (!pendingPromo) return;
|
| 562 |
+
const { from, to } = pendingPromo;
|
| 563 |
+
pendingPromo = null;
|
| 564 |
+
const fenBefore = game.fen();
|
| 565 |
+
const mv = game.move({ from, to, promotion: piece });
|
| 566 |
+
if (!mv) return;
|
| 567 |
+
board.position(game.fen(), false);
|
| 568 |
+
hlLast(from, to);
|
| 569 |
+
sendMove(from + to + piece, fenBefore);
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
// ── Backend call ───────────────────────────────────────────────────────────────
|
| 573 |
+
async function sendMove(uci, fenBefore) {
|
| 574 |
+
stopClock();
|
| 575 |
+
startClock('model');
|
| 576 |
+
lockBoard(true);
|
| 577 |
+
try {
|
| 578 |
+
const r = await apiPost('make_move', {
|
| 579 |
+
move_uci: uci, fen: fenBefore, history: moveHistory,
|
| 580 |
+
temperature: parseFloat(getVal('t')),
|
| 581 |
+
top_k: parseInt(getVal('k')),
|
| 582 |
+
});
|
| 583 |
+
moveHistory = r.history;
|
| 584 |
+
game.load(r.fen);
|
| 585 |
+
board.position(r.fen, true);
|
| 586 |
+
if (r.model_move) { lastFrom = r.model_move.slice(0,2); lastTo = r.model_move.slice(2,4); }
|
| 587 |
+
else { lastFrom = null; lastTo = null; }
|
| 588 |
+
selectedSq = null;
|
| 589 |
+
applyHL();
|
| 590 |
+
updateHist(r.history_text);
|
| 591 |
+
setStatus(r.status);
|
| 592 |
+
gameOver = isOver(r.status);
|
| 593 |
+
lockBoard(gameOver);
|
| 594 |
+
updateTurnClass();
|
| 595 |
+
stopClock();
|
| 596 |
+
if (!gameOver) startClock('player');
|
| 597 |
+
} catch(e) {
|
| 598 |
+
setStatus('Error: ' + e.message);
|
| 599 |
+
game.undo();
|
| 600 |
+
board.position(game.fen());
|
| 601 |
+
lockBoard(false);
|
| 602 |
+
updateTurnClass();
|
| 603 |
+
stopClock();
|
| 604 |
+
startClock('player');
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
async function newGame() {
|
| 609 |
+
gameOver = false; selectedSq = null; clearHL();
|
| 610 |
+
el('new-game-btn').disabled = true;
|
| 611 |
+
setStatus('Starting…');
|
| 612 |
+
try {
|
| 613 |
+
const r = await apiPost('start_game', {
|
| 614 |
+
side: chosenSide,
|
| 615 |
+
temperature: parseFloat(getVal('t')),
|
| 616 |
+
top_k: parseInt(getVal('k')),
|
| 617 |
+
});
|
| 618 |
+
playerWhite = r.player_is_white;
|
| 619 |
+
moveHistory = r.history;
|
| 620 |
+
game = new Chess(r.fen);
|
| 621 |
+
// ← This is the fix: pass correct orientation based on actual assigned side
|
| 622 |
+
initBoard(playerWhite ? 'white' : 'black');
|
| 623 |
+
lastFrom = r.model_move ? r.model_move.slice(0,2) : null;
|
| 624 |
+
lastTo = r.model_move ? r.model_move.slice(2,4) : null;
|
| 625 |
+
selectedSq = null;
|
| 626 |
+
applyHL();
|
| 627 |
+
updateHist(r.history_text);
|
| 628 |
+
setStatus(r.status);
|
| 629 |
+
lockBoard(false);
|
| 630 |
+
resetClocks();
|
| 631 |
+
// if model went first (player is black), model already moved — start player clock
|
| 632 |
+
startClock('player');
|
| 633 |
+
el('pgn-btn') && (el('pgn-btn').disabled = false);
|
| 634 |
+
} catch(e) {
|
| 635 |
+
setStatus('Error: ' + e.message);
|
| 636 |
+
}
|
| 637 |
+
el('new-game-btn').disabled = false;
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
| 641 |
+
const canMove = () => !gameOver && !game.game_over() && (game.turn() === 'w') === playerWhite;
|
| 642 |
+
const myPiece = s => (playerWhite && s[0]==='w') || (!playerWhite && s[0]==='b');
|
| 643 |
+
const myPieceObj = p => (playerWhite && p.color==='w') || (!playerWhite && p.color==='b');
|
| 644 |
+
const isOver = s => /Checkmate|draw|Game over/i.test(s);
|
| 645 |
+
|
| 646 |
+
function getVal(name) {
|
| 647 |
+
const mob = window.innerWidth <= 680;
|
| 648 |
+
return mob ? el(name==='t'?'tm':'km').value : el(name==='t'?'td':'kd').value;
|
| 649 |
+
}
|
| 650 |
+
|
| 651 |
+
function syncS(name, fromMob) {
|
| 652 |
+
const d = el(name==='t'?'td':'kd');
|
| 653 |
+
const m = el(name==='t'?'tm':'km');
|
| 654 |
+
const dv = el(name==='t'?'tv':'kv');
|
| 655 |
+
const mv = el(name==='t'?'tvm':'kvm');
|
| 656 |
+
const fmt = name==='t' ? v => parseFloat(v).toFixed(2) : v => v;
|
| 657 |
+
if (fromMob) { d.value = m.value; }
|
| 658 |
+
else { m.value = d.value; }
|
| 659 |
+
const val = fromMob ? m.value : d.value;
|
| 660 |
+
dv.textContent = fmt(val);
|
| 661 |
+
mv.textContent = fmt(val);
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
function setSide(s) {
|
| 665 |
+
chosenSide = s;
|
| 666 |
+
document.querySelectorAll('.side-btn').forEach(b =>
|
| 667 |
+
b.classList.toggle('active', b.dataset.side === s));
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
const setStatus = msg => el('status-bar').textContent = msg;
|
| 671 |
+
|
| 672 |
+
|
| 673 |
+
function lockBoard(locked) {
|
| 674 |
+
el('board').style.pointerEvents = locked ? 'none' : '';
|
| 675 |
+
el('board').style.opacity = locked ? '0.75' : '';
|
| 676 |
+
updateTurnClass();
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
function updateTurnClass() {
|
| 680 |
+
const myTurn = !gameOver && (game.turn() === 'w') === playerWhite;
|
| 681 |
+
el('board').classList.toggle('my-turn', myTurn);
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
+
function updateHist(text) {
|
| 685 |
+
const btn = el('pgn-btn'); if (btn) btn.disabled = !text;
|
| 686 |
+
const d = el('mh');
|
| 687 |
+
d.innerHTML = (text||'').split('\n').filter(Boolean).map(l=>`<div>${l}</div>`).join('');
|
| 688 |
+
d.scrollTop = d.scrollHeight;
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
function hl(sq, cls) {
|
| 692 |
+
document.querySelectorAll(`[data-square="${sq}"]`).forEach(e => e.classList.add(cls));
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
function clearHL() {
|
| 696 |
+
['hl-from','hl-to','hl-sel','hl-legal','hl-capture','hl-check'].forEach(cls =>
|
| 697 |
+
document.querySelectorAll('.'+cls).forEach(e => e.classList.remove(cls)));
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
function legalDots(sq) {
|
| 701 |
+
game.moves({ square: sq, verbose: true }).forEach(m => {
|
| 702 |
+
hl(m.to, game.get(m.to) ? 'hl-capture' : 'hl-legal');
|
| 703 |
+
});
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
function hlLast(from, to) {
|
| 707 |
+
selectedSq = null;
|
| 708 |
+
lastFrom = from;
|
| 709 |
+
lastTo = to;
|
| 710 |
+
applyHL();
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
+
function mobToggle() {
|
| 714 |
+
el('mob-panel').classList.toggle('on');
|
| 715 |
+
el('mob-overlay').classList.toggle('on');
|
| 716 |
+
}
|
| 717 |
+
function mobClose() {
|
| 718 |
+
el('mob-panel').classList.remove('on');
|
| 719 |
+
el('mob-overlay').classList.remove('on');
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
|
| 723 |
+
// ── Time controls ──────────────────────────────────────────────────────────────
|
| 724 |
+
let tcSecs = 0; // 0 = no limit
|
| 725 |
+
let playerTime = 0; // remaining seconds
|
| 726 |
+
let modelTime = 0;
|
| 727 |
+
let clockTick = null; // setInterval handle
|
| 728 |
+
let clockActive = null; // 'player' | 'model' | null
|
| 729 |
+
|
| 730 |
+
function setTC(secs) {
|
| 731 |
+
tcSecs = secs;
|
| 732 |
+
document.querySelectorAll('.tc-btn').forEach(b =>
|
| 733 |
+
b.classList.toggle('active', parseInt(b.dataset.tc) === secs));
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
function fmtTime(s) {
|
| 737 |
+
if (s <= 0) return '0:00';
|
| 738 |
+
const m = Math.floor(s / 60);
|
| 739 |
+
const sec = Math.floor(s % 60);
|
| 740 |
+
return m + ':' + String(sec).padStart(2, '0');
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
function startClock(who) {
|
| 744 |
+
// who: 'player' or 'model'
|
| 745 |
+
stopClock();
|
| 746 |
+
if (!tcSecs) return;
|
| 747 |
+
clockActive = who;
|
| 748 |
+
updateClockUI();
|
| 749 |
+
clockTick = setInterval(() => {
|
| 750 |
+
if (who === 'player') {
|
| 751 |
+
playerTime = Math.max(0, playerTime - 0.1);
|
| 752 |
+
if (playerTime <= 0) { flagFall('player'); return; }
|
| 753 |
+
} else {
|
| 754 |
+
modelTime = Math.max(0, modelTime - 0.1);
|
| 755 |
+
if (modelTime <= 0) { flagFall('model'); return; }
|
| 756 |
+
}
|
| 757 |
+
updateClockUI();
|
| 758 |
+
}, 100);
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
function stopClock() {
|
| 762 |
+
if (clockTick) { clearInterval(clockTick); clockTick = null; }
|
| 763 |
+
clockActive = null;
|
| 764 |
+
updateClockUI();
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
function flagFall(who) {
|
| 768 |
+
stopClock();
|
| 769 |
+
gameOver = true;
|
| 770 |
+
lockBoard(true);
|
| 771 |
+
setStatus(who === 'player' ? 'Time! Model wins on time.' : 'Time! You win on time.');
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
function resetClocks() {
|
| 775 |
+
stopClock();
|
| 776 |
+
playerTime = tcSecs;
|
| 777 |
+
modelTime = tcSecs;
|
| 778 |
+
updateClockUI();
|
| 779 |
+
el('clocks').classList.toggle('on', tcSecs > 0);
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
function updateClockUI() {
|
| 783 |
+
if (!tcSecs) return;
|
| 784 |
+
// Figure out which clock is on top (opponent) vs bottom (player)
|
| 785 |
+
const playerIsTop = !playerWhite; // board orientation: model is top if player=white
|
| 786 |
+
const topIsPlayer = playerIsTop;
|
| 787 |
+
|
| 788 |
+
const topSecs = topIsPlayer ? playerTime : modelTime;
|
| 789 |
+
const botSecs = topIsPlayer ? modelTime : playerTime;
|
| 790 |
+
const topWho = topIsPlayer ? 'You' : 'Model';
|
| 791 |
+
const botWho = topIsPlayer ? 'Model' : 'You';
|
| 792 |
+
|
| 793 |
+
el('clk-top-lbl').textContent = topWho;
|
| 794 |
+
el('clk-bot-lbl').textContent = botWho;
|
| 795 |
+
el('clk-top-val').textContent = fmtTime(topSecs);
|
| 796 |
+
el('clk-bot-val').textContent = fmtTime(botSecs);
|
| 797 |
+
|
| 798 |
+
const topActive = (clockActive === 'player' && topIsPlayer) || (clockActive === 'model' && !topIsPlayer);
|
| 799 |
+
el('clk-top').className = 'clock-box' + (topActive ? ' active' : '') + (topSecs < 10 && tcSecs ? ' low' : '');
|
| 800 |
+
el('clk-bot').className = 'clock-box' + (!topActive && clockActive ? ' active' : '') + (botSecs < 10 && tcSecs ? ' low' : '');
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
// ── PGN export ─────────────────────────────────────────────────────────────────
|
| 804 |
+
let pgnMoves = []; // SAN move list for PGN
|
| 805 |
+
|
| 806 |
+
function buildPGN() {
|
| 807 |
+
const now = new Date();
|
| 808 |
+
const date = now.toISOString().slice(0,10).replace(/-/g,'.');
|
| 809 |
+
const result = gameOver ? (
|
| 810 |
+
el('status-bar').textContent.includes('wins') ?
|
| 811 |
+
(el('status-bar').textContent.includes('Model') ? (playerWhite ? '0-1' : '1-0') : (playerWhite ? '1-0' : '0-1')) :
|
| 812 |
+
'1/2-1/2'
|
| 813 |
+
) : '*';
|
| 814 |
+
|
| 815 |
+
const header = [
|
| 816 |
+
'[Event "LCM Chess"]',
|
| 817 |
+
'[Site "https://huggingface.co/spaces/MostLime/lcm-chess"]',
|
| 818 |
+
`[Date "${date}"]`,
|
| 819 |
+
`[White "${playerWhite ? 'You' : 'LCM'}"]`,
|
| 820 |
+
`[Black "${playerWhite ? 'LCM' : 'You'}"]`,
|
| 821 |
+
`[Result "${result}"]`,
|
| 822 |
+
`[TimeControl "${tcSecs ? tcSecs+'+0' : '-'}"]`,
|
| 823 |
+
].join('\n');
|
| 824 |
+
|
| 825 |
+
// Build SAN from move history using chess.js
|
| 826 |
+
const tmp = new Chess();
|
| 827 |
+
const sans = [];
|
| 828 |
+
for (const uci of moveHistory) {
|
| 829 |
+
const from = uci.slice(0,2), to = uci.slice(2,4), promo = uci[4] || undefined;
|
| 830 |
+
const mv = tmp.move({ from, to, promotion: promo });
|
| 831 |
+
if (mv) sans.push(mv.san);
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
let body = '';
|
| 835 |
+
for (let i = 0; i < sans.length; i++) {
|
| 836 |
+
if (i % 2 === 0) body += (i/2+1) + '. ';
|
| 837 |
+
body += sans[i] + ' ';
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
return header + '\n\n' + body.trim() + ' ' + result;
|
| 841 |
+
}
|
| 842 |
+
|
| 843 |
+
function copyPGN() {
|
| 844 |
+
if (!moveHistory.length) return;
|
| 845 |
+
const pgn = buildPGN();
|
| 846 |
+
// Try native clipboard, fall back to download
|
| 847 |
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
| 848 |
+
navigator.clipboard.writeText(pgn).then(() => {
|
| 849 |
+
const btn = el('pgn-btn');
|
| 850 |
+
const orig = btn.textContent;
|
| 851 |
+
btn.textContent = 'Copied!';
|
| 852 |
+
setTimeout(() => btn.textContent = orig, 1500);
|
| 853 |
+
}).catch(() => downloadPGN(pgn));
|
| 854 |
+
} else {
|
| 855 |
+
downloadPGN(pgn);
|
| 856 |
+
}
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
function downloadPGN(pgn) {
|
| 860 |
+
const a = document.createElement('a');
|
| 861 |
+
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(pgn);
|
| 862 |
+
a.download = 'lcm-chess.pgn';
|
| 863 |
+
a.click();
|
| 864 |
+
}
|
| 865 |
+
|
| 866 |
+
// Delegated click/touch on board-wrap — survives board.destroy()/reinit
|
| 867 |
+
function handleBoardTap(e) {
|
| 868 |
+
if (e.type === 'touchend') e.preventDefault();
|
| 869 |
+
// If the drag system just handled this same physical interaction, skip
|
| 870 |
+
if (suppressNextClick) { suppressNextClick = false; return; }
|
| 871 |
+
const sqEl = (e.changedTouches ? e.changedTouches[0].target : e.target).closest('[data-square]');
|
| 872 |
+
if (!sqEl) return;
|
| 873 |
+
const sq = sqEl.getAttribute('data-square');
|
| 874 |
+
const piece = game.get(sq);
|
| 875 |
+
const pStr = piece ? (piece.color === 'w' ? 'w' : 'b') + piece.type.toUpperCase() : null;
|
| 876 |
+
onSquareClick(sq, pStr);
|
| 877 |
+
}
|
| 878 |
+
el('board-wrap').addEventListener('click', handleBoardTap);
|
| 879 |
+
el('board-wrap').addEventListener('touchend', handleBoardTap, { passive: false });
|
| 880 |
+
|
| 881 |
+
window.addEventListener('load', () => { initBoard('white'); newGame(); });
|
| 882 |
+
</script>
|
| 883 |
+
</body>
|
| 884 |
+
</html>
|