mirror of
https://github.com/tiennm99/gomoku.git
synced 2026-08-06 18:22:55 +00:00
feat(client): boot/menu/game scenes wired to event bus and protobuf dispatch
- boot-scene: connects WS, waits for NICKNAME_SET then starts MenuScene - menu-scene: subscribes to CLIENT_CONNECT/NICKNAME_SET/SHOW_OPTIONS/SHOW_ROOMS/ ROOM_CREATE_SUCCESS/ROOM_JOIN_SUCCESS/ROOM_JOIN_FAIL_*/GAME_STARTING; delegates to menu-ui stubs; proper shutdown() unsubscribes all handlers - game-scene: Board+Stone stub integration, hover preview, click→sendGameMove, GAME_MOVE_SUCCESS/GAME_OVER/CLIENT_EXIT/GAME_STARTING/SPECTATOR_CANNOT_ACT/ move error events all wired; audio feedback; shutdown() cleanup
This commit is contained in:
@@ -1,18 +1,44 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
/**
|
||||
* BootScene — minimal stub for phase-08 bootstrap.
|
||||
* Phase-09 will flesh this out: connect to WebSocket, then start MenuScene.
|
||||
* BootScene — initial scene that connects to the game server and transitions
|
||||
* to MenuScene once the server sends the first NICKNAME_SET prompt.
|
||||
* @module boot-scene
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
export class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'BootScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
this.add
|
||||
.text(400, 400, 'Gomoku — loading…', { fontSize: '32px', color: '#ffffff' })
|
||||
.setOrigin(0.5);
|
||||
// phase-09 will call connectionService.connect() here and transition to MenuScene
|
||||
this.cameras.main.setBackgroundColor('#1a1a2e');
|
||||
|
||||
this.add.text(400, 370, 'Gomoku', {
|
||||
fontSize: '52px',
|
||||
fontFamily: 'sans-serif',
|
||||
color: '#e94560',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(400, 440, 'Connecting to server…', {
|
||||
fontSize: '18px',
|
||||
fontFamily: 'sans-serif',
|
||||
color: '#888888',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Open WebSocket — URL resolved from window.location.hostname at runtime
|
||||
connectionService.connect();
|
||||
|
||||
// Server sends NICKNAME_SET as the first prompt after client connects.
|
||||
// Unsubscribe immediately so later NICKNAME_SET events (e.g. validation
|
||||
// errors returned while already in MenuScene) don't re-trigger this handler.
|
||||
const onNicknamePrompt = () => {
|
||||
eventBus.off(ClientEventCode.NICKNAME_SET, onNicknamePrompt);
|
||||
this.scene.start('MenuScene');
|
||||
};
|
||||
eventBus.on(ClientEventCode.NICKNAME_SET, onNicknamePrompt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,262 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
/**
|
||||
* GameScene — stub for phase-08 bootstrap.
|
||||
* Phase-09 replaces this with full board rendering and game loop.
|
||||
* GameScene — renders the Gomoku board, handles pointer input and move events.
|
||||
* Uses Board for grid geometry, Stone for piece objects, DOM overlay for HUD.
|
||||
* Board and Stone are stubs in phase-09 — phase-10 replaces with real Phaser objects.
|
||||
* @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 { ClientEventCode } from '../config/protocol-constants.js';
|
||||
import {
|
||||
showGameHud,
|
||||
updateTurnIndicator,
|
||||
addMoveToHistory,
|
||||
showGameOver,
|
||||
showToast,
|
||||
hideGameHud,
|
||||
} 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 {{ setDepth: function, destroy: function }|null} */
|
||||
this.lastMarker = null;
|
||||
/** @type {Phaser.GameObjects.Graphics|null} */
|
||||
this.hoverGraphic = null;
|
||||
/** @type {{ row: number, col: number }|null} */
|
||||
this.hoverPos = null;
|
||||
/** @type {AudioContext|null} */
|
||||
this._audioCtx = null;
|
||||
|
||||
// Bind for stable on/off references
|
||||
this._onMoveSuccess = this._onMoveSuccess.bind(this);
|
||||
this._onGameOver = this._onGameOver.bind(this);
|
||||
this._onClientExit = this._onClientExit.bind(this);
|
||||
this._onGameStarting = this._onGameStarting.bind(this);
|
||||
this._onMoveInvalid = this._onMoveInvalid.bind(this);
|
||||
this._onMoveOccupied = this._onMoveOccupied.bind(this);
|
||||
this._onMoveOutOfBounds = this._onMoveOutOfBounds.bind(this);
|
||||
this._onMoveNotYourTurn = this._onMoveNotYourTurn.bind(this);
|
||||
this._onSpectatorCannotAct = this._onSpectatorCannotAct.bind(this);
|
||||
}
|
||||
|
||||
create() {
|
||||
console.log('[GameScene] stub — phase-09 will implement this');
|
||||
this.cameras.main.setBackgroundColor('#1a1a2e');
|
||||
|
||||
this.board = new Board(this, 800);
|
||||
this.stones = [];
|
||||
this.lastMarker = null;
|
||||
|
||||
// Hover preview graphic (phase-10 will fill this with real visuals)
|
||||
this.hoverGraphic = this.add.graphics();
|
||||
this.hoverGraphic.setDepth(5);
|
||||
|
||||
// Pointer handlers
|
||||
this.input.on('pointerdown', this._handleClick, this);
|
||||
this.input.on('pointermove', this._handleHover, this);
|
||||
|
||||
// Lazily create AudioContext on first user gesture (browser autoplay policy)
|
||||
this.input.once('pointerdown', () => {
|
||||
this._audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
});
|
||||
|
||||
// Show HUD
|
||||
showGameHud();
|
||||
|
||||
// Replay any moves already in state (spectate / rejoin 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_MOVE_INVALID, this._onMoveInvalid);
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_OCCUPIED, this._onMoveOccupied);
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_OUT_OF_BOUNDS, this._onMoveOutOfBounds);
|
||||
eventBus.on(ClientEventCode.GAME_MOVE_NOT_YOUR_TURN, this._onMoveNotYourTurn);
|
||||
eventBus.on(ClientEventCode.SPECTATOR_CANNOT_ACT, this._onSpectatorCannotAct);
|
||||
eventBus.on(ClientEventCode.GAME_OVER, this._onGameOver);
|
||||
eventBus.on(ClientEventCode.CLIENT_EXIT, this._onClientExit);
|
||||
eventBus.on(ClientEventCode.GAME_STARTING, this._onGameStarting);
|
||||
}
|
||||
|
||||
/**
|
||||
* Place a stone at (row, col). Updates last-move marker.
|
||||
* @param {number} row
|
||||
* @param {number} col
|
||||
* @param {string} piece - 'BLACK' or 'WHITE'
|
||||
* @param {boolean} [animate]
|
||||
* @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);
|
||||
|
||||
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 — suspended AudioContext or missing browser support */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle board click — compute grid position and send move if it's this client's turn.
|
||||
* @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.sendGameMove(pos.row, pos.col);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle pointer hover — show ghost stone preview on this client's turn.
|
||||
* Avoids redundant redraws when the pointer stays within the same cell.
|
||||
* @param {Phaser.Input.Pointer} pointer
|
||||
* @private
|
||||
*/
|
||||
_handleHover(pointer) {
|
||||
if (gameState.isSpectating || !gameState.isMyTurn()) {
|
||||
if (this.hoverPos) { this.hoverGraphic.clear(); this.hoverPos = null; }
|
||||
return;
|
||||
}
|
||||
const pos = this.board.pixelToGrid(pointer.x, pointer.y);
|
||||
if (!pos || gameState.isOccupied(pos.row, pos.col)) {
|
||||
if (this.hoverPos) { this.hoverGraphic.clear(); this.hoverPos = null; }
|
||||
return;
|
||||
}
|
||||
// Same cell — keep existing preview
|
||||
if (this.hoverPos && this.hoverPos.row === pos.row && this.hoverPos.col === pos.col) return;
|
||||
this.hoverGraphic.clear();
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server confirmed move — place stone and update HUD.
|
||||
* @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(gameState.currentTurn);
|
||||
if (this.hoverGraphic) this.hoverGraphic.clear();
|
||||
this.hoverPos = null;
|
||||
}
|
||||
|
||||
/** @private */ _onMoveInvalid(_d) { showToast('Invalid move.', 'error'); }
|
||||
/** @private */ _onMoveOccupied(_d) { showToast('Cell is already occupied.', 'error'); }
|
||||
/** @private */ _onMoveOutOfBounds(_d) { showToast('Move is out of bounds.', 'error'); }
|
||||
/** @private */ _onMoveNotYourTurn(_d) { showToast("It's not your turn.", 'warn'); }
|
||||
/** @private */ _onSpectatorCannotAct(_d) { showToast('Spectators cannot move.', 'warn'); }
|
||||
|
||||
/**
|
||||
* Game over — show result overlay; play win/lose audio sequence.
|
||||
* @param {{ result: string, winnerNickname?: string }} data
|
||||
* @private
|
||||
*/
|
||||
_onGameOver(data) {
|
||||
showGameOver(data);
|
||||
if (!this._audioCtx) return;
|
||||
const isWin = !gameState.isSpectating && (
|
||||
(data.result === 'BLACK_WIN' && gameState.isBlack)
|
||||
|| (data.result === 'WHITE_WIN' && !gameState.isBlack)
|
||||
);
|
||||
const freqs = isWin ? [523, 659, 784] : [400, 300];
|
||||
freqs.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);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opponent disconnected or client exited — return to menu lobby.
|
||||
* @private
|
||||
*/
|
||||
_onClientExit() {
|
||||
hideGameHud();
|
||||
this._cleanup();
|
||||
this.scene.start('MenuScene');
|
||||
showLobby();
|
||||
}
|
||||
|
||||
/**
|
||||
* New game starting while already in GameScene (rematch).
|
||||
* Clear board and re-show HUD.
|
||||
* @private
|
||||
*/
|
||||
_onGameStarting() {
|
||||
this.stones.forEach(s => s.destroy());
|
||||
this.stones = [];
|
||||
if (this.lastMarker) { this.lastMarker.destroy(); this.lastMarker = null; }
|
||||
if (this.hoverGraphic) this.hoverGraphic.clear();
|
||||
this.hoverPos = null;
|
||||
showGameHud();
|
||||
}
|
||||
|
||||
/** Unsubscribe all event bus listeners. @private */
|
||||
_cleanup() {
|
||||
eventBus.off(ClientEventCode.GAME_MOVE_SUCCESS, this._onMoveSuccess);
|
||||
eventBus.off(ClientEventCode.GAME_MOVE_INVALID, this._onMoveInvalid);
|
||||
eventBus.off(ClientEventCode.GAME_MOVE_OCCUPIED, this._onMoveOccupied);
|
||||
eventBus.off(ClientEventCode.GAME_MOVE_OUT_OF_BOUNDS, this._onMoveOutOfBounds);
|
||||
eventBus.off(ClientEventCode.GAME_MOVE_NOT_YOUR_TURN, this._onMoveNotYourTurn);
|
||||
eventBus.off(ClientEventCode.SPECTATOR_CANNOT_ACT, this._onSpectatorCannotAct);
|
||||
eventBus.off(ClientEventCode.GAME_OVER, this._onGameOver);
|
||||
eventBus.off(ClientEventCode.CLIENT_EXIT, this._onClientExit);
|
||||
eventBus.off(ClientEventCode.GAME_STARTING, this._onGameStarting);
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this._cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,142 @@
|
||||
import Phaser from 'phaser';
|
||||
|
||||
/**
|
||||
* MenuScene — stub for phase-08 bootstrap.
|
||||
* Phase-09 replaces this with full lobby/room UI.
|
||||
* MenuScene — shows DOM overlay menus (nickname, lobby, room list).
|
||||
* Phaser canvas provides a decorative background; all interaction is via DOM
|
||||
* overlay managed by menu-ui.js (phase-10 implements real UI).
|
||||
* @module menu-scene
|
||||
*/
|
||||
|
||||
import Phaser from 'phaser';
|
||||
import { eventBus } from '../services/event-bus.js';
|
||||
import { ClientEventCode } from '../config/protocol-constants.js';
|
||||
import {
|
||||
showNicknameScreen,
|
||||
showLobby,
|
||||
showRoomList,
|
||||
showWaiting,
|
||||
hideOverlay,
|
||||
} from '../ui/menu-ui.js';
|
||||
import { showToast } from '../ui/game-ui.js';
|
||||
|
||||
export class MenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MenuScene' });
|
||||
// Bind handlers so the same reference is used for on() and off()
|
||||
this._onGameStarting = this._onGameStarting.bind(this);
|
||||
this._onNicknameSet = this._onNicknameSet.bind(this);
|
||||
this._onShowOptions = this._onShowOptions.bind(this);
|
||||
this._onShowRooms = this._onShowRooms.bind(this);
|
||||
this._onRoomCreateSuccess = this._onRoomCreateSuccess.bind(this);
|
||||
this._onRoomJoinSuccess = this._onRoomJoinSuccess.bind(this);
|
||||
this._onRoomJoinFailFull = this._onRoomJoinFailFull.bind(this);
|
||||
this._onRoomJoinFailNotFound = this._onRoomJoinFailNotFound.bind(this);
|
||||
}
|
||||
|
||||
create() {
|
||||
console.log('[MenuScene] stub — phase-09 will implement this');
|
||||
// Subtle decorative background
|
||||
this.cameras.main.setBackgroundColor('#1a1a2e');
|
||||
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 — server has already sent NICKNAME_SET prompt
|
||||
showNicknameScreen();
|
||||
|
||||
// Register event listeners
|
||||
eventBus.on(ClientEventCode.NICKNAME_SET, this._onNicknameSet);
|
||||
eventBus.on(ClientEventCode.SHOW_OPTIONS, this._onShowOptions);
|
||||
eventBus.on(ClientEventCode.SHOW_ROOMS, this._onShowRooms);
|
||||
eventBus.on(ClientEventCode.ROOM_CREATE_SUCCESS, this._onRoomCreateSuccess);
|
||||
eventBus.on(ClientEventCode.ROOM_JOIN_SUCCESS, this._onRoomJoinSuccess);
|
||||
eventBus.on(ClientEventCode.ROOM_JOIN_FAIL_FULL, this._onRoomJoinFailFull);
|
||||
eventBus.on(ClientEventCode.ROOM_JOIN_FAIL_INEXIST, this._onRoomJoinFailNotFound);
|
||||
eventBus.on(ClientEventCode.GAME_STARTING, this._onGameStarting);
|
||||
}
|
||||
|
||||
/** Server accepted the nickname — show the main lobby. @private */
|
||||
_onNicknameSet(_data) {
|
||||
showLobby();
|
||||
}
|
||||
|
||||
/** Server sent main menu options — show lobby UI. @private */
|
||||
_onShowOptions(_data) {
|
||||
showLobby();
|
||||
}
|
||||
|
||||
/**
|
||||
* Server sent available room list.
|
||||
* @param {{ rooms: Array }} data
|
||||
* @private
|
||||
*/
|
||||
_onShowRooms(data) {
|
||||
const rooms = (data && data.rooms) ? data.rooms : [];
|
||||
showRoomList(
|
||||
rooms,
|
||||
(roomId) => { /* phase-10 wires join button */ },
|
||||
(roomId) => { /* phase-10 wires watch button */ },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Room created — show waiting room for owner.
|
||||
* @param {{ roomId: number, ownerNickname: string }} data
|
||||
* @private
|
||||
*/
|
||||
_onRoomCreateSuccess(data) {
|
||||
showWaiting(
|
||||
true,
|
||||
data && data.ownerNickname ? data.ownerNickname : '',
|
||||
1,
|
||||
() => { /* phase-10: owner start button calls connectionService.sendGameStarting() */ },
|
||||
() => { /* phase-10: leave button calls connectionService.sendClientExit() */ },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join succeeded — show waiting room as non-owner.
|
||||
* @param {{ roomId: number, ownerNickname: string, playerCount: number }} data
|
||||
* @private
|
||||
*/
|
||||
_onRoomJoinSuccess(data) {
|
||||
showWaiting(
|
||||
false,
|
||||
data && data.ownerNickname ? data.ownerNickname : '',
|
||||
data && data.playerCount ? data.playerCount : 2,
|
||||
null,
|
||||
() => { /* phase-10: leave button */ },
|
||||
);
|
||||
}
|
||||
|
||||
/** Room is full — notify user. @private */
|
||||
_onRoomJoinFailFull(_data) {
|
||||
showToast('Room is full — please try another.', 'error');
|
||||
}
|
||||
|
||||
/** Room not found — notify user. @private */
|
||||
_onRoomJoinFailNotFound(_data) {
|
||||
showToast('Room not found.', 'error');
|
||||
}
|
||||
|
||||
/**
|
||||
* Game starting — hide menu overlay and transition to GameScene.
|
||||
* @param {object} data - GameStartingResponse payload
|
||||
* @private
|
||||
*/
|
||||
_onGameStarting(data) {
|
||||
hideOverlay();
|
||||
this.scene.start('GameScene', { gameStarting: data });
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
eventBus.off(ClientEventCode.NICKNAME_SET, this._onNicknameSet);
|
||||
eventBus.off(ClientEventCode.SHOW_OPTIONS, this._onShowOptions);
|
||||
eventBus.off(ClientEventCode.SHOW_ROOMS, this._onShowRooms);
|
||||
eventBus.off(ClientEventCode.ROOM_CREATE_SUCCESS, this._onRoomCreateSuccess);
|
||||
eventBus.off(ClientEventCode.ROOM_JOIN_SUCCESS, this._onRoomJoinSuccess);
|
||||
eventBus.off(ClientEventCode.ROOM_JOIN_FAIL_FULL, this._onRoomJoinFailFull);
|
||||
eventBus.off(ClientEventCode.ROOM_JOIN_FAIL_INEXIST, this._onRoomJoinFailNotFound);
|
||||
eventBus.off(ClientEventCode.GAME_STARTING, this._onGameStarting);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user