feat: add professional web 2D Gomoku client

- Add StaticFileHandler to serve static files from Netty WS server
- Create single-page HTML with 8 screens (nickname, lobby, PVP/PVE
  menus, room list, waiting room, game, game over)
- Dark theme CSS with responsive layout and animations
- WebSocket connection with heartbeat and auto-reconnect toast
- Event bus state machine for screen transitions
- Canvas board: wood texture, grid, gradient stones, hover preview,
  last-move indicator, placement animation (easeOutBack)
- Full lobby: create/join rooms, room list, spectator mode
- Move history panel with coordinate display
- Game over with personalized win/lose/draw result
- Web Audio API sound effects (no external files needed)
- Toast notification system for errors
This commit is contained in:
2026-04-10 09:03:37 +07:00
parent c8a933a786
commit 77e141c017
10 changed files with 1913 additions and 1 deletions
+2 -1
View File
@@ -11,7 +11,8 @@
"Bash(where mvn:*)",
"Bash(where mvn.cmd)",
"Bash(mvn clean:*)",
"Bash(mvn test:*)"
"Bash(mvn test:*)",
"Bash(python3:*)"
],
"deny": []
}
@@ -0,0 +1,124 @@
package org.nico.ratel.landlords.server.handler;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.handler.codec.http.*;
import java.io.*;
import java.util.HashMap;
import java.util.Map;
/**
* Serves static files from classpath resources under the "static/" directory.
* Passes /ratel requests downstream to the WebSocket handler.
*/
@ChannelHandler.Sharable
public class StaticFileHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
private static final Map<String, String> MIME_TYPES = new HashMap<>();
static {
MIME_TYPES.put(".html", "text/html; charset=UTF-8");
MIME_TYPES.put(".css", "text/css; charset=UTF-8");
MIME_TYPES.put(".js", "application/javascript; charset=UTF-8");
MIME_TYPES.put(".json", "application/json; charset=UTF-8");
MIME_TYPES.put(".mp3", "audio/mpeg");
MIME_TYPES.put(".jpg", "image/jpeg");
MIME_TYPES.put(".png", "image/png");
MIME_TYPES.put(".svg", "image/svg+xml");
MIME_TYPES.put(".ico", "image/x-icon");
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) throws Exception {
String uri = msg.uri();
// Pass WebSocket upgrade and /ratel requests to next handler
if (uri.startsWith("/ratel")) {
ctx.fireChannelRead(msg.retain());
return;
}
// Sanitize: strip query string
int queryIndex = uri.indexOf('?');
if (queryIndex >= 0) {
uri = uri.substring(0, queryIndex);
}
// Reject path traversal attempts
if (uri.contains("..")) {
sendError(ctx, HttpResponseStatus.FORBIDDEN, msg);
return;
}
// Map root to index.html
if ("/".equals(uri)) {
uri = "/index.html";
}
String resourcePath = "static" + uri;
InputStream in = getClass().getClassLoader().getResourceAsStream(resourcePath);
if (in == null) {
sendError(ctx, HttpResponseStatus.NOT_FOUND, msg);
return;
}
byte[] bytes;
try {
bytes = readAllBytes(in);
} finally {
in.close();
}
String contentType = resolveContentType(uri);
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.OK,
Unpooled.wrappedBuffer(bytes)
);
response.headers()
.set(HttpHeaderNames.CONTENT_TYPE, contentType)
.set(HttpHeaderNames.CONTENT_LENGTH, bytes.length);
boolean keepAlive = HttpUtil.isKeepAlive(msg);
if (keepAlive) {
response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
ctx.writeAndFlush(response);
} else {
response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
}
private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status, FullHttpRequest req) {
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
status,
Unpooled.wrappedBuffer((status.toString()).getBytes())
);
response.headers()
.set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8")
.set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
private String resolveContentType(String uri) {
int dotIndex = uri.lastIndexOf('.');
if (dotIndex >= 0) {
String ext = uri.substring(dotIndex).toLowerCase();
return MIME_TYPES.getOrDefault(ext, "application/octet-stream");
}
return "application/octet-stream";
}
private byte[] readAllBytes(InputStream in) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int read;
while ((read = in.read(chunk)) != -1) {
buffer.write(chunk, 0, read);
}
return buffer.toByteArray();
}
}
@@ -19,6 +19,7 @@ import org.nico.ratel.landlords.print.SimplePrinter;
import org.nico.ratel.landlords.server.ServerContains;
import org.nico.ratel.landlords.server.handler.ProtobufTransferHandler;
import org.nico.ratel.landlords.server.handler.StaticFileHandler;
import org.nico.ratel.landlords.server.handler.WebsocketTransferHandler;
import org.nico.ratel.landlords.server.timer.RoomClearTask;
@@ -44,6 +45,7 @@ public class WebsocketProxy implements Proxy{
.addLast(new HttpServerCodec())
.addLast(new ChunkedWriteHandler())
.addLast(new HttpObjectAggregator(8192))
.addLast(new StaticFileHandler())
.addLast("ws", new WebSocketServerProtocolHandler("/ratel"))
.addLast(new WebsocketTransferHandler());
}
@@ -0,0 +1,757 @@
/* ============================================================
GOMOKU — Dark Theme Stylesheet
Color palette:
bg: #1a1a2e
surface: #16213e
primary: #e94560
hover: #d63851
text: #eee
muted: #888
board wood: #dcb35c
success: #4ecca3
error: #ff6b6b
============================================================ */
/* ── Reset ── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Base ── */
html {
font-size: 16px;
}
body {
background-color: #1a1a2e;
color: #eee;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
min-height: 100vh;
display: flex;
justify-content: center;
}
/* ── App shell ── */
.app-container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 16px 32px;
display: flex;
flex-direction: column;
}
/* ── Header ── */
.game-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #ffffff12;
margin-bottom: 32px;
}
.logo {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: 2px;
color: #e94560;
text-transform: uppercase;
}
.header-info {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9rem;
color: #888;
}
/* Connection status dot */
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #888;
display: inline-block;
transition: background 0.3s;
}
.status-dot.connected { background: #4ecca3; }
.status-dot.disconnected { background: #ff6b6b; }
/* ── Screens ── */
#screens-container {
flex: 1;
position: relative;
}
.screen {
display: none;
flex-direction: column;
align-items: center;
opacity: 0;
transform: translateY(12px);
transition: opacity 0.25s ease, transform 0.25s ease;
}
.screen.active {
display: flex;
opacity: 1;
transform: translateY(0);
}
/* ── Cards / content wrappers ── */
.screen-content {
width: 100%;
}
.centered-card {
max-width: 480px;
margin: 0 auto;
background: #16213e;
border-radius: 16px;
padding: 40px 36px;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
box-shadow: 0 8px 40px #00000060;
}
.wide-card {
max-width: 800px;
margin: 0 auto;
background: #16213e;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 40px #00000060;
}
/* ── Typography ── */
.screen-title {
font-size: 1.8rem;
font-weight: 700;
color: #eee;
text-align: center;
}
.screen-subtitle {
font-size: 1rem;
color: #888;
text-align: center;
line-height: 1.5;
}
.accent-text {
color: #e94560;
font-weight: 600;
}
.mono {
font-family: 'Courier New', Courier, monospace;
letter-spacing: 2px;
}
/* ── Forms ── */
.form-group {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
}
.form-label {
font-size: 0.85rem;
font-weight: 600;
color: #888;
text-transform: uppercase;
letter-spacing: 1px;
}
.form-input {
width: 100%;
padding: 12px 16px;
background: #0f3460;
border: 2px solid #ffffff1a;
border-radius: 8px;
color: #eee;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.form-input::placeholder {
color: #555;
}
.form-input:focus {
border-color: #e94560;
box-shadow: 0 0 0 3px #e9456025;
}
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 24px;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
border: none;
cursor: pointer;
text-decoration: none;
transition: background 0.15s, transform 0.1s, box-shadow 0.15s;
user-select: none;
white-space: nowrap;
}
.btn:active { transform: scale(0.97); }
.btn-primary {
background: #e94560;
color: #fff;
}
.btn-primary:hover { background: #d63851; box-shadow: 0 4px 16px #e9456040; }
.btn-secondary {
background: transparent;
color: #e94560;
border: 2px solid #e94560;
}
.btn-secondary:hover { background: #e9456015; }
.btn-ghost {
background: transparent;
color: #888;
border: 1px solid #ffffff20;
}
.btn-ghost:hover { color: #eee; border-color: #ffffff50; }
.btn-danger {
background: #ff6b6b;
color: #fff;
}
.btn-danger:hover { background: #e85555; }
.btn-large {
width: 280px;
padding: 18px 24px;
font-size: 1.1rem;
border-radius: 12px;
}
.btn-icon {
font-size: 1.2em;
}
.btn-sm {
padding: 6px 14px;
font-size: 0.85rem;
}
/* ── Lobby buttons ── */
.lobby-buttons {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
width: 100%;
}
.menu-buttons {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
margin-bottom: 12px;
}
/* ── Difficulty cards ── */
.difficulty-cards {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.difficulty-card {
background: #16213e;
border: 2px solid #ffffff1a;
border-radius: 14px;
padding: 28px 20px;
width: 170px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
cursor: pointer;
transition: border-color 0.2s, transform 0.15s, box-shadow 0.2s;
color: #eee;
font-family: inherit;
}
.difficulty-card:hover {
border-color: #e94560;
transform: translateY(-4px);
box-shadow: 0 8px 24px #e9456030;
}
.difficulty-card:active { transform: translateY(-1px); }
.difficulty-icon { font-size: 2rem; }
.difficulty-name {
font-size: 1.1rem;
font-weight: 700;
}
.difficulty-desc {
font-size: 0.8rem;
color: #888;
text-align: center;
line-height: 1.4;
}
/* ── Room list ── */
.room-list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 12px;
}
.room-list-actions {
display: flex;
gap: 10px;
}
.table-wrapper {
overflow-x: auto;
border-radius: 8px;
border: 1px solid #ffffff15;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.data-table th {
background: #0f3460;
color: #888;
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.5px;
padding: 12px 16px;
text-align: left;
}
.data-table td {
padding: 12px 16px;
border-top: 1px solid #ffffff0e;
color: #eee;
vertical-align: middle;
}
.data-table tr:hover td {
background: #ffffff07;
}
.room-status-waiting { color: #4ecca3; }
.room-status-playing { color: #dcb35c; }
.room-status-full { color: #ff6b6b; }
.empty-state {
text-align: center;
padding: 60px 20px;
color: #888;
}
.empty-icon {
font-size: 3rem;
display: block;
margin-bottom: 12px;
}
/* ── Waiting room ── */
.room-id-display {
font-size: 1rem;
color: #888;
letter-spacing: 1px;
}
.waiting-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
padding: 32px 0;
}
.waiting-text {
color: #888;
font-size: 1rem;
}
/* CSS spinner */
.spinner {
width: 48px;
height: 48px;
border: 4px solid #ffffff15;
border-top-color: #e94560;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Game layout ── */
.game-layout {
display: flex;
gap: 20px;
width: 100%;
justify-content: center;
align-items: flex-start;
padding: 0 0 16px;
}
.game-sidebar {
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.game-board-container {
flex: 1;
max-width: 580px;
aspect-ratio: 1 / 1;
position: relative;
}
#game-canvas {
width: 100%;
height: 100%;
display: block;
border-radius: 8px;
cursor: crosshair;
}
/* ── Player cards ── */
.player-card {
background: #16213e;
border: 2px solid #ffffff15;
border-radius: 12px;
padding: 16px 14px;
display: flex;
align-items: center;
gap: 12px;
transition: border-color 0.25s, box-shadow 0.25s;
position: relative;
}
.player-card.active {
border-color: #e94560;
box-shadow: 0 0 16px #e9456030;
}
.player-stone {
width: 28px;
height: 28px;
border-radius: 50%;
flex-shrink: 0;
}
.player-stone-black {
background: radial-gradient(circle at 35% 35%, #555, #111);
box-shadow: 0 2px 6px #00000080;
}
.player-stone-white {
background: radial-gradient(circle at 35% 35%, #fff, #ccc);
box-shadow: 0 2px 6px #00000050;
}
.player-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.player-name {
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 120px;
}
.player-label {
font-size: 0.75rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.turn-indicator {
position: absolute;
right: 12px;
width: 8px;
height: 8px;
border-radius: 50%;
background: transparent;
transition: background 0.25s;
}
.player-card.active .turn-indicator {
background: #e94560;
box-shadow: 0 0 6px #e94560;
animation: pulse-dot 1.2s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
.vs-divider {
text-align: center;
font-size: 0.75rem;
font-weight: 700;
color: #ffffff30;
letter-spacing: 2px;
}
/* Move history */
.sidebar-title {
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 1px;
color: #888;
margin-bottom: 8px;
}
.move-list {
max-height: 420px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 4px;
scrollbar-width: thin;
scrollbar-color: #e94560 #16213e;
}
.move-list::-webkit-scrollbar { width: 4px; }
.move-list::-webkit-scrollbar-track { background: #16213e; border-radius: 2px; }
.move-list::-webkit-scrollbar-thumb { background: #e94560; border-radius: 2px; }
.move-item {
background: #16213e;
border-radius: 6px;
padding: 6px 10px;
font-size: 0.8rem;
color: #888;
display: flex;
align-items: center;
gap: 8px;
}
.move-item .move-num {
color: #555;
min-width: 20px;
}
.move-item .move-stone {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.move-stone-black { background: #333; border: 1px solid #666; }
.move-stone-white { background: #ddd; border: 1px solid #aaa; }
/* ── Game controls bar ── */
.game-controls {
display: flex;
gap: 12px;
justify-content: center;
padding: 8px 0;
}
/* ── Game over ── */
.game-over-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
padding: 48px 24px;
animation: fade-in-up 0.4s ease both;
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.result-text {
font-size: 3rem;
font-weight: 800;
letter-spacing: 2px;
line-height: 1;
}
.result-win { color: #4ecca3; }
.result-lose { color: #ff6b6b; }
.result-draw { color: #dcb35c; }
.winner-display {
font-size: 1.2rem;
color: #888;
}
.winner-name {
color: #eee;
font-weight: 600;
}
.game-over-buttons {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
}
/* ── Toast notifications ── */
#toast-container {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9999;
pointer-events: none;
}
.toast {
min-width: 240px;
max-width: 360px;
padding: 14px 18px;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 500;
color: #fff;
pointer-events: auto;
animation: toast-in 0.3s ease both;
box-shadow: 0 4px 20px #00000060;
}
.toast.toast-hide {
animation: toast-out 0.3s ease forwards;
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(40px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(40px); }
}
.toast-info { background: #0f3460; border-left: 4px solid #4ecca3; }
.toast-success { background: #1a3a2a; border-left: 4px solid #4ecca3; }
.toast-error { background: #3a1a1a; border-left: 4px solid #ff6b6b; }
.toast-warning { background: #3a2e10; border-left: 4px solid #dcb35c; }
/* ── Responsive — tablet / mobile ── */
@media (max-width: 900px) {
.game-layout {
flex-direction: column;
align-items: center;
}
.game-sidebar-left {
width: 100%;
flex-direction: row;
justify-content: center;
gap: 12px;
}
.game-sidebar-left .vs-divider {
writing-mode: horizontal-tb;
align-self: center;
}
.game-sidebar-right {
display: none;
}
.game-board-container {
max-width: 95vw;
}
.player-card {
flex: 1;
max-width: 220px;
}
}
@media (max-width: 600px) {
.centered-card {
padding: 28px 20px;
}
.btn-large {
width: 100%;
max-width: 320px;
}
.difficulty-cards {
flex-direction: column;
align-items: center;
}
.difficulty-card {
width: 100%;
max-width: 280px;
flex-direction: row;
text-align: left;
padding: 18px;
}
.difficulty-desc {
text-align: left;
}
.game-over-buttons {
flex-direction: column;
align-items: center;
}
.game-controls {
flex-wrap: wrap;
}
.result-text {
font-size: 2.2rem;
}
}
@@ -0,0 +1,233 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Gomoku</title>
<link rel="stylesheet" href="css/style.css" />
</head>
<body>
<div class="app-container">
<header class="game-header">
<h1 class="logo">Gomoku</h1>
<div class="header-info">
<span id="connection-status" class="status-dot"></span>
<span id="header-nickname"></span>
</div>
</header>
<main id="screens-container">
<!-- Screen 1: Nickname entry -->
<section class="screen active" id="screen-nickname">
<div class="screen-content centered-card">
<h2 class="screen-title">Welcome to Gomoku</h2>
<p class="screen-subtitle">Five in a row wins. Choose your name and play.</p>
<form id="nickname-form" class="form-group" autocomplete="off">
<label for="nickname-input" class="form-label">Your Nickname</label>
<input
type="text"
id="nickname-input"
class="form-input"
placeholder="Enter nickname…"
maxlength="20"
required
/>
<button type="submit" class="btn btn-primary btn-large" id="btn-play">
Play
</button>
</form>
</div>
</section>
<!-- Screen 2: Lobby -->
<section class="screen" id="screen-lobby">
<div class="screen-content centered-card">
<h2 class="screen-title">Lobby</h2>
<p class="screen-subtitle">
Welcome, <span id="lobby-nickname" class="accent-text"></span>!
</p>
<div class="lobby-buttons">
<button class="btn btn-primary btn-large" id="btn-goto-pvp">
<span class="btn-icon">&#9876;</span>
Player vs Player
</button>
<button class="btn btn-primary btn-large" id="btn-goto-pve">
<span class="btn-icon">&#129302;</span>
Player vs AI
</button>
</div>
</div>
</section>
<!-- Screen 3: PvP Menu -->
<section class="screen" id="screen-pvp-menu">
<div class="screen-content centered-card">
<h2 class="screen-title">Player vs Player</h2>
<div class="menu-buttons">
<button class="btn btn-primary btn-large" id="btn-create-room">
Create Room
</button>
<button class="btn btn-secondary btn-large" id="btn-join-room">
Join Room
</button>
</div>
<button class="btn btn-ghost" id="btn-pvp-back">&#8592; Back</button>
</div>
</section>
<!-- Screen 4: PvE Difficulty Menu -->
<section class="screen" id="screen-pve-menu">
<div class="screen-content">
<h2 class="screen-title">Player vs AI</h2>
<p class="screen-subtitle">Select difficulty</p>
<div class="difficulty-cards">
<button class="difficulty-card" id="btn-pve-easy" data-difficulty="easy">
<span class="difficulty-icon">&#128994;</span>
<span class="difficulty-name">Easy</span>
<span class="difficulty-desc">Random moves — great for beginners</span>
</button>
<button class="difficulty-card" id="btn-pve-medium" data-difficulty="medium">
<span class="difficulty-icon">&#128993;</span>
<span class="difficulty-name">Medium</span>
<span class="difficulty-desc">Defensive AI that blocks threats</span>
</button>
<button class="difficulty-card" id="btn-pve-hard" data-difficulty="hard">
<span class="difficulty-icon">&#128308;</span>
<span class="difficulty-name">Hard</span>
<span class="difficulty-desc">Minimax with alpha-beta pruning</span>
</button>
</div>
<button class="btn btn-ghost" id="btn-pve-back">&#8592; Back</button>
</div>
</section>
<!-- Screen 5: Room List -->
<section class="screen" id="screen-room-list">
<div class="screen-content wide-card">
<div class="room-list-header">
<h2 class="screen-title">Available Rooms</h2>
<div class="room-list-actions">
<button class="btn btn-secondary" id="btn-refresh-rooms">&#8635; Refresh</button>
<button class="btn btn-ghost" id="btn-room-list-back">&#8592; Back</button>
</div>
</div>
<div id="room-list-empty" class="empty-state" style="display:none;">
<span class="empty-icon">&#127758;</span>
<p>No rooms available. Create one!</p>
</div>
<div class="table-wrapper">
<table class="data-table" id="room-table">
<thead>
<tr>
<th>Room ID</th>
<th>Owner</th>
<th>Players</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="room-table-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</section>
<!-- Screen 6: Waiting Room -->
<section class="screen" id="screen-waiting-room">
<div class="screen-content centered-card">
<h2 class="screen-title">Waiting Room</h2>
<div class="room-id-display">
Room ID: <span id="waiting-room-id" class="accent-text mono"></span>
</div>
<div class="waiting-area">
<div class="spinner"></div>
<p class="waiting-text">Waiting for opponent…</p>
</div>
<button class="btn btn-danger" id="btn-leave-room">Leave Room</button>
</div>
</section>
<!-- Screen 7: Game -->
<section class="screen" id="screen-game">
<div class="game-layout">
<!-- Left sidebar: players -->
<aside class="game-sidebar game-sidebar-left">
<div class="player-card" id="player-card-black">
<div class="player-stone player-stone-black"></div>
<div class="player-info">
<span class="player-name" id="player-black-name">Player 1</span>
<span class="player-label">Black</span>
</div>
<div class="turn-indicator" id="turn-indicator-black" aria-hidden="true"></div>
</div>
<div class="vs-divider">VS</div>
<div class="player-card" id="player-card-white">
<div class="player-stone player-stone-white"></div>
<div class="player-info">
<span class="player-name" id="player-white-name">Player 2</span>
<span class="player-label">White</span>
</div>
<div class="turn-indicator" id="turn-indicator-white" aria-hidden="true"></div>
</div>
</aside>
<!-- Center: board -->
<div class="game-board-container">
<canvas id="game-canvas" aria-label="Gomoku board"></canvas>
</div>
<!-- Right sidebar: move history -->
<aside class="game-sidebar game-sidebar-right">
<h3 class="sidebar-title">Move History</h3>
<div class="move-list" id="move-list">
<!-- Populated by JS -->
</div>
</aside>
</div>
<!-- Bottom control bar -->
<div class="game-controls">
<button class="btn btn-danger" id="btn-exit-game">Exit Game</button>
<button class="btn btn-secondary" id="btn-toggle-sound" aria-label="Toggle sound">
<span id="sound-icon">&#128266;</span> Sound
</button>
</div>
</section>
<!-- Screen 8: Game Over -->
<section class="screen" id="screen-game-over">
<div class="game-over-content">
<div id="game-over-result" class="result-text">Draw</div>
<div class="winner-display">
<span id="game-over-winner" class="winner-name"></span>
</div>
<div class="game-over-buttons">
<button class="btn btn-primary btn-large" id="btn-rematch">Rematch</button>
<button class="btn btn-secondary btn-large" id="btn-exit-to-lobby">Exit to Lobby</button>
</div>
</div>
</section>
</main>
<!-- Global toast container -->
<div id="toast-container" aria-live="polite" aria-atomic="false"></div>
</div>
<!-- JS modules — order matters -->
<script src="js/game-state.js"></script>
<script src="js/game-connection.js"></script>
<script src="js/game-board.js"></script>
<script src="js/game-ui.js"></script>
<script src="js/game-audio.js"></script>
</body>
</html>
@@ -0,0 +1,85 @@
/**
* GameAudio — Sound effects for stone placement and game results.
* Uses Web Audio API to generate tones (no external audio files needed).
*/
var GameAudio = {
ctx: null,
muted: false,
unlocked: false,
init: function() {
var self = this;
// Unlock audio context on first user click
document.addEventListener('click', function unlock() {
if (!self.unlocked) {
self.ctx = new (window.AudioContext || window.webkitAudioContext)();
self.unlocked = true;
}
document.removeEventListener('click', unlock);
});
var btn = document.getElementById('btn-toggle-sound');
if (btn) {
btn.addEventListener('click', function() { self.toggle(); });
}
},
_playTone: function(frequency, duration, type) {
if (this.muted || !this.ctx) return;
try {
var osc = this.ctx.createOscillator();
var gain = this.ctx.createGain();
osc.connect(gain);
gain.connect(this.ctx.destination);
osc.type = type || 'sine';
osc.frequency.value = frequency;
gain.gain.setValueAtTime(0.15, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, this.ctx.currentTime + duration);
osc.start(this.ctx.currentTime);
osc.stop(this.ctx.currentTime + duration);
} catch (e) { /* ignore audio errors */ }
},
playStone: function() {
this._playTone(800, 0.08, 'sine');
},
playWin: function() {
var self = this;
this._playTone(523, 0.15, 'sine');
setTimeout(function() { self._playTone(659, 0.15, 'sine'); }, 150);
setTimeout(function() { self._playTone(784, 0.3, 'sine'); }, 300);
},
playLose: function() {
var self = this;
this._playTone(400, 0.2, 'triangle');
setTimeout(function() { self._playTone(300, 0.3, 'triangle'); }, 200);
},
toggle: function() {
this.muted = !this.muted;
var icon = document.getElementById('sound-icon');
if (icon) icon.innerHTML = this.muted ? '&#128264;' : '&#128266;';
}
};
// Register event handlers
GameState.on('CODE_GAME_MOVE_SUCCESS', function() {
GameAudio.playStone();
});
GameState.on('CODE_GAME_OVER', function(data) {
if (data.result === 'DRAW') {
GameAudio.playLose();
} else if (data.winnerNickname === GameState.nickname) {
GameAudio.playWin();
} else {
GameAudio.playLose();
}
});
document.addEventListener('DOMContentLoaded', function() {
GameAudio.init();
});
@@ -0,0 +1,226 @@
/**
* GameBoard — Canvas-based 15x15 Gomoku board rendering.
* Draws wood background, grid, stones with gradients, hover preview,
* last-move indicator, and placement animation.
*/
var GameBoard = {
canvas: null,
ctx: null,
cellSize: 0,
PADDING: 40,
BOARD_SIZE: 15,
hoverPos: null,
animating: null, // {row, col, piece, start}
init: function() {
this.canvas = document.getElementById('game-canvas');
this.ctx = this.canvas.getContext('2d');
this.resize();
var self = this;
window.addEventListener('resize', function() { self.resize(); });
this.canvas.addEventListener('click', function(e) { self._handleClick(e); });
this.canvas.addEventListener('mousemove', function(e) { self._handleHover(e); });
this.canvas.addEventListener('mouseleave', function() {
self.hoverPos = null;
self.draw();
});
},
resize: function() {
var container = this.canvas.parentElement;
var size = Math.min(container.clientWidth, container.clientHeight, 700);
var dpr = window.devicePixelRatio || 1;
this.canvas.width = size * dpr;
this.canvas.height = size * dpr;
this.canvas.style.width = size + 'px';
this.canvas.style.height = size + 'px';
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.cellSize = (size - 2 * this.PADDING) / (this.BOARD_SIZE - 1);
this.draw();
},
_gridX: function(col) { return this.PADDING + col * this.cellSize; },
_gridY: function(row) { return this.PADDING + row * this.cellSize; },
draw: function() {
var ctx = this.ctx;
var size = this.canvas.width / (window.devicePixelRatio || 1);
ctx.clearRect(0, 0, size, size);
this._drawBackground(ctx, size);
this._drawGrid(ctx);
this._drawStarPoints(ctx);
this._drawLabels(ctx);
this._drawStones(ctx);
this._drawHover(ctx);
},
_drawBackground: function(ctx, size) {
ctx.fillStyle = '#dcb35c';
ctx.fillRect(0, 0, size, size);
// Subtle wood grain lines
ctx.strokeStyle = 'rgba(139, 105, 20, 0.15)';
ctx.lineWidth = 1;
for (var i = 0; i < size; i += 7) {
ctx.beginPath(); ctx.moveTo(0, i); ctx.lineTo(size, i); ctx.stroke();
}
},
_drawGrid: function(ctx) {
ctx.strokeStyle = '#8b6914';
ctx.lineWidth = 1;
for (var i = 0; i < this.BOARD_SIZE; i++) {
ctx.beginPath();
ctx.moveTo(this._gridX(i), this._gridY(0));
ctx.lineTo(this._gridX(i), this._gridY(this.BOARD_SIZE - 1));
ctx.stroke();
ctx.beginPath();
ctx.moveTo(this._gridX(0), this._gridY(i));
ctx.lineTo(this._gridX(this.BOARD_SIZE - 1), this._gridY(i));
ctx.stroke();
}
},
_drawStarPoints: function(ctx) {
var pts = [[3,3],[3,11],[7,7],[11,3],[11,11]];
ctx.fillStyle = '#8b6914';
for (var i = 0; i < pts.length; i++) {
ctx.beginPath();
ctx.arc(this._gridX(pts[i][1]), this._gridY(pts[i][0]), 4, 0, Math.PI * 2);
ctx.fill();
}
},
_drawLabels: function(ctx) {
ctx.fillStyle = '#5a4510';
ctx.font = '11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
for (var i = 0; i < this.BOARD_SIZE; i++) {
var letter = String.fromCharCode(65 + i);
ctx.fillText(letter, this._gridX(i), this.PADDING - 20);
ctx.fillText(String(i + 1), this.PADDING - 22, this._gridY(i));
}
},
_drawStone: function(ctx, row, col, piece, alpha) {
var x = this._gridX(col);
var y = this._gridY(row);
var r = this.cellSize * 0.43;
ctx.save();
ctx.globalAlpha = (alpha !== undefined) ? alpha : 1;
ctx.shadowColor = 'rgba(0,0,0,0.4)';
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 2;
var grad;
if (piece === 'BLACK') {
grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r);
grad.addColorStop(0, '#555');
grad.addColorStop(1, '#111');
} else {
grad = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, r * 0.1, x, y, r);
grad.addColorStop(0, '#fff');
grad.addColorStop(1, '#ccc');
}
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
if (piece === 'WHITE') {
ctx.strokeStyle = '#999';
ctx.lineWidth = 0.5;
ctx.stroke();
}
ctx.restore();
},
_drawStones: function(ctx) {
var moves = GameState.gameData.moves;
for (var i = 0; i < moves.length; i++) {
this._drawStone(ctx, moves[i].row, moves[i].col, moves[i].piece, 1);
}
// Last move indicator
if (moves.length > 0) {
var last = moves[moves.length - 1];
ctx.fillStyle = '#e94560';
ctx.beginPath();
ctx.arc(this._gridX(last.col), this._gridY(last.row), 4, 0, Math.PI * 2);
ctx.fill();
}
},
_drawHover: function(ctx) {
if (!this.hoverPos || !GameState.isMyTurn()) return;
var r = this.hoverPos.row, c = this.hoverPos.col;
var occupied = GameState.gameData.moves.some(function(m) { return m.row === r && m.col === c; });
if (occupied) return;
var piece = GameState.isBlack ? 'BLACK' : 'WHITE';
this._drawStone(ctx, r, c, piece, 0.4);
},
_toGrid: function(px, offset) {
return Math.round((px - this.PADDING) / this.cellSize);
},
_handleClick: function(e) {
if (GameState.isSpectator || !GameState.isMyTurn()) return;
var rect = this.canvas.getBoundingClientRect();
var col = this._toGrid(e.clientX - rect.left);
var row = this._toGrid(e.clientY - rect.top);
if (row < 0 || row >= this.BOARD_SIZE || col < 0 || col >= this.BOARD_SIZE) return;
var occupied = GameState.gameData.moves.some(function(m) { return m.row === row && m.col === col; });
if (occupied) return;
GameConnection.send('CODE_GAME_MOVE', { row: row, col: col });
},
_handleHover: function(e) {
var rect = this.canvas.getBoundingClientRect();
var col = this._toGrid(e.clientX - rect.left);
var row = this._toGrid(e.clientY - rect.top);
if (row < 0 || row >= this.BOARD_SIZE || col < 0 || col >= this.BOARD_SIZE) {
if (this.hoverPos) { this.hoverPos = null; this.draw(); }
return;
}
if (!this.hoverPos || this.hoverPos.row !== row || this.hoverPos.col !== col) {
this.hoverPos = { row: row, col: col };
this.draw();
}
},
animateStone: function(row, col, piece) {
var self = this;
var start = performance.now();
var duration = 150;
function frame(now) {
var t = Math.min((now - start) / duration, 1);
// easeOutBack
var s = 1.4;
var p = t - 1;
var scale = p * p * ((s + 1) * p + s) + 1;
self.draw();
// Draw animated stone on top
var ctx = self.ctx;
var x = self._gridX(col);
var y = self._gridY(row);
ctx.save();
ctx.translate(x, y);
ctx.scale(scale, scale);
ctx.translate(-x, -y);
self._drawStone(ctx, row, col, piece, 1);
ctx.restore();
if (t < 1) requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
}
};
// Register event handlers
GameState.on('CODE_GAME_STARTING', function() {
setTimeout(function() { GameBoard.init(); }, 50);
});
GameState.on('CODE_GAME_MOVE_SUCCESS', function(data) {
GameBoard.animateStone(data.row, data.col, data.piece);
});
@@ -0,0 +1,85 @@
/**
* GameConnection — WebSocket transport layer.
* Connects to server, sends/receives JSON messages, handles heartbeat.
*/
var GameConnection = {
ws: null,
_heartbeatTimer: null,
connect: function() {
var protocol = (location.protocol === 'https:') ? 'wss://' : 'ws://';
var url = protocol + location.host + '/ratel';
this.ws = new WebSocket(url);
var self = this;
this.ws.onopen = function() {
console.log('WebSocket connected');
document.getElementById('connection-status').classList.add('connected');
self._startHeartbeat();
};
this.ws.onmessage = function(event) {
try {
var msg = JSON.parse(event.data);
var code = msg.code;
var data = msg.data;
// Try parsing data as JSON, fall back to raw string
if (data && typeof data === 'string') {
try { data = JSON.parse(data); } catch (e) { /* keep as string */ }
}
GameState.emit(code, data);
} catch (e) {
console.error('Message parse error:', e, event.data);
}
};
this.ws.onclose = function() {
console.log('WebSocket disconnected');
document.getElementById('connection-status').classList.remove('connected');
self._stopHeartbeat();
GameState.emit('_disconnected');
};
this.ws.onerror = function(err) {
console.error('WebSocket error:', err);
};
},
send: function(code, data) {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
console.warn('WebSocket not connected');
return;
}
var dataStr = '';
if (data !== undefined && data !== null) {
dataStr = (typeof data === 'string') ? data : JSON.stringify(data);
}
this.ws.send(JSON.stringify({ code: code, data: dataStr, info: '' }));
},
_startHeartbeat: function() {
var self = this;
this._heartbeatTimer = setInterval(function() {
self.send('CODE_CLIENT_HEAD_BEAT', '');
}, 50000);
},
_stopHeartbeat: function() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
},
disconnect: function() {
this._stopHeartbeat();
if (this.ws) this.ws.close();
}
};
document.addEventListener('DOMContentLoaded', function() {
GameConnection.connect();
});
@@ -0,0 +1,145 @@
/**
* GameState — global state machine and event bus.
* Manages screen transitions, game data, and event dispatch.
*/
var GameState = {
// Client identity
clientId: null,
nickname: '',
// Room state
roomId: null,
isBlack: false,
isSpectator: false,
// Current game data
gameData: {
blackPlayerId: null,
blackPlayerNickname: '',
whitePlayerId: null,
whitePlayerNickname: '',
boardSize: 15,
moves: [],
currentTurn: 'BLACK',
result: null,
winnerNickname: ''
},
// Event bus
_handlers: {},
on: function(code, fn) {
if (!this._handlers[code]) this._handlers[code] = [];
this._handlers[code].push(fn);
},
emit: function(code, data) {
var handlers = this._handlers[code];
if (handlers) {
for (var i = 0; i < handlers.length; i++) {
try { handlers[i](data); } catch (e) { console.error('Handler error:', code, e); }
}
}
},
switchScreen: function(id) {
var screens = document.querySelectorAll('.screen');
for (var i = 0; i < screens.length; i++) {
screens[i].classList.remove('active');
}
var target = document.getElementById(id);
if (target) {
// Small delay for CSS transition
setTimeout(function() { target.classList.add('active'); }, 20);
}
},
resetGameData: function() {
this.gameData = {
blackPlayerId: null, blackPlayerNickname: '',
whitePlayerId: null, whitePlayerNickname: '',
boardSize: 15, moves: [], currentTurn: 'BLACK',
result: null, winnerNickname: ''
};
this.isSpectator = false;
},
isMyTurn: function() {
if (this.isSpectator) return false;
var myPiece = this.isBlack ? 'BLACK' : 'WHITE';
return this.gameData.currentTurn === myPiece;
},
init: function() {
this._registerCoreHandlers();
},
_registerCoreHandlers: function() {
var self = this;
this.on('CODE_CLIENT_CONNECT', function(data) {
self.clientId = parseInt(data);
});
this.on('CODE_CLIENT_NICKNAME_SET', function() {
self.switchScreen('screen-nickname');
});
this.on('CODE_SHOW_OPTIONS', function() {
self.switchScreen('screen-lobby');
});
this.on('CODE_GAME_STARTING', function(data) {
self.resetGameData();
self.roomId = data.roomId;
self.gameData.blackPlayerId = data.blackPlayerId;
self.gameData.blackPlayerNickname = data.blackPlayerNickname;
self.gameData.whitePlayerId = data.whitePlayerId;
self.gameData.whitePlayerNickname = data.whitePlayerNickname;
self.isBlack = (self.clientId === data.blackPlayerId);
self.gameData.currentTurn = 'BLACK';
self.switchScreen('screen-game');
});
this.on('CODE_GAME_MOVE_SUCCESS', function(data) {
self.gameData.moves.push(data);
self.gameData.currentTurn = (data.piece === 'BLACK') ? 'WHITE' : 'BLACK';
});
this.on('CODE_GAME_OVER', function(data) {
self.gameData.result = data.result;
self.gameData.winnerNickname = data.winnerNickname;
self.switchScreen('screen-game-over');
});
this.on('CODE_CLIENT_EXIT', function() {
self.resetGameData();
self.roomId = null;
self.switchScreen('screen-lobby');
});
this.on('CODE_CLIENT_KICK', function() {
self.resetGameData();
self.roomId = null;
self.switchScreen('screen-lobby');
});
this.on('CODE_ROOM_CREATE_SUCCESS', function(data) {
self.roomId = data.id;
self.switchScreen('screen-waiting-room');
});
this.on('CODE_SHOW_ROOMS', function() {
self.switchScreen('screen-room-list');
});
this.on('CODE_GAME_WATCH_SUCCESSFUL', function() {
self.isSpectator = true;
self.switchScreen('screen-game');
});
}
};
document.addEventListener('DOMContentLoaded', function() {
GameState.init();
});
@@ -0,0 +1,254 @@
/**
* GameUI — DOM manipulation for lobby, panels, move history,
* game-over screen, toast notifications, and button wiring.
*/
var GameUI = {
init: function() {
this._wireNickname();
this._wireLobby();
this._wirePvpMenu();
this._wirePveMenu();
this._wireRoomList();
this._wireWaitingRoom();
this._wireGame();
this._wireGameOver();
this._registerEventHandlers();
},
// --- Button wiring ---
_wireNickname: function() {
var form = document.getElementById('nickname-form');
form.addEventListener('submit', function(e) {
e.preventDefault();
var name = document.getElementById('nickname-input').value.trim();
if (!name) return;
GameState.nickname = name;
GameConnection.send('CODE_CLIENT_NICKNAME_SET', name);
});
},
_wireLobby: function() {
document.getElementById('btn-goto-pvp').addEventListener('click', function() {
GameState.switchScreen('screen-pvp-menu');
});
document.getElementById('btn-goto-pve').addEventListener('click', function() {
GameState.switchScreen('screen-pve-menu');
});
},
_wirePvpMenu: function() {
document.getElementById('btn-create-room').addEventListener('click', function() {
GameConnection.send('CODE_ROOM_CREATE', '');
});
document.getElementById('btn-join-room').addEventListener('click', function() {
GameConnection.send('CODE_GET_ROOMS', '');
});
document.getElementById('btn-pvp-back').addEventListener('click', function() {
GameState.switchScreen('screen-lobby');
});
},
_wirePveMenu: function() {
document.getElementById('btn-pve-easy').addEventListener('click', function() {
GameConnection.send('CODE_ROOM_CREATE_PVE', '1');
});
document.getElementById('btn-pve-medium').addEventListener('click', function() {
GameConnection.send('CODE_ROOM_CREATE_PVE', '2');
});
document.getElementById('btn-pve-hard').addEventListener('click', function() {
GameConnection.send('CODE_ROOM_CREATE_PVE', '3');
});
document.getElementById('btn-pve-back').addEventListener('click', function() {
GameState.switchScreen('screen-lobby');
});
},
_wireRoomList: function() {
document.getElementById('btn-refresh-rooms').addEventListener('click', function() {
GameConnection.send('CODE_GET_ROOMS', '');
});
document.getElementById('btn-room-list-back').addEventListener('click', function() {
GameState.switchScreen('screen-pvp-menu');
});
},
_wireWaitingRoom: function() {
document.getElementById('btn-leave-room').addEventListener('click', function() {
GameConnection.send('CODE_CLIENT_EXIT', '');
});
},
_wireGame: function() {
document.getElementById('btn-exit-game').addEventListener('click', function() {
GameConnection.send('CODE_CLIENT_EXIT', '');
});
},
_wireGameOver: function() {
document.getElementById('btn-rematch').addEventListener('click', function() {
GameConnection.send('CODE_GAME_READY', '');
});
document.getElementById('btn-exit-to-lobby').addEventListener('click', function() {
GameConnection.send('CODE_CLIENT_EXIT', '');
});
},
// --- Event handlers ---
_registerEventHandlers: function() {
var self = this;
GameState.on('CODE_SHOW_OPTIONS', function() {
document.getElementById('lobby-nickname').textContent = GameState.nickname;
document.getElementById('header-nickname').textContent = GameState.nickname;
});
GameState.on('CODE_ROOM_CREATE_SUCCESS', function(data) {
var id = data.id || data.roomId || '';
document.getElementById('waiting-room-id').textContent = id;
});
GameState.on('CODE_SHOW_ROOMS', function(data) {
self._renderRoomList(data);
});
GameState.on('CODE_GAME_STARTING', function(data) {
self._setupGameScreen(data);
});
GameState.on('CODE_GAME_MOVE_SUCCESS', function(data) {
self._addMoveToHistory(data);
self._updateTurnIndicator();
});
GameState.on('CODE_GAME_OVER', function(data) {
self._showGameOver(data);
});
// Error toasts
GameState.on('CODE_ROOM_JOIN_FAIL_BY_FULL', function() { self.showToast('Room is full', 'error'); });
GameState.on('CODE_ROOM_JOIN_FAIL_BY_INEXIST', function() { self.showToast('Room not found', 'error'); });
GameState.on('CODE_GAME_MOVE_NOT_YOUR_TURN', function() { self.showToast('Not your turn', 'error'); });
GameState.on('CODE_GAME_MOVE_OCCUPIED', function() { self.showToast('Position occupied', 'error'); });
GameState.on('CODE_GAME_MOVE_OUT_OF_BOUNDS', function() { self.showToast('Out of bounds', 'error'); });
GameState.on('CODE_GAME_MOVE_INVALID', function() { self.showToast('Invalid move', 'error'); });
GameState.on('CODE_CLIENT_KICK', function() { self.showToast('Kicked for inactivity', 'error'); });
GameState.on('CODE_PVE_DIFFICULTY_NOT_SUPPORT', function() { self.showToast('Difficulty not supported', 'error'); });
GameState.on('_disconnected', function() { self.showToast('Disconnected from server. Refresh to reconnect.', 'error'); });
},
// --- Room list ---
_renderRoomList: function(data) {
var rooms = data;
if (typeof data === 'string') {
try { rooms = JSON.parse(data); } catch (e) { rooms = []; }
}
if (!Array.isArray(rooms)) rooms = [];
var tbody = document.getElementById('room-table-body');
var empty = document.getElementById('room-list-empty');
tbody.innerHTML = '';
if (rooms.length === 0) {
empty.style.display = 'block';
return;
}
empty.style.display = 'none';
for (var i = 0; i < rooms.length; i++) {
var r = rooms[i];
var tr = document.createElement('tr');
tr.innerHTML =
'<td>' + (r.roomId || r.id) + '</td>' +
'<td>' + (r.roomOwner || '') + '</td>' +
'<td>' + (r.roomClientCount || 0) + '/2</td>' +
'<td>' + (r.status || r.roomStatus || '') + '</td>' +
'<td>' +
'<button class="btn btn-sm btn-primary" onclick="GameUI.joinRoom(' + (r.roomId || r.id) + ')">Join</button> ' +
'<button class="btn btn-sm btn-secondary" onclick="GameUI.watchRoom(' + (r.roomId || r.id) + ')">Watch</button>' +
'</td>';
tbody.appendChild(tr);
}
},
joinRoom: function(roomId) {
GameConnection.send('CODE_ROOM_JOIN', String(roomId));
},
watchRoom: function(roomId) {
GameConnection.send('CODE_GAME_WATCH', String(roomId));
},
// --- Game screen ---
_setupGameScreen: function(data) {
document.getElementById('player-black-name').textContent = data.blackPlayerNickname;
document.getElementById('player-white-name').textContent = data.whitePlayerNickname;
document.getElementById('move-list').innerHTML = '';
this._updateTurnIndicator();
},
_updateTurnIndicator: function() {
var isBlackTurn = GameState.gameData.currentTurn === 'BLACK';
var blackCard = document.getElementById('player-card-black');
var whiteCard = document.getElementById('player-card-white');
blackCard.classList.toggle('active', isBlackTurn);
whiteCard.classList.toggle('active', !isBlackTurn);
},
_addMoveToHistory: function(data) {
var list = document.getElementById('move-list');
var n = GameState.gameData.moves.length;
var col = String.fromCharCode(65 + data.col);
var row = data.row + 1;
var div = document.createElement('div');
div.className = 'move-entry';
div.innerHTML = '<span class="move-num">#' + n + '</span> ' +
'<span class="move-piece move-piece-' + data.piece.toLowerCase() + '"></span> ' +
'<span class="move-coord">' + col + row + '</span>';
list.appendChild(div);
list.scrollTop = list.scrollHeight;
},
// --- Game over ---
_showGameOver: function(data) {
var resultEl = document.getElementById('game-over-result');
var winnerEl = document.getElementById('game-over-winner');
if (data.result === 'DRAW') {
resultEl.textContent = 'Draw!';
resultEl.className = 'result-text result-draw';
winnerEl.textContent = '';
} else if (data.winnerNickname === GameState.nickname) {
resultEl.textContent = 'You Win!';
resultEl.className = 'result-text result-win';
winnerEl.textContent = '';
} else {
resultEl.textContent = 'You Lose!';
resultEl.className = 'result-text result-lose';
winnerEl.textContent = 'Winner: ' + data.winnerNickname;
}
},
// --- Toast ---
showToast: function(message, type) {
var container = document.getElementById('toast-container');
var toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'info');
toast.textContent = message;
container.appendChild(toast);
setTimeout(function() {
toast.classList.add('toast-exit');
setTimeout(function() { toast.remove(); }, 300);
}, 3000);
}
};
document.addEventListener('DOMContentLoaded', function() {
GameUI.init();
});