mirror of
https://github.com/tiennm99/caro.git
synced 2026-09-04 16:16:45 +00:00
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:
@@ -11,7 +11,8 @@
|
||||
"Bash(where mvn:*)",
|
||||
"Bash(where mvn.cmd)",
|
||||
"Bash(mvn clean:*)",
|
||||
"Bash(mvn test:*)"
|
||||
"Bash(mvn test:*)",
|
||||
"Bash(python3:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
+124
@@ -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();
|
||||
}
|
||||
}
|
||||
+2
@@ -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">⚔</span>
|
||||
Player vs Player
|
||||
</button>
|
||||
<button class="btn btn-primary btn-large" id="btn-goto-pve">
|
||||
<span class="btn-icon">🤖</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">← 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">🟢</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">🟡</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">🔴</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">← 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">↻ Refresh</button>
|
||||
<button class="btn btn-ghost" id="btn-room-list-back">← Back</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="room-list-empty" class="empty-state" style="display:none;">
|
||||
<span class="empty-icon">🌎</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">🔊</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 ? '🔈' : '🔊';
|
||||
}
|
||||
};
|
||||
|
||||
// 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();
|
||||
});
|
||||
Reference in New Issue
Block a user