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:
2026-04-10 09:46:06 +07:00
parent cb0761bedd
commit 22bb9c1371
19 changed files with 2507 additions and 2 deletions
+2 -1
View File
@@ -12,7 +12,8 @@
"Bash(where mvn.cmd)",
"Bash(mvn clean:*)",
"Bash(mvn test:*)",
"Bash(python3:*)"
"Bash(python3:*)",
"Bash(npm --version)"
],
"deny": []
}
+5 -1
View File
@@ -47,4 +47,8 @@ target
/.settings
/.project
/ratel-landlords/.project
.vscode
.vscode
# Web client
node_modules
web-client/dist
+167
View File
@@ -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>
+1122
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -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"
}
}
+18
View File
@@ -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',
});
+7
View File
@@ -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;
+108
View File
@@ -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);
}
}
}
+79
View File
@@ -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;
}
+32
View File
@@ -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');
});
}
}
+223
View File
@@ -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();
}
}
+48
View File
@@ -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();
+48
View File
@@ -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();
+145
View File
@@ -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'));
+178
View File
@@ -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);
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: { port: 5173 }
});