From 77e141c0174ae2d9c6ebe9ef15f4f1148c8347c2 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 10 Apr 2026 09:03:37 +0700 Subject: [PATCH] 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 --- .claude/settings.local.json | 3 +- .../server/handler/StaticFileHandler.java | 124 +++ .../server/proxy/WebsocketProxy.java | 2 + .../src/main/resources/static/css/style.css | 757 ++++++++++++++++++ .../src/main/resources/static/index.html | 233 ++++++ .../main/resources/static/js/game-audio.js | 85 ++ .../main/resources/static/js/game-board.js | 226 ++++++ .../resources/static/js/game-connection.js | 85 ++ .../main/resources/static/js/game-state.js | 145 ++++ .../src/main/resources/static/js/game-ui.js | 254 ++++++ 10 files changed, 1913 insertions(+), 1 deletion(-) create mode 100644 landlords-server/src/main/java/org/nico/ratel/landlords/server/handler/StaticFileHandler.java create mode 100644 landlords-server/src/main/resources/static/css/style.css create mode 100644 landlords-server/src/main/resources/static/index.html create mode 100644 landlords-server/src/main/resources/static/js/game-audio.js create mode 100644 landlords-server/src/main/resources/static/js/game-board.js create mode 100644 landlords-server/src/main/resources/static/js/game-connection.js create mode 100644 landlords-server/src/main/resources/static/js/game-state.js create mode 100644 landlords-server/src/main/resources/static/js/game-ui.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5be2187..3fd1157 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "Bash(where mvn:*)", "Bash(where mvn.cmd)", "Bash(mvn clean:*)", - "Bash(mvn test:*)" + "Bash(mvn test:*)", + "Bash(python3:*)" ], "deny": [] } diff --git a/landlords-server/src/main/java/org/nico/ratel/landlords/server/handler/StaticFileHandler.java b/landlords-server/src/main/java/org/nico/ratel/landlords/server/handler/StaticFileHandler.java new file mode 100644 index 0000000..7066c89 --- /dev/null +++ b/landlords-server/src/main/java/org/nico/ratel/landlords/server/handler/StaticFileHandler.java @@ -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 { + + private static final Map 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(); + } +} diff --git a/landlords-server/src/main/java/org/nico/ratel/landlords/server/proxy/WebsocketProxy.java b/landlords-server/src/main/java/org/nico/ratel/landlords/server/proxy/WebsocketProxy.java index 964437b..84d4d90 100644 --- a/landlords-server/src/main/java/org/nico/ratel/landlords/server/proxy/WebsocketProxy.java +++ b/landlords-server/src/main/java/org/nico/ratel/landlords/server/proxy/WebsocketProxy.java @@ -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()); } diff --git a/landlords-server/src/main/resources/static/css/style.css b/landlords-server/src/main/resources/static/css/style.css new file mode 100644 index 0000000..316bda6 --- /dev/null +++ b/landlords-server/src/main/resources/static/css/style.css @@ -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; + } +} diff --git a/landlords-server/src/main/resources/static/index.html b/landlords-server/src/main/resources/static/index.html new file mode 100644 index 0000000..584745f --- /dev/null +++ b/landlords-server/src/main/resources/static/index.html @@ -0,0 +1,233 @@ + + + + + + Gomoku + + + +
+ +
+

Gomoku

+
+ + +
+
+ +
+ + +
+
+

Welcome to Gomoku

+

Five in a row wins. Choose your name and play.

+
+ + + +
+
+
+ + +
+
+

Lobby

+

+ Welcome, ! +

+
+ + +
+
+
+ + +
+
+

Player vs Player

+ + +
+
+ + +
+
+

Player vs AI

+

Select difficulty

+
+ + + +
+ +
+
+ + +
+
+
+

Available Rooms

+
+ + +
+
+ +
+ + + + + + + + + + + + + +
Room IDOwnerPlayersStatusActions
+
+
+
+ + +
+
+

Waiting Room

+
+ Room ID: +
+
+
+

Waiting for opponent…

+
+ +
+
+ + +
+
+ + + + + +
+ +
+ + + + +
+ + +
+ + +
+
+ + +
+
+
Draw
+
+ +
+
+ + +
+
+
+ +
+ + +
+ +
+ + + + + + + + + diff --git a/landlords-server/src/main/resources/static/js/game-audio.js b/landlords-server/src/main/resources/static/js/game-audio.js new file mode 100644 index 0000000..25a56a1 --- /dev/null +++ b/landlords-server/src/main/resources/static/js/game-audio.js @@ -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 ? '🔈' : '🔊'; + } +}; + +// 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(); +}); diff --git a/landlords-server/src/main/resources/static/js/game-board.js b/landlords-server/src/main/resources/static/js/game-board.js new file mode 100644 index 0000000..e4d0bf4 --- /dev/null +++ b/landlords-server/src/main/resources/static/js/game-board.js @@ -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); +}); diff --git a/landlords-server/src/main/resources/static/js/game-connection.js b/landlords-server/src/main/resources/static/js/game-connection.js new file mode 100644 index 0000000..466f13c --- /dev/null +++ b/landlords-server/src/main/resources/static/js/game-connection.js @@ -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(); +}); diff --git a/landlords-server/src/main/resources/static/js/game-state.js b/landlords-server/src/main/resources/static/js/game-state.js new file mode 100644 index 0000000..8d23316 --- /dev/null +++ b/landlords-server/src/main/resources/static/js/game-state.js @@ -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(); +}); diff --git a/landlords-server/src/main/resources/static/js/game-ui.js b/landlords-server/src/main/resources/static/js/game-ui.js new file mode 100644 index 0000000..7a163e2 --- /dev/null +++ b/landlords-server/src/main/resources/static/js/game-ui.js @@ -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 = + '' + (r.roomId || r.id) + '' + + '' + (r.roomOwner || '') + '' + + '' + (r.roomClientCount || 0) + '/2' + + '' + (r.status || r.roomStatus || '') + '' + + '' + + ' ' + + '' + + ''; + 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 = '#' + n + ' ' + + ' ' + + '' + col + row + ''; + 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(); +});