diff --git a/client/src/scenes/boot-scene.js b/client/src/scenes/boot-scene.js index 9819aeb..a653590 100644 --- a/client/src/scenes/boot-scene.js +++ b/client/src/scenes/boot-scene.js @@ -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); } } diff --git a/client/src/scenes/game-scene.js b/client/src/scenes/game-scene.js index 321ea7e..c3e08fa 100644 --- a/client/src/scenes/game-scene.js +++ b/client/src/scenes/game-scene.js @@ -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(); } } diff --git a/client/src/scenes/menu-scene.js b/client/src/scenes/menu-scene.js index 6485998..34ef525 100644 --- a/client/src/scenes/menu-scene.js +++ b/client/src/scenes/menu-scene.js @@ -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); } }