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:
2026-04-11 13:41:40 +07:00
parent 6b2035632f
commit 2c37c8f4ef
3 changed files with 418 additions and 18 deletions
+34 -8
View File
@@ -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);
}
}
+252 -5
View File
@@ -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();
}
}
+132 -5
View File
@@ -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);
}
}