mirror of
https://github.com/tiennm99/caro.git
synced 2026-09-04 16:16:45 +00:00
feat: add Phaser 3 web client with Vite scaffold
Separate web-client/ directory with Phaser 3 + Vite + vanilla JS: - Services: event bus, WebSocket connection (heartbeat, reconnect), game state, protocol constants matching server enums - Scenes: BootScene (connect), MenuScene (DOM overlay), GameScene (canvas board with grid, stones, hover, click-to-move, animations) - Objects: Board (wood grid, star points, labels), Stone (gradient circles with drop tween animation) - UI: DOM overlays for nickname, lobby, PVP/PVE menus, room list, waiting room, game HUD, game over, toast notifications - Audio: Web Audio API stone click + win/lose tones - Full game flow: nickname → lobby → create/join/PVE → play → game over
This commit is contained in:
@@ -12,7 +12,8 @@
|
||||
"Bash(where mvn.cmd)",
|
||||
"Bash(mvn clean:*)",
|
||||
"Bash(mvn test:*)",
|
||||
"Bash(python3:*)"
|
||||
"Bash(python3:*)",
|
||||
"Bash(npm --version)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
||||
+5
-1
@@ -47,4 +47,8 @@ target
|
||||
/.settings
|
||||
/.project
|
||||
/ratel-landlords/.project
|
||||
.vscode
|
||||
.vscode
|
||||
|
||||
# Web client
|
||||
node_modules
|
||||
web-client/dist
|
||||
@@ -0,0 +1,167 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Gomoku</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
background: #1a1a2e;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
color: #eee;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
#game-container {
|
||||
position: relative;
|
||||
width: 800px;
|
||||
height: 800px;
|
||||
max-width: 100vw;
|
||||
max-height: 100vh;
|
||||
}
|
||||
#game-container canvas { display: block; }
|
||||
#ui-overlay {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
#ui-overlay > * { pointer-events: auto; }
|
||||
|
||||
/* Menu panels */
|
||||
.menu-panel {
|
||||
position: absolute; top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: #16213e; border-radius: 16px; padding: 40px;
|
||||
min-width: 320px; text-align: center;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
display: flex; flex-direction: column; gap: 12px; align-items: center;
|
||||
}
|
||||
.menu-panel.wide { min-width: 480px; }
|
||||
.menu-title { font-size: 28px; color: #e94560; margin-bottom: 4px; }
|
||||
.menu-subtitle { font-size: 14px; color: #888; margin-bottom: 8px; }
|
||||
.accent { color: #e94560; font-weight: bold; }
|
||||
|
||||
/* Buttons */
|
||||
.menu-btn {
|
||||
padding: 12px 28px; border: none; border-radius: 8px;
|
||||
font-size: 15px; cursor: pointer; transition: all 0.15s;
|
||||
font-family: inherit; color: #eee; width: 100%; max-width: 260px;
|
||||
}
|
||||
.menu-btn.primary { background: #e94560; }
|
||||
.menu-btn.primary:hover { background: #d63851; }
|
||||
.menu-btn.secondary { background: transparent; border: 1px solid #e94560; color: #e94560; }
|
||||
.menu-btn.secondary:hover { background: rgba(233,69,96,0.1); }
|
||||
.menu-btn.ghost { background: transparent; color: #888; font-size: 13px; }
|
||||
.menu-btn.ghost:hover { color: #eee; }
|
||||
.menu-btn.danger { background: #c0392b; }
|
||||
.menu-btn.danger:hover { background: #a93226; }
|
||||
.menu-btn.small { padding: 6px 14px; font-size: 13px; width: auto; max-width: none; }
|
||||
|
||||
/* Input */
|
||||
.menu-input {
|
||||
width: 100%; max-width: 260px; padding: 12px 16px;
|
||||
background: #0f3460; border: 1px solid #333; border-radius: 8px;
|
||||
color: #eee; font-size: 15px; outline: none; font-family: inherit;
|
||||
}
|
||||
.menu-input:focus { border-color: #e94560; box-shadow: 0 0 0 2px rgba(233,69,96,0.2); }
|
||||
|
||||
/* Room table */
|
||||
.room-table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; }
|
||||
.room-table th { color: #888; text-align: left; padding: 6px 8px; border-bottom: 1px solid #333; }
|
||||
.room-table td { padding: 8px; border-bottom: 1px solid #222; }
|
||||
.empty-state { color: #666; padding: 24px; text-align: center; }
|
||||
.menu-row { display: flex; gap: 8px; justify-content: center; }
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
width: 36px; height: 36px; border: 3px solid #333;
|
||||
border-top-color: #e94560; border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite; margin: 12px auto;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* Game HUD */
|
||||
.game-hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
|
||||
.game-hud > * { pointer-events: auto; }
|
||||
.hud-top {
|
||||
position: absolute; top: 4px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 12px; align-items: center;
|
||||
background: rgba(22,33,62,0.9); border-radius: 8px; padding: 4px 16px;
|
||||
}
|
||||
.player-panel { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
||||
.stone-dot { width: 14px; height: 14px; border-radius: 50%; display: inline-block; }
|
||||
.stone-dot.black { background: #222; border: 1px solid #555; }
|
||||
.stone-dot.white { background: #f5f5f5; border: 1px solid #999; }
|
||||
.turn-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #333; transition: background 0.2s;
|
||||
}
|
||||
.turn-dot.active { background: #4ecca3; box-shadow: 0 0 6px #4ecca3; }
|
||||
.hud-vs { color: #555; font-size: 12px; font-weight: bold; }
|
||||
|
||||
/* Move history sidebar */
|
||||
.hud-side {
|
||||
position: absolute; top: 50px; right: 4px;
|
||||
background: rgba(22,33,62,0.85); border-radius: 8px;
|
||||
width: 100px; max-height: 360px; padding: 6px; overflow: hidden;
|
||||
}
|
||||
.hud-side-title { font-size: 11px; color: #888; text-align: center; margin-bottom: 4px; }
|
||||
.move-list { max-height: 320px; overflow-y: auto; font-size: 11px; }
|
||||
.move-entry { padding: 2px 4px; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.move-entry.black { color: #ccc; }
|
||||
.move-entry.white { color: #aaa; }
|
||||
|
||||
/* Bottom controls */
|
||||
.hud-bottom {
|
||||
position: absolute; bottom: 4px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; gap: 8px;
|
||||
}
|
||||
|
||||
/* Game over overlay */
|
||||
.game-over-overlay {
|
||||
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
|
||||
background: rgba(0,0,0,0.6); display: flex; justify-content: center; align-items: center;
|
||||
z-index: 20; animation: fadeIn 0.3s;
|
||||
}
|
||||
.game-over-card {
|
||||
background: #16213e; border-radius: 16px; padding: 40px;
|
||||
text-align: center; min-width: 300px;
|
||||
}
|
||||
.result-text { font-size: 42px; font-weight: bold; margin-bottom: 8px; }
|
||||
.result-text.win { color: #4ecca3; }
|
||||
.result-text.lose { color: #e94560; }
|
||||
.result-text.draw { color: #f0c040; }
|
||||
.winner-name { color: #888; font-size: 14px; margin-bottom: 20px; }
|
||||
.game-over-buttons { display: flex; flex-direction: column; gap: 10px; align-items: center; margin-top: 16px; }
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
/* Toast notifications */
|
||||
#toast-container {
|
||||
position: fixed; bottom: 20px; right: 20px;
|
||||
display: flex; flex-direction: column; gap: 8px; z-index: 100;
|
||||
}
|
||||
.toast {
|
||||
padding: 10px 20px; border-radius: 8px; font-size: 13px;
|
||||
color: #fff; animation: slideIn 0.3s; min-width: 200px;
|
||||
}
|
||||
.toast-error { background: #c0392b; }
|
||||
.toast-info { background: #2980b9; }
|
||||
.toast-success { background: #27ae60; }
|
||||
.toast-exit { opacity: 0; transition: opacity 0.3s; }
|
||||
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container"></div>
|
||||
<div id="ui-overlay"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1122
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "caro-web-client",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"phaser": "^3.87.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^6.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Phaser from 'phaser';
|
||||
import { BootScene } from '../scenes/boot-scene.js';
|
||||
import { MenuScene } from '../scenes/menu-scene.js';
|
||||
import { GameScene } from '../scenes/game-scene.js';
|
||||
|
||||
/** Phaser game configuration */
|
||||
export const gameConfig = {
|
||||
type: Phaser.AUTO,
|
||||
width: 800,
|
||||
height: 800,
|
||||
parent: 'game-container',
|
||||
backgroundColor: '#1a1a2e',
|
||||
scale: {
|
||||
mode: Phaser.Scale.FIT,
|
||||
autoCenter: Phaser.Scale.CENTER_BOTH
|
||||
},
|
||||
scene: [BootScene, MenuScene, GameScene]
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Protocol constants matching server's ServerEventCode and ClientEventCode enums.
|
||||
* @module protocol-constants
|
||||
*/
|
||||
|
||||
/** @enum {string} Codes the client sends TO the server */
|
||||
export const ServerEventCode = Object.freeze({
|
||||
NICKNAME_SET: 'CODE_CLIENT_NICKNAME_SET',
|
||||
INFO_SET: 'CODE_CLIENT_INFO_SET',
|
||||
ROOM_CREATE: 'CODE_ROOM_CREATE',
|
||||
ROOM_CREATE_PVE: 'CODE_ROOM_CREATE_PVE',
|
||||
ROOM_JOIN: 'CODE_ROOM_JOIN',
|
||||
GET_ROOMS: 'CODE_GET_ROOMS',
|
||||
GAME_MOVE: 'CODE_GAME_MOVE',
|
||||
GAME_READY: 'CODE_GAME_READY',
|
||||
CLIENT_EXIT: 'CODE_CLIENT_EXIT',
|
||||
GAME_WATCH: 'CODE_GAME_WATCH',
|
||||
GAME_WATCH_EXIT: 'CODE_GAME_WATCH_EXIT',
|
||||
HEARTBEAT: 'CODE_CLIENT_HEAD_BEAT',
|
||||
GAME_STARTING: 'CODE_GAME_STARTING',
|
||||
});
|
||||
|
||||
/** @enum {string} Codes the server sends TO the client */
|
||||
export const ClientEventCode = Object.freeze({
|
||||
CLIENT_CONNECT: 'CODE_CLIENT_CONNECT',
|
||||
NICKNAME_SET: 'CODE_CLIENT_NICKNAME_SET',
|
||||
SHOW_OPTIONS: 'CODE_SHOW_OPTIONS',
|
||||
SHOW_ROOMS: 'CODE_SHOW_ROOMS',
|
||||
ROOM_CREATE_SUCCESS: 'CODE_ROOM_CREATE_SUCCESS',
|
||||
ROOM_JOIN_SUCCESS: 'CODE_ROOM_JOIN_SUCCESS',
|
||||
ROOM_JOIN_FAIL_FULL: 'CODE_ROOM_JOIN_FAIL_BY_FULL',
|
||||
ROOM_JOIN_FAIL_INEXIST: 'CODE_ROOM_JOIN_FAIL_BY_INEXIST',
|
||||
ROOM_PLAY_FAIL_INEXIST: 'CODE_ROOM_PLAY_FAIL_BY_INEXIST',
|
||||
GAME_STARTING: 'CODE_GAME_STARTING',
|
||||
GAME_MOVE_SUCCESS: 'CODE_GAME_MOVE_SUCCESS',
|
||||
GAME_MOVE_INVALID: 'CODE_GAME_MOVE_INVALID',
|
||||
GAME_MOVE_OCCUPIED: 'CODE_GAME_MOVE_OCCUPIED',
|
||||
GAME_MOVE_OUT_OF_BOUNDS: 'CODE_GAME_MOVE_OUT_OF_BOUNDS',
|
||||
GAME_MOVE_NOT_YOUR_TURN: 'CODE_GAME_MOVE_NOT_YOUR_TURN',
|
||||
GAME_OVER: 'CODE_GAME_OVER',
|
||||
GAME_READY: 'CODE_GAME_READY',
|
||||
CLIENT_EXIT: 'CODE_CLIENT_EXIT',
|
||||
CLIENT_KICK: 'CODE_CLIENT_KICK',
|
||||
GAME_WATCH: 'CODE_GAME_WATCH',
|
||||
GAME_WATCH_SUCCESSFUL: 'CODE_GAME_WATCH_SUCCESSFUL',
|
||||
PVE_DIFFICULTY_NOT_SUPPORT: 'CODE_PVE_DIFFICULTY_NOT_SUPPORT',
|
||||
SHOW_BOARD: 'CODE_SHOW_BOARD',
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import Phaser from 'phaser';
|
||||
import { gameConfig } from './config/game-config.js';
|
||||
|
||||
/** @type {Phaser.Game} */
|
||||
const game = new Phaser.Game(gameConfig);
|
||||
|
||||
export default game;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Board — Phaser GameObject that renders the 15x15 Gomoku grid.
|
||||
* Draws wood background, grid lines, star points, and coordinate labels.
|
||||
* @module board
|
||||
*/
|
||||
|
||||
import Phaser from 'phaser';
|
||||
|
||||
const BOARD_SIZE = 15;
|
||||
const PADDING = 50;
|
||||
const STAR_POINTS = [[3,3],[3,11],[7,7],[11,3],[11,11]];
|
||||
const WOOD_COLOR = 0xdcb35c;
|
||||
const LINE_COLOR = 0x8b6914;
|
||||
const LABEL_COLOR = '#5a4510';
|
||||
|
||||
export class Board extends Phaser.GameObjects.Graphics {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} canvasSize - total canvas pixel size
|
||||
*/
|
||||
constructor(scene, canvasSize) {
|
||||
super(scene);
|
||||
this.canvasSize = canvasSize;
|
||||
this.cellSize = (canvasSize - 2 * PADDING) / (BOARD_SIZE - 1);
|
||||
scene.add.existing(this);
|
||||
this.draw();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pixel X for a board column.
|
||||
* @param {number} col - 0-14
|
||||
* @returns {number}
|
||||
*/
|
||||
gridX(col) { return PADDING + col * this.cellSize; }
|
||||
|
||||
/**
|
||||
* Get pixel Y for a board row.
|
||||
* @param {number} row - 0-14
|
||||
* @returns {number}
|
||||
*/
|
||||
gridY(row) { return PADDING + row * this.cellSize; }
|
||||
|
||||
/**
|
||||
* Convert pixel coordinates to nearest grid intersection.
|
||||
* @param {number} px - pixel X
|
||||
* @param {number} py - pixel Y
|
||||
* @returns {{ row: number, col: number }|null} - null if out of bounds
|
||||
*/
|
||||
pixelToGrid(px, py) {
|
||||
const col = Math.round((px - PADDING) / this.cellSize);
|
||||
const row = Math.round((py - PADDING) / this.cellSize);
|
||||
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return null;
|
||||
return { row, col };
|
||||
}
|
||||
|
||||
/** @returns {number} */
|
||||
getCellSize() { return this.cellSize; }
|
||||
|
||||
/** Draw the full board (background, grid, stars, labels). */
|
||||
draw() {
|
||||
this.clear();
|
||||
this._drawBackground();
|
||||
this._drawGrid();
|
||||
this._drawStarPoints();
|
||||
this._drawLabels();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_drawBackground() {
|
||||
this.fillStyle(WOOD_COLOR, 1);
|
||||
this.fillRect(0, 0, this.canvasSize, this.canvasSize);
|
||||
// Subtle grain lines
|
||||
this.lineStyle(1, LINE_COLOR, 0.1);
|
||||
for (let i = 0; i < this.canvasSize; i += 7) {
|
||||
this.lineBetween(0, i, this.canvasSize, i);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_drawGrid() {
|
||||
this.lineStyle(1, LINE_COLOR, 1);
|
||||
for (let i = 0; i < BOARD_SIZE; i++) {
|
||||
this.lineBetween(this.gridX(i), this.gridY(0), this.gridX(i), this.gridY(BOARD_SIZE - 1));
|
||||
this.lineBetween(this.gridX(0), this.gridY(i), this.gridX(BOARD_SIZE - 1), this.gridY(i));
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_drawStarPoints() {
|
||||
this.fillStyle(LINE_COLOR, 1);
|
||||
for (const [r, c] of STAR_POINTS) {
|
||||
this.fillCircle(this.gridX(c), this.gridY(r), 4);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_drawLabels() {
|
||||
for (let i = 0; i < BOARD_SIZE; i++) {
|
||||
const letter = String.fromCharCode(65 + i);
|
||||
this.scene.add.text(this.gridX(i), PADDING - 25, letter, {
|
||||
fontSize: '12px', color: LABEL_COLOR, fontFamily: 'sans-serif'
|
||||
}).setOrigin(0.5);
|
||||
this.scene.add.text(PADDING - 25, this.gridY(i), String(i + 1), {
|
||||
fontSize: '12px', color: LABEL_COLOR, fontFamily: 'sans-serif'
|
||||
}).setOrigin(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Stone — Phaser GameObject for a single Gomoku stone.
|
||||
* Renders black or white circle with gradient effect and drop animation.
|
||||
* @module stone
|
||||
*/
|
||||
|
||||
import Phaser from 'phaser';
|
||||
|
||||
const STONE_RADIUS_RATIO = 0.43;
|
||||
|
||||
export class Stone extends Phaser.GameObjects.Graphics {
|
||||
/**
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x - pixel X
|
||||
* @param {number} y - pixel Y
|
||||
* @param {string} piece - 'BLACK' or 'WHITE'
|
||||
* @param {number} cellSize - board cell size for radius calc
|
||||
* @param {boolean} [animate=true] - play drop animation
|
||||
*/
|
||||
constructor(scene, x, y, piece, cellSize, animate = true) {
|
||||
super(scene);
|
||||
this.setPosition(x, y);
|
||||
this.piece = piece;
|
||||
this.radius = cellSize * STONE_RADIUS_RATIO;
|
||||
|
||||
this._drawStone();
|
||||
scene.add.existing(this);
|
||||
|
||||
if (animate) {
|
||||
this.setScale(0);
|
||||
scene.tweens.add({
|
||||
targets: this,
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
duration: 180,
|
||||
ease: 'Back.easeOut'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_drawStone() {
|
||||
this.clear();
|
||||
const r = this.radius;
|
||||
|
||||
if (this.piece === 'BLACK') {
|
||||
// Dark stone with highlight
|
||||
this.fillStyle(0x222222, 1);
|
||||
this.fillCircle(0, 0, r);
|
||||
// Highlight
|
||||
this.fillStyle(0x555555, 0.4);
|
||||
this.fillCircle(-r * 0.25, -r * 0.25, r * 0.35);
|
||||
} else {
|
||||
// White stone with subtle border
|
||||
this.fillStyle(0xf5f5f5, 1);
|
||||
this.fillCircle(0, 0, r);
|
||||
// Highlight
|
||||
this.fillStyle(0xffffff, 0.6);
|
||||
this.fillCircle(-r * 0.25, -r * 0.25, r * 0.3);
|
||||
// Border
|
||||
this.lineStyle(1, 0x999999, 0.8);
|
||||
this.strokeCircle(0, 0, r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a last-move indicator (small red dot).
|
||||
* @param {Phaser.Scene} scene
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @returns {Phaser.GameObjects.Graphics}
|
||||
*/
|
||||
export function createLastMoveMarker(scene, x, y) {
|
||||
const g = scene.add.graphics();
|
||||
g.fillStyle(0xe94560, 1);
|
||||
g.fillCircle(x, y, 4);
|
||||
return g;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import Phaser from 'phaser';
|
||||
import { connectionService } from '../services/connection-service.js';
|
||||
import { eventBus } from '../services/event-bus.js';
|
||||
import { ClientEventCode } from '../config/protocol-constants.js';
|
||||
|
||||
/**
|
||||
* BootScene — initial scene that connects to server
|
||||
* and transitions to MenuScene once connected.
|
||||
*/
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'BootScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
this.add.text(400, 380, 'Gomoku', {
|
||||
fontSize: '48px', fontFamily: 'sans-serif', color: '#e94560'
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(400, 440, 'Connecting to server...', {
|
||||
fontSize: '18px', fontFamily: 'sans-serif', color: '#888'
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Connect to server
|
||||
connectionService.connect();
|
||||
|
||||
// Transition to menu once server prompts for nickname
|
||||
eventBus.on(ClientEventCode.NICKNAME_SET, () => {
|
||||
this.scene.start('MenuScene');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* GameScene — renders the Gomoku board, handles clicks and move events.
|
||||
* Uses Board for grid rendering, Stone for pieces, DOM overlay for HUD.
|
||||
* @module game-scene
|
||||
*/
|
||||
|
||||
import Phaser from 'phaser';
|
||||
import { Board } from '../objects/board.js';
|
||||
import { Stone, createLastMoveMarker } from '../objects/stone.js';
|
||||
import { eventBus } from '../services/event-bus.js';
|
||||
import { gameState } from '../services/game-state-service.js';
|
||||
import { connectionService } from '../services/connection-service.js';
|
||||
import { ServerEventCode, ClientEventCode } from '../config/protocol-constants.js';
|
||||
import { showGameHud, updateTurnIndicator, addMoveToHistory, showGameOver } from '../ui/game-ui.js';
|
||||
import { showLobby } from '../ui/menu-ui.js';
|
||||
|
||||
export class GameScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'GameScene' });
|
||||
/** @type {Board|null} */
|
||||
this.board = null;
|
||||
/** @type {Stone[]} */
|
||||
this.stones = [];
|
||||
/** @type {Phaser.GameObjects.Graphics|null} */
|
||||
this.lastMarker = null;
|
||||
/** @type {Phaser.GameObjects.Graphics|null} */
|
||||
this.hoverGraphic = null;
|
||||
/** @type {{ row: number, col: number }|null} */
|
||||
this.hoverPos = null;
|
||||
|
||||
// Bind handlers for cleanup
|
||||
this._onMoveSuccess = this._onMoveSuccess.bind(this);
|
||||
this._onGameOver = this._onGameOver.bind(this);
|
||||
this._onClientExit = this._onClientExit.bind(this);
|
||||
this._onGameStarting = this._onGameStarting.bind(this);
|
||||
}
|
||||
|
||||
create() {
|
||||
this.board = new Board(this, 800);
|
||||
this.stones = [];
|
||||
this.lastMarker = null;
|
||||
|
||||
// Hover preview graphic
|
||||
this.hoverGraphic = this.add.graphics();
|
||||
this.hoverGraphic.setDepth(5);
|
||||
|
||||
// Click handler
|
||||
this.input.on('pointerdown', this._handleClick, this);
|
||||
this.input.on('pointermove', this._handleHover, this);
|
||||
|
||||
// Show game HUD overlay
|
||||
showGameHud();
|
||||
|
||||
// Draw any existing moves (rejoin/spectate scenario)
|
||||
for (const move of gameState.moves) {
|
||||
this._placeStone(move.row, move.col, move.piece, false);
|
||||
}
|
||||
|
||||
// Register event handlers
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_SUCCESS, this._onMoveSuccess);
|
||||
eventBus.on(ClientEventCode.GAME_OVER, this._onGameOver);
|
||||
eventBus.on(ClientEventCode.CLIENT_EXIT, this._onClientExit);
|
||||
eventBus.on(ClientEventCode.CLIENT_KICK, this._onClientExit);
|
||||
eventBus.on(ClientEventCode.GAME_STARTING, this._onGameStarting);
|
||||
|
||||
// Play stone sound setup
|
||||
this._audioCtx = null;
|
||||
this.input.once('pointerdown', () => {
|
||||
this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a stone on the board.
|
||||
* @param {number} row
|
||||
* @param {number} col
|
||||
* @param {string} piece - 'BLACK' or 'WHITE'
|
||||
* @param {boolean} [animate=true]
|
||||
* @private
|
||||
*/
|
||||
_placeStone(row, col, piece, animate = true) {
|
||||
const x = this.board.gridX(col);
|
||||
const y = this.board.gridY(row);
|
||||
const stone = new Stone(this, x, y, piece, this.board.getCellSize(), animate);
|
||||
stone.setDepth(10);
|
||||
this.stones.push(stone);
|
||||
|
||||
// Update last move marker
|
||||
if (this.lastMarker) this.lastMarker.destroy();
|
||||
this.lastMarker = createLastMoveMarker(this, x, y);
|
||||
this.lastMarker.setDepth(15);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_playStoneSound() {
|
||||
if (!this._audioCtx) return;
|
||||
try {
|
||||
const osc = this._audioCtx.createOscillator();
|
||||
const gain = this._audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(this._audioCtx.destination);
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = 800;
|
||||
gain.gain.setValueAtTime(0.12, this._audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, this._audioCtx.currentTime + 0.08);
|
||||
osc.start();
|
||||
osc.stop(this._audioCtx.currentTime + 0.08);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle board click — send move to server.
|
||||
* @param {Phaser.Input.Pointer} pointer
|
||||
* @private
|
||||
*/
|
||||
_handleClick(pointer) {
|
||||
if (gameState.isSpectating || !gameState.isMyTurn()) return;
|
||||
const pos = this.board.pixelToGrid(pointer.x, pointer.y);
|
||||
if (!pos) return;
|
||||
if (gameState.isOccupied(pos.row, pos.col)) return;
|
||||
connectionService.send(ServerEventCode.GAME_MOVE, { row: pos.row, col: pos.col });
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle hover — show preview stone.
|
||||
* @param {Phaser.Input.Pointer} pointer
|
||||
* @private
|
||||
*/
|
||||
_handleHover(pointer) {
|
||||
this.hoverGraphic.clear();
|
||||
if (gameState.isSpectating || !gameState.isMyTurn()) return;
|
||||
const pos = this.board.pixelToGrid(pointer.x, pointer.y);
|
||||
if (!pos || gameState.isOccupied(pos.row, pos.col)) {
|
||||
this.hoverPos = null;
|
||||
return;
|
||||
}
|
||||
if (this.hoverPos && this.hoverPos.row === pos.row && this.hoverPos.col === pos.col) return;
|
||||
this.hoverPos = pos;
|
||||
const x = this.board.gridX(pos.col);
|
||||
const y = this.board.gridY(pos.row);
|
||||
const color = gameState.isBlack ? 0x222222 : 0xf5f5f5;
|
||||
this.hoverGraphic.fillStyle(color, 0.35);
|
||||
this.hoverGraphic.fillCircle(x, y, this.board.getCellSize() * 0.43);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle successful move from server.
|
||||
* @param {{ row: number, col: number, piece: string, playerNickname: string }} data
|
||||
* @private
|
||||
*/
|
||||
_onMoveSuccess(data) {
|
||||
this._placeStone(data.row, data.col, data.piece, true);
|
||||
this._playStoneSound();
|
||||
addMoveToHistory(data);
|
||||
updateTurnIndicator();
|
||||
this.hoverGraphic.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game over.
|
||||
* @param {{ result: string, winnerNickname: string }} data
|
||||
* @private
|
||||
*/
|
||||
_onGameOver(data) {
|
||||
showGameOver(data);
|
||||
// Play win/lose sound
|
||||
if (!this._audioCtx) return;
|
||||
const isWin = data.winnerNickname === gameState.nickname;
|
||||
const freq = isWin ? [523, 659, 784] : [400, 300];
|
||||
freq.forEach((f, i) => {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const osc = this._audioCtx.createOscillator();
|
||||
const gain = this._audioCtx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(this._audioCtx.destination);
|
||||
osc.type = isWin ? 'sine' : 'triangle';
|
||||
osc.frequency.value = f;
|
||||
gain.gain.setValueAtTime(0.12, this._audioCtx.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, this._audioCtx.currentTime + 0.2);
|
||||
osc.start();
|
||||
osc.stop(this._audioCtx.currentTime + 0.2);
|
||||
} catch (_) { /* ignore */ }
|
||||
}, i * 150);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle client exit — return to menu.
|
||||
* @private
|
||||
*/
|
||||
_onClientExit() {
|
||||
this._cleanup();
|
||||
this.scene.start('MenuScene');
|
||||
showLobby();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle rematch — new game starting while already in GameScene.
|
||||
* @private
|
||||
*/
|
||||
_onGameStarting() {
|
||||
// Clear existing stones and redraw board
|
||||
this.stones.forEach(s => s.destroy());
|
||||
this.stones = [];
|
||||
if (this.lastMarker) { this.lastMarker.destroy(); this.lastMarker = null; }
|
||||
this.hoverGraphic.clear();
|
||||
showGameHud();
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_cleanup() {
|
||||
eventBus.off(ClientEventCode.GAME_MOVE_SUCCESS, this._onMoveSuccess);
|
||||
eventBus.off(ClientEventCode.GAME_OVER, this._onGameOver);
|
||||
eventBus.off(ClientEventCode.CLIENT_EXIT, this._onClientExit);
|
||||
eventBus.off(ClientEventCode.CLIENT_KICK, this._onClientExit);
|
||||
eventBus.off(ClientEventCode.GAME_STARTING, this._onGameStarting);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this._cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* MenuScene — shows DOM overlay menus (nickname, lobby, PVP/PVE).
|
||||
* Phaser canvas shows a subtle background; all interaction is via DOM.
|
||||
* @module menu-scene
|
||||
*/
|
||||
|
||||
import Phaser from 'phaser';
|
||||
import { eventBus } from '../services/event-bus.js';
|
||||
import { ClientEventCode } from '../config/protocol-constants.js';
|
||||
import { showNicknameScreen, hideOverlay } from '../ui/menu-ui.js';
|
||||
|
||||
export class MenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MenuScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// Subtle background
|
||||
this.cameras.main.setBackgroundColor('#1a1a2e');
|
||||
|
||||
// Grid pattern background decoration
|
||||
const g = this.add.graphics();
|
||||
g.lineStyle(1, 0x16213e, 0.4);
|
||||
for (let i = 0; i < 800; i += 40) {
|
||||
g.lineBetween(i, 0, i, 800);
|
||||
g.lineBetween(0, i, 800, i);
|
||||
}
|
||||
|
||||
// Show nickname entry
|
||||
showNicknameScreen();
|
||||
|
||||
// Transition to GameScene when game starts
|
||||
eventBus.on(ClientEventCode.GAME_STARTING, this._onGameStarting.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle game starting — transition to GameScene.
|
||||
* @private
|
||||
*/
|
||||
_onGameStarting() {
|
||||
hideOverlay();
|
||||
this.scene.start('GameScene');
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
eventBus.off(ClientEventCode.GAME_STARTING, this._onGameStarting.bind(this));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* WebSocket connection service.
|
||||
* Wraps browser WebSocket with heartbeat, auto-reconnect, and message parsing.
|
||||
* @module connection-service
|
||||
*/
|
||||
|
||||
import { eventBus } from './event-bus.js';
|
||||
import { ServerEventCode } from '../config/protocol-constants.js';
|
||||
|
||||
/** Default server WebSocket URL */
|
||||
const DEFAULT_WS_URL = 'ws://localhost:1025/ratel';
|
||||
|
||||
class ConnectionService {
|
||||
constructor() {
|
||||
/** @type {WebSocket|null} */
|
||||
this._ws = null;
|
||||
/** @type {number|null} */
|
||||
this._heartbeatTimer = null;
|
||||
/** @type {number} */
|
||||
this._reconnectDelay = 1000;
|
||||
/** @type {boolean} */
|
||||
this._intentionalClose = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the game server.
|
||||
* @param {string} [url] - WebSocket URL, defaults to same-origin or localhost
|
||||
*/
|
||||
connect(url) {
|
||||
const wsUrl = url || this._resolveUrl();
|
||||
this._intentionalClose = false;
|
||||
|
||||
try {
|
||||
this._ws = new WebSocket(wsUrl);
|
||||
} catch (e) {
|
||||
console.error('WebSocket creation failed:', e);
|
||||
return;
|
||||
}
|
||||
|
||||
this._ws.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
this._reconnectDelay = 1000;
|
||||
this._startHeartbeat();
|
||||
eventBus.emit('ws:connected', null);
|
||||
};
|
||||
|
||||
this._ws.onmessage = (event) => this._onMessage(event);
|
||||
|
||||
this._ws.onclose = () => {
|
||||
console.log('WebSocket disconnected');
|
||||
this._stopHeartbeat();
|
||||
eventBus.emit('ws:disconnected', null);
|
||||
if (!this._intentionalClose) this._scheduleReconnect(wsUrl);
|
||||
};
|
||||
|
||||
this._ws.onerror = (err) => {
|
||||
console.error('WebSocket error:', err);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the server.
|
||||
* @param {string} code - ServerEventCode value
|
||||
* @param {string|object} [data] - payload (objects are JSON.stringify'd)
|
||||
*/
|
||||
send(code, data) {
|
||||
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
||||
console.warn('WebSocket not connected, cannot send:', code);
|
||||
return;
|
||||
}
|
||||
const dataStr = (data === undefined || data === null) ? ''
|
||||
: (typeof data === 'string') ? data
|
||||
: JSON.stringify(data);
|
||||
this._ws.send(JSON.stringify({ code, data: dataStr, info: '' }));
|
||||
}
|
||||
|
||||
/** Close the connection intentionally. */
|
||||
disconnect() {
|
||||
this._intentionalClose = true;
|
||||
this._stopHeartbeat();
|
||||
if (this._ws) this._ws.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse incoming WebSocket message and emit via event bus.
|
||||
* @param {MessageEvent} event
|
||||
* @private
|
||||
*/
|
||||
_onMessage(event) {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
let data = msg.data;
|
||||
if (data && typeof data === 'string') {
|
||||
try { data = JSON.parse(data); } catch (_) { /* keep as string */ }
|
||||
}
|
||||
eventBus.emit(msg.code, data);
|
||||
} catch (e) {
|
||||
console.error('Message parse error:', e, event.data);
|
||||
}
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_startHeartbeat() {
|
||||
this._stopHeartbeat();
|
||||
this._heartbeatTimer = setInterval(() => {
|
||||
this.send(ServerEventCode.HEARTBEAT, '');
|
||||
}, 50000);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_stopHeartbeat() {
|
||||
if (this._heartbeatTimer) {
|
||||
clearInterval(this._heartbeatTimer);
|
||||
this._heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect with exponential backoff (max 30s).
|
||||
* @param {string} url
|
||||
* @private
|
||||
*/
|
||||
_scheduleReconnect(url) {
|
||||
console.log(`Reconnecting in ${this._reconnectDelay}ms...`);
|
||||
setTimeout(() => this.connect(url), this._reconnectDelay);
|
||||
this._reconnectDelay = Math.min(this._reconnectDelay * 2, 30000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive WebSocket URL from current page location or fall back to default.
|
||||
* @returns {string}
|
||||
* @private
|
||||
*/
|
||||
_resolveUrl() {
|
||||
if (typeof window !== 'undefined' && window.location.hostname !== 'localhost'
|
||||
&& window.location.hostname !== '127.0.0.1') {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${proto}//${window.location.hostname}:1025/ratel`;
|
||||
}
|
||||
return DEFAULT_WS_URL;
|
||||
}
|
||||
}
|
||||
|
||||
export const connectionService = new ConnectionService();
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Simple pub/sub event bus for decoupling WebSocket from scenes.
|
||||
* @module event-bus
|
||||
*/
|
||||
|
||||
class EventBus {
|
||||
constructor() {
|
||||
/** @type {Map<string, Set<function>>} */
|
||||
this._listeners = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to an event.
|
||||
* @param {string} event - event name (e.g. ClientEventCode value)
|
||||
* @param {function} callback - handler function
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this._listeners.has(event)) {
|
||||
this._listeners.set(event, new Set());
|
||||
}
|
||||
this._listeners.get(event).add(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from an event.
|
||||
* @param {string} event
|
||||
* @param {function} callback
|
||||
*/
|
||||
off(event, callback) {
|
||||
const set = this._listeners.get(event);
|
||||
if (set) set.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event to all subscribers.
|
||||
* @param {string} event
|
||||
* @param {*} data - parsed event data
|
||||
*/
|
||||
emit(event, data) {
|
||||
const set = this._listeners.get(event);
|
||||
if (!set) return;
|
||||
for (const cb of set) {
|
||||
try { cb(data); } catch (e) { console.error(`EventBus error [${event}]:`, e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const eventBus = new EventBus();
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Game state service — stores all client-side game state.
|
||||
* Pure data container with reset methods, no game logic.
|
||||
* @module game-state-service
|
||||
*/
|
||||
|
||||
import { eventBus } from './event-bus.js';
|
||||
import { ClientEventCode } from '../config/protocol-constants.js';
|
||||
|
||||
/**
|
||||
* @typedef {Object} MoveEntry
|
||||
* @property {number} row
|
||||
* @property {number} col
|
||||
* @property {string} piece - 'BLACK' or 'WHITE'
|
||||
* @property {string} playerNickname
|
||||
* @property {number} playerId
|
||||
*/
|
||||
|
||||
class GameStateService {
|
||||
constructor() {
|
||||
/** @type {number|null} */
|
||||
this.clientId = null;
|
||||
/** @type {string} */
|
||||
this.nickname = '';
|
||||
/** @type {number|null} */
|
||||
this.roomId = null;
|
||||
/** @type {boolean} */
|
||||
this.isBlack = false;
|
||||
/** @type {boolean} */
|
||||
this.isSpectating = false;
|
||||
/** @type {string} - 'BLACK' or 'WHITE' */
|
||||
this.currentTurn = 'BLACK';
|
||||
/** @type {MoveEntry[]} */
|
||||
this.moves = [];
|
||||
/** @type {number} */
|
||||
this.boardSize = 15;
|
||||
/** @type {string} */
|
||||
this.blackPlayerNickname = '';
|
||||
/** @type {string} */
|
||||
this.whitePlayerNickname = '';
|
||||
/** @type {number} */
|
||||
this.blackPlayerId = -1;
|
||||
/** @type {number} */
|
||||
this.whitePlayerId = -1;
|
||||
|
||||
this._registerHandlers();
|
||||
}
|
||||
|
||||
/** @returns {boolean} */
|
||||
isMyTurn() {
|
||||
if (this.isSpectating) return false;
|
||||
const myPiece = this.isBlack ? 'BLACK' : 'WHITE';
|
||||
return this.currentTurn === myPiece;
|
||||
}
|
||||
|
||||
/** Reset room and game state (on exit/kick). */
|
||||
reset() {
|
||||
this.roomId = null;
|
||||
this.isSpectating = false;
|
||||
this.resetBoard();
|
||||
}
|
||||
|
||||
/** Reset board state (on new game / rematch). */
|
||||
resetBoard() {
|
||||
this.moves = [];
|
||||
this.currentTurn = 'BLACK';
|
||||
this.isBlack = false;
|
||||
this.blackPlayerNickname = '';
|
||||
this.whitePlayerNickname = '';
|
||||
this.blackPlayerId = -1;
|
||||
this.whitePlayerId = -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a board position is occupied.
|
||||
* @param {number} row
|
||||
* @param {number} col
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isOccupied(row, col) {
|
||||
return this.moves.some(m => m.row === row && m.col === col);
|
||||
}
|
||||
|
||||
/** @private */
|
||||
_registerHandlers() {
|
||||
eventBus.on(ClientEventCode.CLIENT_CONNECT, (data) => {
|
||||
this.clientId = parseInt(data);
|
||||
});
|
||||
|
||||
eventBus.on(ClientEventCode.GAME_STARTING, (data) => {
|
||||
this.resetBoard();
|
||||
this.roomId = data.roomId;
|
||||
this.blackPlayerId = data.blackPlayerId;
|
||||
this.blackPlayerNickname = data.blackPlayerNickname;
|
||||
this.whitePlayerId = data.whitePlayerId;
|
||||
this.whitePlayerNickname = data.whitePlayerNickname;
|
||||
this.boardSize = data.boardSize || 15;
|
||||
this.isBlack = (this.clientId === data.blackPlayerId);
|
||||
this.currentTurn = 'BLACK';
|
||||
});
|
||||
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_SUCCESS, (data) => {
|
||||
this.moves.push(data);
|
||||
this.currentTurn = (data.piece === 'BLACK') ? 'WHITE' : 'BLACK';
|
||||
});
|
||||
|
||||
eventBus.on(ClientEventCode.CLIENT_EXIT, () => this.reset());
|
||||
eventBus.on(ClientEventCode.CLIENT_KICK, () => this.reset());
|
||||
}
|
||||
}
|
||||
|
||||
export const gameState = new GameStateService();
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Game UI — DOM overlay for player info, move history, game over, and toasts.
|
||||
* Rendered on top of the Phaser canvas during gameplay.
|
||||
* @module game-ui
|
||||
*/
|
||||
|
||||
import { connectionService } from '../services/connection-service.js';
|
||||
import { eventBus } from '../services/event-bus.js';
|
||||
import { gameState } from '../services/game-state-service.js';
|
||||
import { ServerEventCode, ClientEventCode } from '../config/protocol-constants.js';
|
||||
|
||||
const overlay = () => document.getElementById('ui-overlay');
|
||||
|
||||
/**
|
||||
* Show the game HUD (player panels + move history + controls).
|
||||
*/
|
||||
export function showGameHud() {
|
||||
const el = overlay();
|
||||
el.innerHTML = `
|
||||
<div class="game-hud">
|
||||
<div class="hud-top">
|
||||
<div class="player-panel" id="panel-black">
|
||||
<span class="stone-dot black"></span>
|
||||
<span class="player-name" id="hud-black-name">${gameState.blackPlayerNickname}</span>
|
||||
<span class="turn-dot" id="turn-black"></span>
|
||||
</div>
|
||||
<div class="hud-vs">VS</div>
|
||||
<div class="player-panel" id="panel-white">
|
||||
<span class="stone-dot white"></span>
|
||||
<span class="player-name" id="hud-white-name">${gameState.whitePlayerNickname}</span>
|
||||
<span class="turn-dot" id="turn-white"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hud-side" id="move-history">
|
||||
<div class="hud-side-title">Moves</div>
|
||||
<div class="move-list" id="move-list"></div>
|
||||
</div>
|
||||
<div class="hud-bottom">
|
||||
<button id="btn-exit-game" class="menu-btn danger small">Exit</button>
|
||||
<button id="btn-toggle-sound" class="menu-btn ghost small">🔊</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
el.style.display = 'flex';
|
||||
document.getElementById('btn-exit-game').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.CLIENT_EXIT, '');
|
||||
});
|
||||
updateTurnIndicator();
|
||||
}
|
||||
|
||||
/** Update the turn indicator dots. */
|
||||
export function updateTurnIndicator() {
|
||||
const blackDot = document.getElementById('turn-black');
|
||||
const whiteDot = document.getElementById('turn-white');
|
||||
if (!blackDot || !whiteDot) return;
|
||||
blackDot.classList.toggle('active', gameState.currentTurn === 'BLACK');
|
||||
whiteDot.classList.toggle('active', gameState.currentTurn === 'WHITE');
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a move entry to the history panel.
|
||||
* @param {{ row: number, col: number, piece: string, playerNickname: string }} data
|
||||
*/
|
||||
export function addMoveToHistory(data) {
|
||||
const list = document.getElementById('move-list');
|
||||
if (!list) return;
|
||||
const n = gameState.moves.length;
|
||||
const col = String.fromCharCode(65 + data.col);
|
||||
const row = data.row + 1;
|
||||
const div = document.createElement('div');
|
||||
div.className = `move-entry ${data.piece.toLowerCase()}`;
|
||||
div.textContent = `#${n} ${data.piece === 'BLACK' ? '●' : '○'} ${col}${row}`;
|
||||
list.appendChild(div);
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the game over overlay.
|
||||
* @param {{ result: string, winnerNickname: string }} data
|
||||
*/
|
||||
export function showGameOver(data) {
|
||||
let resultText, resultClass;
|
||||
if (data.result === 'DRAW') {
|
||||
resultText = 'Draw!';
|
||||
resultClass = 'draw';
|
||||
} else if (data.winnerNickname === gameState.nickname) {
|
||||
resultText = 'You Win!';
|
||||
resultClass = 'win';
|
||||
} else {
|
||||
resultText = 'You Lose!';
|
||||
resultClass = 'lose';
|
||||
}
|
||||
|
||||
const el = overlay();
|
||||
el.innerHTML += `
|
||||
<div class="game-over-overlay">
|
||||
<div class="game-over-card">
|
||||
<div class="result-text ${resultClass}">${resultText}</div>
|
||||
${data.winnerNickname ? `<div class="winner-name">Winner: ${data.winnerNickname}</div>` : ''}
|
||||
<div class="game-over-buttons">
|
||||
<button id="btn-rematch" class="menu-btn primary">Rematch</button>
|
||||
<button id="btn-exit-lobby" class="menu-btn secondary">Exit to Lobby</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.getElementById('btn-rematch').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.GAME_READY, '');
|
||||
});
|
||||
document.getElementById('btn-exit-lobby').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.CLIENT_EXIT, '');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a toast notification.
|
||||
* @param {string} message
|
||||
* @param {'info'|'error'|'success'} [type='info']
|
||||
*/
|
||||
export function showToast(message, type = 'info') {
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast toast-${type}`;
|
||||
toast.textContent = message;
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.classList.add('toast-exit');
|
||||
setTimeout(() => toast.remove(), 300);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Register error toasts
|
||||
eventBus.on(ClientEventCode.ROOM_JOIN_FAIL_FULL, () => showToast('Room is full', 'error'));
|
||||
eventBus.on(ClientEventCode.ROOM_JOIN_FAIL_INEXIST, () => showToast('Room not found', 'error'));
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_NOT_YOUR_TURN, () => showToast('Not your turn', 'error'));
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_OCCUPIED, () => showToast('Position occupied', 'error'));
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_OUT_OF_BOUNDS, () => showToast('Out of bounds', 'error'));
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_INVALID, () => showToast('Invalid move', 'error'));
|
||||
eventBus.on(ClientEventCode.PVE_DIFFICULTY_NOT_SUPPORT, () => showToast('Difficulty not supported', 'error'));
|
||||
eventBus.on('ws:disconnected', () => showToast('Connection lost. Reconnecting...', 'error'));
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Menu UI — DOM overlay for nickname, lobby, PVP/PVE menus, room list, waiting room.
|
||||
* @module menu-ui
|
||||
*/
|
||||
|
||||
import { connectionService } from '../services/connection-service.js';
|
||||
import { eventBus } from '../services/event-bus.js';
|
||||
import { gameState } from '../services/game-state-service.js';
|
||||
import { ServerEventCode, ClientEventCode } from '../config/protocol-constants.js';
|
||||
|
||||
const overlay = () => document.getElementById('ui-overlay');
|
||||
|
||||
/**
|
||||
* Show a screen in the overlay.
|
||||
* @param {string} html
|
||||
*/
|
||||
function showOverlay(html) {
|
||||
const el = overlay();
|
||||
el.innerHTML = html;
|
||||
el.style.display = 'flex';
|
||||
}
|
||||
|
||||
/** Hide the overlay. */
|
||||
export function hideOverlay() {
|
||||
const el = overlay();
|
||||
el.innerHTML = '';
|
||||
el.style.display = 'none';
|
||||
}
|
||||
|
||||
/** Show nickname entry screen. */
|
||||
export function showNicknameScreen() {
|
||||
showOverlay(`
|
||||
<div class="menu-panel">
|
||||
<h1 class="menu-title">Gomoku</h1>
|
||||
<p class="menu-subtitle">Five in a row wins</p>
|
||||
<input type="text" id="input-nickname" class="menu-input" placeholder="Enter nickname…" maxlength="20" />
|
||||
<button id="btn-play" class="menu-btn primary">Play</button>
|
||||
</div>
|
||||
`);
|
||||
const input = document.getElementById('input-nickname');
|
||||
const btn = document.getElementById('btn-play');
|
||||
const submit = () => {
|
||||
const name = input.value.trim();
|
||||
if (!name) return;
|
||||
gameState.nickname = name;
|
||||
connectionService.send(ServerEventCode.NICKNAME_SET, name);
|
||||
};
|
||||
btn.addEventListener('click', submit);
|
||||
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
|
||||
input.focus();
|
||||
}
|
||||
|
||||
/** Show lobby (main menu). */
|
||||
export function showLobby() {
|
||||
showOverlay(`
|
||||
<div class="menu-panel">
|
||||
<h2 class="menu-title">Lobby</h2>
|
||||
<p class="menu-subtitle">Welcome, <span class="accent">${gameState.nickname}</span></p>
|
||||
<button id="btn-pvp" class="menu-btn primary">Player vs Player</button>
|
||||
<button id="btn-pve" class="menu-btn primary">Player vs AI</button>
|
||||
</div>
|
||||
`);
|
||||
document.getElementById('btn-pvp').addEventListener('click', showPvpMenu);
|
||||
document.getElementById('btn-pve').addEventListener('click', showPveMenu);
|
||||
}
|
||||
|
||||
/** Show PVP submenu. */
|
||||
function showPvpMenu() {
|
||||
showOverlay(`
|
||||
<div class="menu-panel">
|
||||
<h2 class="menu-title">Player vs Player</h2>
|
||||
<button id="btn-create" class="menu-btn primary">Create Room</button>
|
||||
<button id="btn-rooms" class="menu-btn secondary">Join Room</button>
|
||||
<button id="btn-back" class="menu-btn ghost">← Back</button>
|
||||
</div>
|
||||
`);
|
||||
document.getElementById('btn-create').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.ROOM_CREATE, '');
|
||||
});
|
||||
document.getElementById('btn-rooms').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.GET_ROOMS, '');
|
||||
});
|
||||
document.getElementById('btn-back').addEventListener('click', showLobby);
|
||||
}
|
||||
|
||||
/** Show PVE difficulty selection. */
|
||||
function showPveMenu() {
|
||||
showOverlay(`
|
||||
<div class="menu-panel">
|
||||
<h2 class="menu-title">Player vs AI</h2>
|
||||
<p class="menu-subtitle">Select difficulty</p>
|
||||
<button id="btn-easy" class="menu-btn primary">Easy</button>
|
||||
<button id="btn-medium" class="menu-btn primary">Medium</button>
|
||||
<button id="btn-hard" class="menu-btn primary">Hard</button>
|
||||
<button id="btn-back" class="menu-btn ghost">← Back</button>
|
||||
</div>
|
||||
`);
|
||||
document.getElementById('btn-easy').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.ROOM_CREATE_PVE, '1');
|
||||
});
|
||||
document.getElementById('btn-medium').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.ROOM_CREATE_PVE, '2');
|
||||
});
|
||||
document.getElementById('btn-hard').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.ROOM_CREATE_PVE, '3');
|
||||
});
|
||||
document.getElementById('btn-back').addEventListener('click', showLobby);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show room list.
|
||||
* @param {Array} rooms
|
||||
*/
|
||||
export function showRoomList(rooms) {
|
||||
const rows = Array.isArray(rooms) ? rooms : [];
|
||||
const tableRows = rows.length === 0
|
||||
? '<tr><td colspan="4" class="empty-state">No rooms available</td></tr>'
|
||||
: rows.map(r => `
|
||||
<tr>
|
||||
<td>${r.roomId || r.id}</td>
|
||||
<td>${r.roomOwner || ''}</td>
|
||||
<td>${r.roomClientCount || 0}/2</td>
|
||||
<td>
|
||||
<button class="menu-btn small primary" data-join="${r.roomId || r.id}">Join</button>
|
||||
<button class="menu-btn small secondary" data-watch="${r.roomId || r.id}">Watch</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
showOverlay(`
|
||||
<div class="menu-panel wide">
|
||||
<h2 class="menu-title">Available Rooms</h2>
|
||||
<table class="room-table">
|
||||
<thead><tr><th>ID</th><th>Owner</th><th>Players</th><th>Actions</th></tr></thead>
|
||||
<tbody>${tableRows}</tbody>
|
||||
</table>
|
||||
<div class="menu-row">
|
||||
<button id="btn-refresh" class="menu-btn secondary">↻ Refresh</button>
|
||||
<button id="btn-back" class="menu-btn ghost">← Back</button>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
overlay().querySelectorAll('[data-join]').forEach(btn => {
|
||||
btn.addEventListener('click', () => connectionService.send(ServerEventCode.ROOM_JOIN, btn.dataset.join));
|
||||
});
|
||||
overlay().querySelectorAll('[data-watch]').forEach(btn => {
|
||||
btn.addEventListener('click', () => connectionService.send(ServerEventCode.GAME_WATCH, btn.dataset.watch));
|
||||
});
|
||||
document.getElementById('btn-refresh').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.GET_ROOMS, '');
|
||||
});
|
||||
document.getElementById('btn-back').addEventListener('click', showPvpMenu);
|
||||
}
|
||||
|
||||
/** Show waiting room. */
|
||||
export function showWaitingRoom() {
|
||||
showOverlay(`
|
||||
<div class="menu-panel">
|
||||
<h2 class="menu-title">Waiting Room</h2>
|
||||
<p>Room ID: <span class="accent">${gameState.roomId}</span></p>
|
||||
<div class="spinner"></div>
|
||||
<p class="menu-subtitle">Waiting for opponent…</p>
|
||||
<button id="btn-leave" class="menu-btn danger">Leave</button>
|
||||
</div>
|
||||
`);
|
||||
document.getElementById('btn-leave').addEventListener('click', () => {
|
||||
connectionService.send(ServerEventCode.CLIENT_EXIT, '');
|
||||
});
|
||||
}
|
||||
|
||||
// Register server event handlers for menu navigation
|
||||
eventBus.on(ClientEventCode.SHOW_OPTIONS, showLobby);
|
||||
eventBus.on(ClientEventCode.SHOW_ROOMS, showRoomList);
|
||||
eventBus.on(ClientEventCode.ROOM_CREATE_SUCCESS, (data) => {
|
||||
gameState.roomId = data.id;
|
||||
showWaitingRoom();
|
||||
});
|
||||
eventBus.on(ClientEventCode.CLIENT_EXIT, showLobby);
|
||||
eventBus.on(ClientEventCode.CLIENT_KICK, showLobby);
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
server: { port: 5173 }
|
||||
});
|
||||
Reference in New Issue
Block a user