mirror of
https://github.com/tiennm99/caro.git
synced 2026-09-03 04:17:40 +00:00
plan: Phaser 3 web client - 6 phases, separate deployment
Standalone Phaser 3 + Vite + vanilla JS (JSDoc) Gomoku web client. Connects to existing server via WebSocket. DOM overlays for menus, Canvas for board. 6 phases, ~12h effort.
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# Phase 1: Project Scaffold
|
||||
|
||||
## Context Links
|
||||
- [Plan overview](plan.md)
|
||||
- Server WS handler: `landlords-server/.../handler/WebsocketTransferHandler.java`
|
||||
- Server WS proxy: `landlords-server/.../proxy/WebsocketProxy.java`
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1 (blocker for all other phases)
|
||||
- **Status:** Pending
|
||||
- **Description:** Initialize Vite + Phaser 3 project in `web-client/`, verify Phaser boots a blank canvas.
|
||||
|
||||
## Requirements
|
||||
- `npm create vite` with vanilla JS template (or manual init)
|
||||
- Phaser 3 latest stable as dependency
|
||||
- Vite dev server on port 5173 (default)
|
||||
- `index.html` with a `#game-container` div for Phaser canvas + a `#ui-overlay` div for DOM menus
|
||||
- `src/main.js` creates Phaser.Game with config from `src/config/game-config.js`
|
||||
- BootScene placeholder that shows "Loading..." text
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
web-client/
|
||||
package.json
|
||||
vite.config.js
|
||||
index.html <-- #game-container + #ui-overlay
|
||||
src/
|
||||
main.js <-- Phaser.Game instantiation
|
||||
config/
|
||||
game-config.js <-- Phaser config: 800x800, Scale.FIT, scenes list
|
||||
scenes/
|
||||
boot-scene.js <-- placeholder "Loading..." text
|
||||
public/
|
||||
(empty, for future assets)
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to Create
|
||||
- `web-client/package.json`
|
||||
- `web-client/vite.config.js`
|
||||
- `web-client/index.html`
|
||||
- `web-client/src/main.js`
|
||||
- `web-client/src/config/game-config.js`
|
||||
- `web-client/src/scenes/boot-scene.js`
|
||||
|
||||
### Files to Modify
|
||||
- None
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. Create `web-client/` directory at repo root
|
||||
2. Create `package.json` with:
|
||||
- `name: "caro-web-client"`
|
||||
- `type: "module"`
|
||||
- `scripts: { "dev": "vite", "build": "vite build", "preview": "vite preview" }`
|
||||
- `dependencies: { "phaser": "^3.80.0" }`
|
||||
- `devDependencies: { "vite": "^6.0.0" }`
|
||||
3. Create `vite.config.js`:
|
||||
```js
|
||||
import { defineConfig } from 'vite';
|
||||
export default defineConfig({
|
||||
server: { port: 5173 }
|
||||
});
|
||||
```
|
||||
4. Create `index.html`:
|
||||
- Minimal HTML5 boilerplate
|
||||
- `<div id="game-container"></div>` -- Phaser mounts here
|
||||
- `<div id="ui-overlay"></div>` -- DOM menus render here (hidden by default)
|
||||
- `<script type="module" src="/src/main.js"></script>`
|
||||
- Basic CSS: body margin 0, background #1a1a2e, flex-center the container, overlay absolute positioned over canvas
|
||||
5. Create `src/config/game-config.js`:
|
||||
- Export Phaser config object: `type: Phaser.AUTO`, `width: 800`, `height: 800`
|
||||
- `scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }`
|
||||
- `parent: 'game-container'`
|
||||
- `backgroundColor: '#2d2d44'`
|
||||
- `scene: [BootScene]` (import from scenes)
|
||||
6. Create `src/scenes/boot-scene.js`:
|
||||
- Extends `Phaser.Scene`, key: `'BootScene'`
|
||||
- `create()`: display centered "Loading..." text
|
||||
- Will be expanded in Phase 3 to transition to MenuScene
|
||||
7. Create `src/main.js`:
|
||||
- Import config from `game-config.js`
|
||||
- `new Phaser.Game(config)`
|
||||
- Export game instance for potential service access
|
||||
8. Run `npm install` and `npm run dev` to verify Phaser boots
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `npm run dev` starts Vite on port 5173
|
||||
- [ ] Browser shows Phaser canvas with "Loading..." text
|
||||
- [ ] No console errors
|
||||
- [ ] `npm run build` produces working static build in `dist/`
|
||||
|
||||
## Risk Assessment
|
||||
- **Node.js not installed:** User must have Node.js. Document in README.
|
||||
- **Phaser version mismatch:** Pin to `^3.80.0` for stability.
|
||||
|
||||
## Next Steps
|
||||
- Phase 2: Services layer (can start immediately after scaffold)
|
||||
@@ -0,0 +1,190 @@
|
||||
# Phase 2: Services Layer
|
||||
|
||||
## Context Links
|
||||
- [Plan overview](plan.md)
|
||||
- [Phase 1: Scaffold](phase-01-project-scaffold.md)
|
||||
- Server `Msg` entity: `landlords-common/.../entity/Msg.java` -- `{code, data, info}`
|
||||
- Server event codes: `landlords-common/.../enums/ServerEventCode.java`, `ClientEventCode.java`
|
||||
- Server WS handler: `landlords-server/.../handler/WebsocketTransferHandler.java`
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1 (all scenes depend on these services)
|
||||
- **Status:** Pending
|
||||
- **Blocked by:** Phase 1
|
||||
- **Description:** Build three decoupled service modules: WebSocket connection, event bus, game state. Plus protocol constants extracted from server source.
|
||||
|
||||
## Key Insights
|
||||
- Server WS message format: `{"code": "CODE_...", "data": "json_string_or_plain_string", "info": ""}`
|
||||
- `data` field is a JSON **string** (not nested object) -- must `JSON.parse(data)` when data is structured
|
||||
- Server sends `CODE_CLIENT_CONNECT` + `CODE_CLIENT_NICKNAME_SET` ~2s after WS handshake (server has `Thread.sleep(2000L)`)
|
||||
- Heartbeat: server reads idle timeout; client must send `CODE_CLIENT_HEAD_BEAT` every ~50s
|
||||
- Server ignores heartbeat messages (no handler called, just keeps connection alive)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
connection-service.js
|
||||
|-- wraps browser WebSocket
|
||||
|-- auto-reconnect with backoff
|
||||
|-- heartbeat timer (50s interval)
|
||||
|-- on message: parse JSON -> event-bus.emit(code, parsedData)
|
||||
|-- send(code, data): serialize to Msg format -> ws.send()
|
||||
|
||||
event-bus.js
|
||||
|-- on(event, callback): subscribe
|
||||
|-- off(event, callback): unsubscribe
|
||||
|-- emit(event, data): notify all subscribers
|
||||
|
||||
game-state-service.js
|
||||
|-- stores: clientId, nickname, roomId, isBlack, isMyTurn, boardState[][], moves[]
|
||||
|-- reset methods for new game / exit room
|
||||
|-- no logic, pure state container
|
||||
|
||||
protocol-constants.js
|
||||
|-- SERVER_EVENTS: all CODE_CLIENT_* codes (server -> client)
|
||||
|-- CLIENT_EVENTS: all CODE_* codes (client -> server)
|
||||
|-- string constants, no enums needed
|
||||
```
|
||||
|
||||
### Data Flow: Sending a Move
|
||||
```
|
||||
GameScene.onBoardClick(row, col)
|
||||
-> connectionService.send('CODE_GAME_MOVE', JSON.stringify({row, col}))
|
||||
-> ws.send('{"code":"CODE_GAME_MOVE","data":"{\"row\":7,\"col\":7}","info":""}')
|
||||
```
|
||||
|
||||
### Data Flow: Receiving a Move
|
||||
```
|
||||
ws.onmessage(frame)
|
||||
-> JSON.parse(frame.data) => {code: "CODE_GAME_MOVE_SUCCESS", data: "{\"row\":7,...}", info: ""}
|
||||
-> eventBus.emit('CODE_GAME_MOVE_SUCCESS', {row:7, col:7, piece:"BLACK", playerNickname:"p1", playerId:1})
|
||||
(data string auto-parsed to object by connection-service)
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to Create
|
||||
- `web-client/src/services/connection-service.js`
|
||||
- `web-client/src/services/event-bus.js`
|
||||
- `web-client/src/services/game-state-service.js`
|
||||
- `web-client/src/config/protocol-constants.js`
|
||||
|
||||
### Files to Modify
|
||||
- None
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. `protocol-constants.js`
|
||||
|
||||
Export two frozen objects with all event code strings:
|
||||
|
||||
```js
|
||||
/** @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',
|
||||
});
|
||||
|
||||
/** @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',
|
||||
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',
|
||||
});
|
||||
```
|
||||
|
||||
### 2. `event-bus.js`
|
||||
|
||||
Simple pub/sub:
|
||||
- `_listeners` Map of `event -> Set<callback>`
|
||||
- `on(event, cb)` -- add listener
|
||||
- `off(event, cb)` -- remove listener
|
||||
- `emit(event, data)` -- call all listeners for event
|
||||
- Export singleton instance
|
||||
- ~40 lines
|
||||
|
||||
### 3. `connection-service.js`
|
||||
|
||||
WebSocket wrapper:
|
||||
- `connect(url)` -- create WebSocket, attach handlers
|
||||
- `send(code, data)` -- build `{code, data, info:""}`, `ws.send(JSON.stringify(msg))`
|
||||
- `disconnect()` -- close WS, clear heartbeat
|
||||
- Internal: `_onMessage(event)`:
|
||||
1. `JSON.parse(event.data)` to get `{code, data, info}`
|
||||
2. Try `JSON.parse(msg.data)` for structured data; fall back to raw string
|
||||
3. `eventBus.emit(msg.code, parsedData)`
|
||||
- Internal: `_startHeartbeat()` -- `setInterval` every 50000ms, send `CODE_CLIENT_HEAD_BEAT`
|
||||
- Internal: `_stopHeartbeat()` -- `clearInterval`
|
||||
- `onopen`: emit internal `'ws:connected'` event
|
||||
- `onclose`: emit `'ws:disconnected'`, attempt reconnect with exponential backoff (1s, 2s, 4s, max 30s)
|
||||
- `onerror`: log, let `onclose` handle reconnect
|
||||
- Export singleton
|
||||
- ~80 lines
|
||||
|
||||
**Critical detail:** The `data` field in outgoing messages must be a **string**. For structured data like `{row, col}`, use `JSON.stringify({row, col})` as the data value. For simple strings like nickname, pass the string directly.
|
||||
|
||||
### 4. `game-state-service.js`
|
||||
|
||||
Plain state object:
|
||||
- `clientId` -- set on `CODE_CLIENT_CONNECT`
|
||||
- `nickname` -- set after nickname submission
|
||||
- `roomId` -- set on room create/join
|
||||
- `isBlack` -- derived from `CODE_GAME_STARTING` comparing clientId to blackPlayerId
|
||||
- `isMyTurn` -- toggled on each `CODE_GAME_MOVE_SUCCESS`
|
||||
- `board` -- 15x15 2D array, initialized to `null`, set cells on move success
|
||||
- `moves` -- array of `{row, col, piece, playerNickname}` for move history
|
||||
- `isSpectating` -- boolean
|
||||
- `reset()` -- clear room/game state
|
||||
- `resetBoard()` -- clear board/moves for rematch
|
||||
- Export singleton
|
||||
- ~60 lines
|
||||
|
||||
## Todo List
|
||||
- [ ] Create `protocol-constants.js` with all event codes
|
||||
- [ ] Create `event-bus.js` with on/off/emit
|
||||
- [ ] Create `connection-service.js` with connect/send/heartbeat/reconnect
|
||||
- [ ] Create `game-state-service.js` with state fields and reset methods
|
||||
- [ ] Verify WS connection to running server in browser console
|
||||
|
||||
## Success Criteria
|
||||
- [ ] `eventBus.on('CODE_CLIENT_CONNECT', cb)` fires when server sends connect event
|
||||
- [ ] `connectionService.send('CODE_CLIENT_NICKNAME_SET', 'TestUser')` accepted by server
|
||||
- [ ] Heartbeat keeps connection alive beyond 60s
|
||||
- [ ] `game-state-service` stores and resets state correctly
|
||||
- [ ] All files under 100 lines each
|
||||
- [ ] JSDoc on all exported functions
|
||||
|
||||
## Risk Assessment
|
||||
- **`data` field double-encoding:** Server expects `data` as a string. If we pass an object, server's `MapHelper.parser()` will fail. Mitigation: `connection-service.send()` must `JSON.stringify` objects before placing in `data` field.
|
||||
- **Reconnect during active game:** If WS drops mid-game, server has no rejoin mechanism. Mitigation: show "Connection lost" overlay; on reconnect, user starts fresh (server already cleaned up the room via `CODE_CLIENT_OFFLINE`).
|
||||
|
||||
## Next Steps
|
||||
- Phase 3 (Boot + Menu scenes) depends on these services being complete
|
||||
@@ -0,0 +1,206 @@
|
||||
# Phase 3: Boot + Menu Scenes
|
||||
|
||||
## Context Links
|
||||
- [Plan overview](plan.md)
|
||||
- [Phase 2: Services](phase-02-services-layer.md)
|
||||
- Server nickname handler: `landlords-server/.../event/ServerEventListener_CODE_CLIENT_NICKNAME_SET.java` -- max 10 chars, non-empty
|
||||
- Server show options flow: after nickname set, server sends `CODE_SHOW_OPTIONS`
|
||||
- Server room list: `CODE_GET_ROOMS` -> `CODE_SHOW_ROOMS` with `[{roomId, roomOwner, roomClientCount, roomType}]`
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1
|
||||
- **Status:** Pending
|
||||
- **Blocked by:** Phase 1, Phase 2
|
||||
- **Description:** BootScene connects to server, MenuScene manages all pre-game UI via DOM overlays (nickname, lobby, PVP/PVE menus).
|
||||
|
||||
## Key Insights
|
||||
- Server flow after WS connect: waits 2s, sends `CODE_CLIENT_CONNECT` (data=clientId), then `CODE_CLIENT_NICKNAME_SET` (data=null, meaning "please set nickname")
|
||||
- If nickname invalid (empty or >10 chars), server sends `CODE_CLIENT_NICKNAME_SET` again with `{invalidLength: N}` -- client should re-prompt
|
||||
- After valid nickname, server sends `CODE_SHOW_OPTIONS` -- client shows main menu
|
||||
- PVP: `CODE_ROOM_CREATE` -> `CODE_ROOM_CREATE_SUCCESS` (room JSON) -> wait for opponent -> auto-starts on join
|
||||
- PVE: `CODE_ROOM_CREATE_PVE` with data "1"/"2"/"3" -> server auto-starts immediately
|
||||
- Join room: `CODE_ROOM_JOIN` with data=roomId string -> `CODE_ROOM_JOIN_SUCCESS` -> auto-starts if 2 players
|
||||
|
||||
## Architecture
|
||||
|
||||
### Scene Flow
|
||||
```
|
||||
BootScene
|
||||
create(): connect to WS, show "Connecting..." text on canvas
|
||||
on 'CODE_CLIENT_CONNECT': store clientId, show "Connected!"
|
||||
on 'CODE_CLIENT_NICKNAME_SET': transition to MenuScene
|
||||
|
||||
MenuScene
|
||||
create(): show nickname form via menu-ui.js
|
||||
Substates (managed by menu-ui.js DOM swaps):
|
||||
1. NICKNAME -- input + submit button
|
||||
2. LOBBY -- main menu: PVP / PVE / Spectate buttons
|
||||
3. PVP_MENU -- "Create Room" button + room list table + join button
|
||||
4. PVE_MENU -- difficulty picker (Easy/Medium/Hard)
|
||||
5. SPECTATE_MENU -- room list + watch button
|
||||
6. WAITING -- "Waiting for opponent..." (after PVP room create)
|
||||
|
||||
on 'CODE_SHOW_OPTIONS': switch to LOBBY substate
|
||||
on 'CODE_SHOW_ROOMS': populate room list table
|
||||
on 'CODE_ROOM_CREATE_SUCCESS': switch to WAITING, store roomId
|
||||
on 'CODE_ROOM_JOIN_SUCCESS': store room info
|
||||
on 'CODE_GAME_STARTING': hide all overlays, transition to GameScene
|
||||
on 'CODE_ROOM_JOIN_FAIL_*': show error toast
|
||||
on 'CODE_PVE_DIFFICULTY_NOT_SUPPORT': show error toast
|
||||
on 'CODE_GAME_WATCH_SUCCESSFUL': transition to GameScene (spectator mode)
|
||||
```
|
||||
|
||||
### DOM Overlay Strategy
|
||||
- All menus live in `#ui-overlay` div (positioned absolute over Phaser canvas)
|
||||
- `menu-ui.js` manages showing/hiding substate containers
|
||||
- Phaser canvas stays visible as background (dark board aesthetic)
|
||||
- On transition to GameScene, hide `#ui-overlay` entirely
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to Create
|
||||
- `web-client/src/scenes/boot-scene.js` (overwrite Phase 1 placeholder)
|
||||
- `web-client/src/scenes/menu-scene.js`
|
||||
- `web-client/src/ui/menu-ui.js`
|
||||
|
||||
### Files to Modify
|
||||
- `web-client/index.html` -- add DOM overlay structure and CSS
|
||||
- `web-client/src/config/game-config.js` -- add MenuScene to scene list
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Update `index.html` -- Add DOM overlay structure
|
||||
|
||||
Inside `#ui-overlay`, add containers for each substate:
|
||||
|
||||
```html
|
||||
<div id="ui-overlay" class="hidden">
|
||||
<div id="nickname-screen" class="ui-screen hidden">
|
||||
<h1>Caro (Gomoku)</h1>
|
||||
<input id="nickname-input" maxlength="10" placeholder="Enter nickname..." />
|
||||
<button id="nickname-submit">Play</button>
|
||||
<p id="nickname-error" class="error hidden"></p>
|
||||
</div>
|
||||
|
||||
<div id="lobby-screen" class="ui-screen hidden">
|
||||
<h2>Welcome, <span id="player-name"></span></h2>
|
||||
<button id="btn-pvp">Player vs Player</button>
|
||||
<button id="btn-pve">Player vs AI</button>
|
||||
<button id="btn-spectate">Spectate</button>
|
||||
</div>
|
||||
|
||||
<div id="pvp-screen" class="ui-screen hidden">
|
||||
<h2>PVP Lobby</h2>
|
||||
<button id="btn-create-room">Create Room</button>
|
||||
<button id="btn-refresh-rooms">Refresh</button>
|
||||
<table id="room-table"><thead><tr><th>ID</th><th>Owner</th><th>Players</th><th></th></tr></thead><tbody></tbody></table>
|
||||
<button id="btn-back-pvp">Back</button>
|
||||
</div>
|
||||
|
||||
<div id="pve-screen" class="ui-screen hidden">
|
||||
<h2>Play vs AI</h2>
|
||||
<button data-difficulty="1">Easy</button>
|
||||
<button data-difficulty="2">Medium</button>
|
||||
<button data-difficulty="3">Hard</button>
|
||||
<button id="btn-back-pve">Back</button>
|
||||
</div>
|
||||
|
||||
<div id="spectate-screen" class="ui-screen hidden">
|
||||
<h2>Spectate</h2>
|
||||
<button id="btn-refresh-spectate">Refresh</button>
|
||||
<table id="spectate-table"><thead><tr><th>ID</th><th>Owner</th><th>Players</th><th></th></tr></thead><tbody></tbody></table>
|
||||
<button id="btn-back-spectate">Back</button>
|
||||
</div>
|
||||
|
||||
<div id="waiting-screen" class="ui-screen hidden">
|
||||
<h2>Waiting for opponent...</h2>
|
||||
<p>Room ID: <span id="waiting-room-id"></span></p>
|
||||
<button id="btn-cancel-wait">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
CSS: dark theme, centered cards, simple button styles. Keep inline in `<style>` tag (~50 lines).
|
||||
|
||||
### 2. `boot-scene.js` (~50 lines)
|
||||
|
||||
```
|
||||
- constructor: super({ key: 'BootScene' })
|
||||
- create():
|
||||
- Display "Connecting..." centered text
|
||||
- connectionService.connect(wsUrl)
|
||||
- eventBus.on(CLIENT_CONNECT, (clientId) => {
|
||||
gameState.clientId = clientId
|
||||
this.statusText.setText('Connected!')
|
||||
})
|
||||
- eventBus.on(NICKNAME_SET, () => {
|
||||
this.scene.start('MenuScene')
|
||||
})
|
||||
- wsUrl: derive from window.location or fallback 'ws://localhost:1025/ratel'
|
||||
- Config: check URL param ?ws=... for override, else default
|
||||
```
|
||||
|
||||
### 3. `menu-scene.js` (~80 lines)
|
||||
|
||||
```
|
||||
- constructor: super({ key: 'MenuScene' })
|
||||
- create():
|
||||
- menuUi.show('nickname')
|
||||
- Register event listeners on eventBus for all menu-related server events
|
||||
- Wire up menuUi callbacks for user actions
|
||||
- Event handlers:
|
||||
- CODE_SHOW_OPTIONS: menuUi.show('lobby')
|
||||
- CODE_SHOW_ROOMS: menuUi.populateRooms(data)
|
||||
- CODE_ROOM_CREATE_SUCCESS: gameState.roomId = data.id; menuUi.show('waiting')
|
||||
- CODE_ROOM_JOIN_SUCCESS: store room info in gameState
|
||||
- CODE_GAME_STARTING: store game info, menuUi.hideAll(), this.scene.start('GameScene')
|
||||
- CODE_ROOM_JOIN_FAIL_*: menuUi.showError('Room full' / 'Room not found')
|
||||
- CODE_GAME_WATCH_SUCCESSFUL: gameState.isSpectating = true; menuUi.hideAll(); this.scene.start('GameScene')
|
||||
- shutdown(): unsubscribe all eventBus listeners, menuUi.hideAll()
|
||||
```
|
||||
|
||||
### 4. `menu-ui.js` (~150 lines)
|
||||
|
||||
DOM manipulation module:
|
||||
- `show(screen)` -- hide all `.ui-screen`, show target, show overlay
|
||||
- `hideAll()` -- hide overlay
|
||||
- `populateRooms(roomList)` -- clear + rebuild room table tbody with join/watch buttons
|
||||
- `showError(msg)` -- show toast or error text, auto-hide after 3s
|
||||
- `bindActions(callbacks)` -- attach click handlers to all buttons, pass action callbacks:
|
||||
- `onNicknameSubmit(nickname)`
|
||||
- `onCreateRoom()`
|
||||
- `onJoinRoom(roomId)`
|
||||
- `onCreatePVE(difficulty)`
|
||||
- `onWatchRoom(roomId)`
|
||||
- `onRefreshRooms()`
|
||||
- `onCancelWait()`
|
||||
- `onBack()`
|
||||
|
||||
Each callback in menu-scene.js calls the appropriate `connectionService.send()`.
|
||||
|
||||
## Todo List
|
||||
- [ ] Add DOM overlay HTML structure to `index.html`
|
||||
- [ ] Add CSS styles (dark theme) to `index.html`
|
||||
- [ ] Implement `boot-scene.js` with WS connect + transition
|
||||
- [ ] Implement `menu-ui.js` with show/hide/populate/bind
|
||||
- [ ] Implement `menu-scene.js` wiring eventBus to menuUi
|
||||
- [ ] Update `game-config.js` scene list: [BootScene, MenuScene]
|
||||
- [ ] Test: nickname flow end-to-end with running server
|
||||
- [ ] Test: PVP room create + join flow
|
||||
- [ ] Test: PVE game start flow
|
||||
|
||||
## Success Criteria
|
||||
- [ ] BootScene connects and transitions to MenuScene on server prompt
|
||||
- [ ] Nickname submission accepted by server, lobby appears
|
||||
- [ ] PVP room creation shows waiting screen with room ID
|
||||
- [ ] Room list populates with active rooms, join works
|
||||
- [ ] PVE difficulty selection starts game immediately
|
||||
- [ ] Error toasts show on join failures
|
||||
- [ ] All files under 150 lines, JSDoc on exports
|
||||
|
||||
## Risk Assessment
|
||||
- **DOM events not cleaned up on scene restart:** Each `menu-scene.js` shutdown must unbind eventBus listeners. Use named function refs (not anonymous) so `off()` works.
|
||||
- **Race condition:** `CODE_GAME_STARTING` can arrive very quickly after `CODE_ROOM_JOIN_SUCCESS` in PVP (server auto-starts when 2 players join). MenuScene must handle both events without assuming a delay.
|
||||
|
||||
## Next Steps
|
||||
- Phase 4: GameScene + board rendering (depends on this phase)
|
||||
@@ -0,0 +1,202 @@
|
||||
# Phase 4: Game Scene + Board Rendering
|
||||
|
||||
## Context Links
|
||||
- [Plan overview](plan.md)
|
||||
- [Phase 3: Boot + Menu](phase-03-boot-menu-scenes.md)
|
||||
- Server move handler: `landlords-server/.../event/ServerEventListener_CODE_GAME_MOVE.java`
|
||||
- Server game starting: `landlords-server/.../event/ServerEventListener_CODE_GAME_STARTING.java`
|
||||
- Move success data: `{row, col, piece("BLACK"/"WHITE"), playerNickname, playerId}`
|
||||
- Board size: 15x15 (from `Board.BOARD_SIZE`)
|
||||
|
||||
## Overview
|
||||
- **Priority:** P1
|
||||
- **Status:** Pending
|
||||
- **Blocked by:** Phase 3
|
||||
- **Description:** Render the Gomoku board using Phaser Graphics, handle click-to-place, display player info and turn indicator via DOM panel.
|
||||
|
||||
## Key Insights
|
||||
- `CODE_GAME_STARTING` data: `{roomId, blackPlayerId, blackPlayerNickname, whitePlayerId, whitePlayerNickname, boardSize: 15}`
|
||||
- Player determines their color by comparing `gameState.clientId` to `blackPlayerId`
|
||||
- Turn alternates: black always first. Client tracks turn locally via move count (odd=black, even=white) or by checking `playerId` in move success
|
||||
- `CODE_GAME_MOVE_SUCCESS` is broadcast to BOTH players (and spectators). Client must render the move regardless of who made it.
|
||||
- Move errors (`OCCUPIED`, `OUT_OF_BOUNDS`, `NOT_YOUR_TURN`, `INVALID`) only sent to the player who attempted the invalid move
|
||||
|
||||
## Architecture
|
||||
|
||||
### Board Rendering (Phaser Graphics)
|
||||
```
|
||||
800x800 canvas
|
||||
Margin: 40px each side -> play area: 720x720
|
||||
Grid: 15x15 intersections (14 gaps)
|
||||
Cell size: 720 / 14 = ~51.4px
|
||||
Grid lines: Phaser.Graphics lines, color #8B7355 (wood brown)
|
||||
Star points: 5 dots at standard Gomoku positions (3,3), (3,11), (7,7), (11,3), (11,11)
|
||||
|
||||
Stone: Phaser.Graphics circle, radius ~22px
|
||||
BLACK: radial gradient fill #111 -> #333
|
||||
WHITE: radial gradient fill #fff -> #ddd
|
||||
Placement animation: scale tween 0 -> 1 over 150ms, ease 'Back.easeOut'
|
||||
|
||||
Last move indicator: small colored dot or ring on the most recent stone
|
||||
|
||||
Click detection: pointer event on canvas, snap to nearest intersection
|
||||
- Calculate (row, col) from pointer position
|
||||
- Reject if not player's turn (local check before sending)
|
||||
- Send CODE_GAME_MOVE with {row, col}
|
||||
```
|
||||
|
||||
### Coordinate Mapping
|
||||
```
|
||||
boardX(col) = MARGIN + col * CELL_SIZE
|
||||
boardY(row) = MARGIN + row * CELL_SIZE
|
||||
|
||||
colFromX(x) = Math.round((x - MARGIN) / CELL_SIZE)
|
||||
rowFromY(y) = Math.round((y - MARGIN) / CELL_SIZE)
|
||||
|
||||
Clamp to [0, 14] range
|
||||
```
|
||||
|
||||
### DOM Game Panel (game-ui.js)
|
||||
- Player info bar (top or side): black player name, white player name, highlight active turn
|
||||
- Move history panel (optional, scrollable): list of moves with row,col
|
||||
- Toast area for errors ("Not your turn!", "Position occupied")
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to Create
|
||||
- `web-client/src/scenes/game-scene.js`
|
||||
- `web-client/src/objects/board.js`
|
||||
- `web-client/src/objects/stone.js`
|
||||
- `web-client/src/ui/game-ui.js`
|
||||
|
||||
### Files to Modify
|
||||
- `web-client/src/config/game-config.js` -- add GameScene to scene list
|
||||
- `web-client/src/services/game-state-service.js` -- add board update methods
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. `board.js` (~120 lines) -- Phaser GameObject
|
||||
|
||||
Board rendering class:
|
||||
- `constructor(scene, config)` -- config has margin, cellSize, boardSize
|
||||
- `drawGrid()` -- draw 15 horizontal + 15 vertical lines using `scene.add.graphics()`
|
||||
- `drawStarPoints()` -- 5 filled circles at standard positions
|
||||
- `drawLabels()` -- optional: row numbers (0-14) and column letters along edges
|
||||
- `getIntersection(pointerX, pointerY)` -- returns `{row, col}` snapped to nearest intersection, or null if too far from any intersection (tolerance: cellSize * 0.4)
|
||||
- `getCenterPosition(row, col)` -- returns `{x, y}` canvas coordinates
|
||||
- Constants: `MARGIN = 40`, `CELL_SIZE`, `BOARD_SIZE = 15`
|
||||
- Export class
|
||||
|
||||
### 2. `stone.js` (~60 lines) -- Phaser GameObject
|
||||
|
||||
Stone rendering:
|
||||
- `constructor(scene, x, y, piece)` -- piece is "BLACK" or "WHITE"
|
||||
- Draw filled circle with gradient-like effect:
|
||||
- Main circle with fill color
|
||||
- Smaller inner circle offset for 3D highlight effect
|
||||
- `playPlaceAnimation()` -- scale tween from 0 to 1, 150ms, Back.easeOut
|
||||
- `setLastMoveIndicator(show)` -- add/remove small red dot at center
|
||||
- Export class
|
||||
|
||||
### 3. `game-scene.js` (~150 lines)
|
||||
|
||||
```
|
||||
constructor: super({ key: 'GameScene' })
|
||||
|
||||
create():
|
||||
- Read game info from gameState (set by MenuScene before transition)
|
||||
- Create Board object
|
||||
- Create stones container (empty initially)
|
||||
- Setup game-ui.js DOM panel (player names, turn indicator)
|
||||
- Register eventBus listeners:
|
||||
- CODE_GAME_MOVE_SUCCESS: placeStone(data)
|
||||
- CODE_GAME_MOVE_OCCUPIED: gameUi.showToast('Position occupied')
|
||||
- CODE_GAME_MOVE_OUT_OF_BOUNDS: gameUi.showToast('Out of bounds')
|
||||
- CODE_GAME_MOVE_NOT_YOUR_TURN: gameUi.showToast('Not your turn')
|
||||
- CODE_GAME_MOVE_INVALID: gameUi.showToast('Invalid move')
|
||||
- CODE_GAME_OVER: handled in Phase 5
|
||||
- CODE_CLIENT_EXIT: opponent left, show message, return to menu
|
||||
- CODE_CLIENT_KICK: kicked for idle, return to menu
|
||||
- Setup pointer click handler on canvas
|
||||
|
||||
handleClick(pointer):
|
||||
- If spectating, ignore
|
||||
- If not my turn (local check), ignore (avoid unnecessary server round-trip)
|
||||
- board.getIntersection(pointer.x, pointer.y) -> {row, col}
|
||||
- If null (clicked too far from intersection), ignore
|
||||
- If gameState.board[row][col] != null (local occupied check), ignore
|
||||
- connectionService.send(CODE_GAME_MOVE, JSON.stringify({row, col}))
|
||||
|
||||
placeStone(data):
|
||||
- {row, col, piece, playerNickname, playerId} = data
|
||||
- pos = board.getCenterPosition(row, col)
|
||||
- Create new Stone(scene, pos.x, pos.y, piece)
|
||||
- stone.playPlaceAnimation()
|
||||
- Remove last-move indicator from previous stone, add to this one
|
||||
- gameState.board[row][col] = piece
|
||||
- gameState.moves.push(data)
|
||||
- Update turn: gameState.isMyTurn = (data.playerId !== gameState.clientId)
|
||||
- gameUi.updateTurn(gameState.isMyTurn)
|
||||
- gameUi.addMoveToHistory(data)
|
||||
|
||||
shutdown():
|
||||
- Unsubscribe all eventBus listeners
|
||||
- gameUi.hide()
|
||||
```
|
||||
|
||||
### 4. `game-ui.js` (~100 lines)
|
||||
|
||||
DOM manipulation for in-game panels:
|
||||
- `show(blackName, whiteName, isBlack)` -- create/show player info bar
|
||||
- Two player cards: name + piece color indicator
|
||||
- Highlight current turn
|
||||
- `updateTurn(isMyTurn)` -- toggle highlight between player cards, show "Your turn" / "Opponent's turn"
|
||||
- `addMoveToHistory(moveData)` -- append to scrollable move list
|
||||
- `showToast(msg)` -- temporary error message, auto-fade after 2s
|
||||
- `hide()` -- remove DOM elements
|
||||
|
||||
Add DOM containers to `index.html`:
|
||||
```html
|
||||
<div id="game-panel" class="hidden">
|
||||
<div id="player-info"></div>
|
||||
<div id="move-history"></div>
|
||||
<div id="toast-container"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 5. Update `game-state-service.js`
|
||||
|
||||
Add methods:
|
||||
- `initGame(startingData)` -- set roomId, player IDs/names, isBlack, isMyTurn (black goes first), init board 15x15 nulls
|
||||
- `updateBoard(row, col, piece)` -- set board[row][col]
|
||||
- `isMyTurn` getter based on current state
|
||||
|
||||
## Todo List
|
||||
- [ ] Create `board.js` with grid, star points, coordinate mapping
|
||||
- [ ] Create `stone.js` with circle rendering and placement tween
|
||||
- [ ] Create `game-ui.js` with player info, turn indicator, toast
|
||||
- [ ] Create `game-scene.js` wiring board + stones + events + clicks
|
||||
- [ ] Add `#game-panel` DOM structure to `index.html`
|
||||
- [ ] Update `game-config.js` scene list
|
||||
- [ ] Update `game-state-service.js` with game init and board methods
|
||||
- [ ] Test: click to place stone, see it appear after server confirms
|
||||
- [ ] Test: opponent's move appears with animation
|
||||
- [ ] Test: error toasts on invalid moves
|
||||
|
||||
## Success Criteria
|
||||
- [ ] 15x15 board renders with grid lines and star points
|
||||
- [ ] Clicking intersection sends move to server
|
||||
- [ ] Confirmed moves appear as stones with placement animation
|
||||
- [ ] Last move indicator visible
|
||||
- [ ] Turn indicator updates correctly
|
||||
- [ ] Error toasts display and auto-dismiss
|
||||
- [ ] Spectator mode: board renders moves but clicks are ignored
|
||||
- [ ] All files under 150 lines, JSDoc on exports
|
||||
|
||||
## Risk Assessment
|
||||
- **Click precision on small screens:** `CELL_SIZE` may be small on mobile. Mitigation: Phaser `Scale.FIT` + generous snap tolerance (40% of cell size).
|
||||
- **Rapid successive moves in PVE:** AI responds instantly, two `CODE_GAME_MOVE_SUCCESS` events arrive back-to-back. Ensure `placeStone` is idempotent and animation queue doesn't break. Each stone is an independent tween -- no sequential dependency needed.
|
||||
- **Canvas vs DOM event conflict:** Phaser pointer events and DOM overlay events can conflict. Mitigation: hide DOM overlays when GameScene is active; game-panel is positioned outside the canvas clickable area (above or beside).
|
||||
|
||||
## Next Steps
|
||||
- Phase 5: Game over + spectator (depends on this phase)
|
||||
@@ -0,0 +1,227 @@
|
||||
# Phase 5: Game Over + Spectator Mode
|
||||
|
||||
## Context Links
|
||||
- [Plan overview](plan.md)
|
||||
- [Phase 4: Game Scene](phase-04-game-scene-board.md)
|
||||
- Server game over: `landlords-server/.../event/ServerEventListener_CODE_GAME_MOVE.java` lines 115-131
|
||||
- Server ready handler: `landlords-server/.../event/ServerEventListener_CODE_GAME_READY.java`
|
||||
- Server watch handler: `landlords-server/.../event/ServerEventListener_CODE_GAME_WATCH.java`
|
||||
- Spectator event wrapper: `ClientEventListener_CODE_GAME_WATCH.java` -- `{code, data}` wrapping
|
||||
|
||||
## Overview
|
||||
- **Priority:** P2
|
||||
- **Status:** Pending
|
||||
- **Blocked by:** Phase 4
|
||||
- **Description:** Handle game end (win/lose/draw), rematch flow, opponent disconnect, and spectator mode viewing.
|
||||
|
||||
## Key Insights
|
||||
|
||||
### Game Over
|
||||
- `CODE_GAME_OVER` data: `{result: "BLACK_WIN"|"WHITE_WIN"|"DRAW", winnerNickname: string, board: string}`
|
||||
- Sent to both players and spectators
|
||||
- After game over, players can send `CODE_GAME_READY` to signal rematch willingness
|
||||
- `CODE_GAME_READY` response: `{clientNickName, status: "READY"|"NO_READY", clientId}`
|
||||
- Ready is a toggle -- sending again toggles back to NO_READY
|
||||
- When both players are READY, server auto-fires `CODE_GAME_STARTING` again (new game, same room)
|
||||
|
||||
### Opponent Disconnect
|
||||
- `CODE_CLIENT_EXIT` data (to player): `{roomId, exitClientId, exitClientNickname}` -- room is destroyed server-side
|
||||
- `CODE_CLIENT_KICK` data: client nickname string -- idle kick, room destroyed
|
||||
- After either, client should return to lobby (server sends `CODE_SHOW_OPTIONS` after cleanup)
|
||||
|
||||
### Spectator Mode
|
||||
- Spectator receives `CODE_GAME_WATCH` events wrapping inner events: `{code: "CODE_...", data: ...}`
|
||||
- Inner codes: `CODE_ROOM_JOIN_SUCCESS`, `CODE_GAME_STARTING`, `CODE_GAME_MOVE_SUCCESS`, `CODE_CLIENT_EXIT`, `CODE_CLIENT_KICK`, `CODE_GAME_OVER`
|
||||
- Spectator also receives `CODE_GAME_WATCH_SUCCESSFUL` directly: `{owner, status}`
|
||||
- Spectator exits by sending `CODE_GAME_WATCH_EXIT`, then navigates back to menu
|
||||
- Spectator CANNOT make moves, ready up, or interact with game
|
||||
|
||||
## Architecture
|
||||
|
||||
### Game Over Flow
|
||||
```
|
||||
CODE_GAME_OVER received
|
||||
-> GameScene: disable click handler
|
||||
-> Show GameOverScene (overlay or scene transition)
|
||||
- Display: "You Win!" / "You Lose!" / "Draw!"
|
||||
- Winner name
|
||||
- Buttons: [Rematch] [Exit to Lobby]
|
||||
|
||||
[Rematch] clicked:
|
||||
-> send CODE_GAME_READY
|
||||
-> show "Waiting for opponent..." or "Both ready!"
|
||||
-> on CODE_GAME_READY from opponent: update UI to show their ready status
|
||||
-> on CODE_GAME_STARTING: clear overlay, reset board, start new game in GameScene
|
||||
|
||||
[Exit] clicked:
|
||||
-> send CODE_CLIENT_EXIT
|
||||
-> gameState.reset()
|
||||
-> scene.start('MenuScene')
|
||||
```
|
||||
|
||||
### Spectator Flow
|
||||
```
|
||||
MenuScene: user clicks Watch on a room
|
||||
-> send CODE_GAME_WATCH with roomId
|
||||
-> receive CODE_GAME_WATCH_SUCCESSFUL: {owner, status}
|
||||
-> transition to GameScene with isSpectating=true
|
||||
|
||||
GameScene (spectator):
|
||||
- Board renders normally
|
||||
- Click handler disabled
|
||||
- Player info shows "Spectating" badge
|
||||
- Move events come wrapped in CODE_GAME_WATCH: {code, data}
|
||||
- Unwrap and handle inner code normally (reuse placeStone, etc.)
|
||||
- On game over: show result, offer [Exit Spectating] button
|
||||
- Exit: send CODE_GAME_WATCH_EXIT, return to MenuScene
|
||||
```
|
||||
|
||||
### Spectator Event Unwrapping (in game-scene.js)
|
||||
```js
|
||||
eventBus.on(ClientEventCode.GAME_WATCH, (wrapData) => {
|
||||
const innerCode = wrapData.code;
|
||||
const innerData = typeof wrapData.data === 'string'
|
||||
? tryParseJson(wrapData.data)
|
||||
: wrapData.data;
|
||||
// Route to existing handlers
|
||||
this.handleServerEvent(innerCode, innerData);
|
||||
});
|
||||
```
|
||||
|
||||
## Related Code Files
|
||||
|
||||
### Files to Create
|
||||
- `web-client/src/scenes/game-over-scene.js`
|
||||
|
||||
### Files to Modify
|
||||
- `web-client/src/scenes/game-scene.js` -- add game over, exit, kick, spectator event handlers
|
||||
- `web-client/src/ui/game-ui.js` -- add game over overlay, spectator badge
|
||||
- `web-client/src/config/game-config.js` -- add GameOverScene to scene list
|
||||
- `web-client/index.html` -- add game-over DOM overlay structure
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Game Over DOM overlay (in `index.html`)
|
||||
|
||||
```html
|
||||
<div id="game-over-overlay" class="hidden">
|
||||
<div class="game-over-card">
|
||||
<h1 id="game-result-text"></h1>
|
||||
<p id="game-result-detail"></p>
|
||||
<div id="rematch-status" class="hidden">
|
||||
<p>You: <span id="my-ready-status">Not Ready</span></p>
|
||||
<p>Opponent: <span id="opponent-ready-status">Not Ready</span></p>
|
||||
</div>
|
||||
<div class="game-over-buttons">
|
||||
<button id="btn-rematch">Rematch</button>
|
||||
<button id="btn-exit-game">Exit to Lobby</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 2. `game-over-scene.js` (~80 lines)
|
||||
|
||||
Decision: use DOM overlay managed by game-ui.js rather than a separate Phaser scene. The board should remain visible behind the overlay. Rename this to game-over logic inside `game-ui.js` instead.
|
||||
|
||||
Actually -- implement as **methods in `game-ui.js`** rather than a separate scene. This avoids scene transition complexity and keeps the board visible.
|
||||
|
||||
- `showGameOver(result, winnerNickname, isWinner, isDraw)` -- populate and show overlay
|
||||
- `showRematchStatus(myReady, opponentReady)` -- update ready indicators
|
||||
- `hideGameOver()` -- hide overlay for new game
|
||||
- `showSpectatorGameOver(result, winnerNickname)` -- simplified: result + exit button only
|
||||
|
||||
Remove `game-over-scene.js` from plan. Update `game-config.js` -- no extra scene needed.
|
||||
|
||||
### 3. Update `game-scene.js` -- Game Over Handling (~30 lines added)
|
||||
|
||||
```
|
||||
on CODE_GAME_OVER:
|
||||
- Parse result, determine if current player won
|
||||
- Disable click handler
|
||||
- gameUi.showGameOver(result, winnerNickname, isWinner, isDraw)
|
||||
- Wire rematch button: send CODE_GAME_READY, show rematch status
|
||||
|
||||
on CODE_GAME_READY:
|
||||
- Update rematch status display
|
||||
- If both ready, server will send CODE_GAME_STARTING
|
||||
|
||||
on CODE_GAME_STARTING (during rematch):
|
||||
- gameState.resetBoard()
|
||||
- Clear all stone objects from scene
|
||||
- gameUi.hideGameOver()
|
||||
- Re-init board state (new black/white assignment)
|
||||
- Re-enable click handler
|
||||
```
|
||||
|
||||
### 4. Update `game-scene.js` -- Exit/Kick Handling (~15 lines added)
|
||||
|
||||
```
|
||||
on CODE_CLIENT_EXIT:
|
||||
- gameUi.showToast(exitClientNickname + ' left the game')
|
||||
- After 2s delay: gameState.reset(), scene.start('MenuScene')
|
||||
|
||||
on CODE_CLIENT_KICK:
|
||||
- gameUi.showToast('Kicked for being idle')
|
||||
- gameState.reset(), scene.start('MenuScene')
|
||||
```
|
||||
|
||||
### 5. Update `game-scene.js` -- Spectator Support (~25 lines added)
|
||||
|
||||
```
|
||||
create():
|
||||
- if gameState.isSpectating:
|
||||
- Disable click handler
|
||||
- gameUi.showSpectatorBadge()
|
||||
- Register CODE_GAME_WATCH listener (unwrap + route)
|
||||
|
||||
on CODE_GAME_WATCH:
|
||||
- Unwrap {code, data}
|
||||
- Route to existing handlers (placeStone, game over, etc.)
|
||||
|
||||
Spectator exit button:
|
||||
- send CODE_GAME_WATCH_EXIT
|
||||
- gameState.reset()
|
||||
- scene.start('MenuScene')
|
||||
```
|
||||
|
||||
### 6. Update `game-ui.js` -- New Methods (~50 lines added)
|
||||
|
||||
- `showGameOver(...)` -- show overlay with result text
|
||||
- `hideGameOver()` -- hide overlay
|
||||
- `showRematchStatus(myReady, opponentReady)` -- toggle ready indicators
|
||||
- `showSpectatorBadge()` -- add "SPECTATING" label to player info
|
||||
- `showSpectatorExit()` -- add exit button for spectators
|
||||
|
||||
## Todo List
|
||||
- [ ] Add game-over DOM overlay to `index.html`
|
||||
- [ ] Add game over methods to `game-ui.js`
|
||||
- [ ] Add `CODE_GAME_OVER` handler in `game-scene.js`
|
||||
- [ ] Add rematch flow (`CODE_GAME_READY` send + receive) in `game-scene.js`
|
||||
- [ ] Add rematch restart logic (clear board, re-init) in `game-scene.js`
|
||||
- [ ] Add `CODE_CLIENT_EXIT` and `CODE_CLIENT_KICK` handlers in `game-scene.js`
|
||||
- [ ] Add spectator event unwrapping (`CODE_GAME_WATCH`) in `game-scene.js`
|
||||
- [ ] Add spectator UI elements (badge, exit button) to `game-ui.js`
|
||||
- [ ] Test: win/lose/draw displays correctly
|
||||
- [ ] Test: rematch flow -- both ready -> new game starts
|
||||
- [ ] Test: opponent exits mid-game -> return to lobby
|
||||
- [ ] Test: spectator sees moves and game over
|
||||
|
||||
## Success Criteria
|
||||
- [ ] Game over overlay shows correct result (win/lose/draw)
|
||||
- [ ] Rematch button sends ready signal, UI shows both players' ready state
|
||||
- [ ] New game starts with cleared board when both ready
|
||||
- [ ] Exit button sends `CODE_CLIENT_EXIT` and returns to lobby
|
||||
- [ ] Opponent disconnect shows notification and returns to lobby after delay
|
||||
- [ ] Spectator sees all moves in real-time, cannot interact
|
||||
- [ ] Spectator exit sends `CODE_GAME_WATCH_EXIT` and returns to lobby
|
||||
- [ ] `game-scene.js` stays under 200 lines total
|
||||
- [ ] `game-ui.js` stays under 200 lines total
|
||||
|
||||
## Risk Assessment
|
||||
- **Rematch player color swap:** Server re-assigns black/white in `CODE_GAME_STARTING`. Client must NOT assume same colors. Re-read `blackPlayerId` from new starting data.
|
||||
- **Stale event listeners after rematch:** Board reset clears stones but scene is NOT restarted. Event listeners persist, which is correct -- no unbind/rebind needed.
|
||||
- **Spectator joining mid-game:** Server sends `CODE_GAME_WATCH_SUCCESSFUL` with room status but does NOT replay past moves. Spectator sees the board from their join point onward. This is a server limitation -- document it, don't try to work around.
|
||||
|
||||
## Next Steps
|
||||
- Phase 6: Polish + error handling (cross-cutting)
|
||||
@@ -0,0 +1,170 @@
|
||||
# Phase 6: Polish + Error Handling
|
||||
|
||||
## Context Links
|
||||
- [Plan overview](plan.md)
|
||||
- [Phase 2: Services](phase-02-services-layer.md) -- reconnect logic lives here
|
||||
- [Phase 3: Menu](phase-03-boot-menu-scenes.md) -- toast system
|
||||
- [Phase 4: Game](phase-04-game-scene-board.md) -- game-ui.js toast
|
||||
|
||||
## Overview
|
||||
- **Priority:** P2
|
||||
- **Status:** Pending
|
||||
- **Blocked by:** Phase 5
|
||||
- **Description:** Cross-cutting improvements: connection lost overlay, reconnect UX, hover effects, sound placeholders, input validation hardening, manual integration test checklist.
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### 1. Connection Lost Overlay (~20 lines in `game-ui.js`)
|
||||
|
||||
Add to `index.html`:
|
||||
```html
|
||||
<div id="connection-lost" class="hidden">
|
||||
<div class="connection-card">
|
||||
<h2>Connection Lost</h2>
|
||||
<p>Reconnecting<span id="reconnect-dots">...</span></p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- `eventBus.on('ws:disconnected')` -> show overlay (full-screen semi-transparent)
|
||||
- `eventBus.on('ws:connected')` -> hide overlay
|
||||
- If reconnect succeeds, server treats it as a new client (old room is gone). Redirect to BootScene.
|
||||
|
||||
### 2. Board Hover Effect (~15 lines in `game-scene.js`)
|
||||
|
||||
- On `pointermove`: calculate nearest intersection
|
||||
- If valid + empty + my turn: draw a semi-transparent stone preview at that position
|
||||
- On `pointerout` or click: clear preview
|
||||
- Use a single reusable Graphics object for the preview (no object churn)
|
||||
|
||||
### 3. Input Validation Hardening
|
||||
|
||||
**Nickname input (`menu-ui.js`):**
|
||||
- Trim whitespace
|
||||
- Reject empty or >10 chars client-side before sending (avoid server round-trip)
|
||||
- Disable submit button while waiting for server response
|
||||
|
||||
**Click debounce (`game-scene.js`):**
|
||||
- After sending a move, set `awaitingResponse = true`
|
||||
- On `CODE_GAME_MOVE_SUCCESS` or any move error: set `awaitingResponse = false`
|
||||
- Reject clicks while `awaitingResponse` is true
|
||||
- Prevents double-click sending duplicate moves
|
||||
|
||||
### 4. Toast System Consolidation
|
||||
|
||||
Both `menu-ui.js` and `game-ui.js` need toast capability. Extract shared toast logic:
|
||||
- Add `#toast-container` to `index.html` at top level (outside scene-specific containers)
|
||||
- Create `showToast(msg, type='error', duration=2500)` function in `game-ui.js` (reuse from menu too)
|
||||
- Types: `error` (red), `info` (blue), `success` (green)
|
||||
- Auto-dismiss with CSS fade-out animation
|
||||
|
||||
### 5. Keyboard Shortcuts
|
||||
|
||||
- `Escape` during game: show confirmation "Exit game?" dialog
|
||||
- `Enter` on nickname input: submit (already handled if form has submit event)
|
||||
|
||||
### 6. WS URL Configuration
|
||||
|
||||
- Check `?ws=` URL parameter for custom server address
|
||||
- Fallback: `ws://localhost:1025/ratel`
|
||||
- Display connected server address in BootScene
|
||||
- Example: `http://localhost:5173/?ws=ws://192.168.1.5:1025/ratel`
|
||||
|
||||
### 7. Responsive Layout
|
||||
|
||||
- Phaser `Scale.FIT` handles canvas scaling
|
||||
- DOM overlays: use viewport-relative units (vh/vw) and max-width for panels
|
||||
- Test at 800x800, 1920x1080, 1366x768 browser sizes
|
||||
- Mobile: not a priority but should not break entirely
|
||||
|
||||
## Integration Test Checklist (Manual)
|
||||
|
||||
Run server: `java -jar landlords-server/target/landlords-server-*.jar -p 1024`
|
||||
(WebSocket will be on port 1025)
|
||||
|
||||
Open client: `http://localhost:5173/?ws=ws://localhost:1025/ratel`
|
||||
|
||||
### Connection Flow
|
||||
- [ ] Client connects, shows "Connecting..."
|
||||
- [ ] After ~2s, nickname prompt appears
|
||||
- [ ] Enter valid nickname (1-10 chars), lobby appears
|
||||
- [ ] Enter invalid nickname (empty / >10), error shown, re-prompted
|
||||
|
||||
### PVP Flow
|
||||
- [ ] Create room, waiting screen shows room ID
|
||||
- [ ] Open second browser tab, join room by ID
|
||||
- [ ] Game starts, board renders with player info
|
||||
- [ ] Black player moves first, click places stone
|
||||
- [ ] White player's turn, black player click rejected locally
|
||||
- [ ] Play until 5-in-a-row, game over screen shows winner
|
||||
- [ ] Both click Rematch, new game starts with cleared board
|
||||
- [ ] One player exits, other returns to lobby
|
||||
- [ ] Test room list refresh shows available rooms
|
||||
|
||||
### PVE Flow
|
||||
- [ ] Select PVE, choose difficulty
|
||||
- [ ] Game starts immediately (player is black)
|
||||
- [ ] Place stone, AI responds automatically
|
||||
- [ ] Play until game over
|
||||
- [ ] Exit returns to lobby
|
||||
|
||||
### Spectator Flow
|
||||
- [ ] Start a PVP game in two tabs
|
||||
- [ ] Third tab: spectate the room
|
||||
- [ ] Spectator sees moves in real-time
|
||||
- [ ] Spectator cannot click to place stones
|
||||
- [ ] Game over shown to spectator
|
||||
- [ ] Spectator exits, returns to lobby
|
||||
|
||||
### Error Handling
|
||||
- [ ] Close server: "Connection Lost" overlay appears
|
||||
- [ ] Restart server: client reconnects, returns to nickname prompt
|
||||
- [ ] Join non-existent room: error toast
|
||||
- [ ] Join full room: error toast
|
||||
- [ ] Rapid-click same position: no duplicate requests
|
||||
|
||||
## Todo List
|
||||
- [ ] Add connection-lost overlay to `index.html` and wire in `game-ui.js`
|
||||
- [ ] Add board hover preview in `game-scene.js`
|
||||
- [ ] Add click debounce in `game-scene.js`
|
||||
- [ ] Add client-side nickname validation in `menu-ui.js`
|
||||
- [ ] Consolidate toast system in `game-ui.js`
|
||||
- [ ] Add WS URL parameter support in `boot-scene.js`
|
||||
- [ ] Add Escape key handler in `game-scene.js`
|
||||
- [ ] Test responsive layout at multiple sizes
|
||||
- [ ] Run full integration test checklist
|
||||
|
||||
## Success Criteria
|
||||
- [ ] Connection lost overlay appears/disappears correctly
|
||||
- [ ] Hover preview shows semi-transparent stone at valid positions
|
||||
- [ ] No duplicate move requests on rapid clicks
|
||||
- [ ] All integration test checklist items pass
|
||||
- [ ] No console errors during normal gameplay
|
||||
- [ ] All JS files remain under 200 lines
|
||||
|
||||
## Risk Assessment
|
||||
- **Reconnect creates new identity:** Server has no session resumption. This is a known limitation. After reconnect, user must re-enter nickname and rejoin. Document this, don't over-engineer.
|
||||
- **Hover performance:** Redrawing preview on every pointermove could lag. Mitigation: use a single Graphics object, clear+redraw only when intersection changes (cache last hover position).
|
||||
|
||||
## File Ownership Summary (All Phases)
|
||||
|
||||
| File | Owner Phase | Touched By |
|
||||
|------|------------|------------|
|
||||
| `package.json` | 1 | 1 only |
|
||||
| `vite.config.js` | 1 | 1 only |
|
||||
| `index.html` | 1 | 3, 4, 5, 6 (DOM additions) |
|
||||
| `main.js` | 1 | 1 only |
|
||||
| `game-config.js` | 1 | 3, 4 (scene list) |
|
||||
| `protocol-constants.js` | 2 | 2 only |
|
||||
| `event-bus.js` | 2 | 2 only |
|
||||
| `connection-service.js` | 2 | 2, 6 (reconnect polish) |
|
||||
| `game-state-service.js` | 2 | 2, 4, 5 (game methods) |
|
||||
| `boot-scene.js` | 3 | 3, 6 (WS URL param) |
|
||||
| `menu-scene.js` | 3 | 3 only |
|
||||
| `menu-ui.js` | 3 | 3, 6 (validation) |
|
||||
| `board.js` | 4 | 4 only |
|
||||
| `stone.js` | 4 | 4 only |
|
||||
| `game-scene.js` | 4 | 4, 5, 6 (game over, spectator, hover) |
|
||||
| `game-ui.js` | 4 | 4, 5, 6 (game over overlay, toast, connection) |
|
||||
|
||||
Note: Phases are sequential so file ownership conflicts are not possible.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "Phaser 3 Web Client for Gomoku"
|
||||
description: "Standalone Phaser 3 + Vite web client connecting to existing Netty server via WebSocket"
|
||||
status: pending
|
||||
priority: P1
|
||||
effort: 12h
|
||||
branch: master
|
||||
tags: [web-client, phaser3, vite, gomoku, websocket]
|
||||
created: 2026-04-10
|
||||
---
|
||||
|
||||
# Phaser 3 Web Client for Gomoku
|
||||
|
||||
## Overview
|
||||
|
||||
Build a standalone web client using Phaser 3 (game engine) + Vite (build tool) + vanilla JavaScript (with JSDoc). Connects to existing Netty server at `ws://host:port/ratel`. Server owns all game logic; client is display + input only.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser
|
||||
+-- Phaser 3 Game (canvas: board rendering, stones, animations)
|
||||
+-- DOM Overlays (HTML/CSS: menus, forms, lobby, toasts)
|
||||
+-- Services (JS modules, not Phaser-coupled)
|
||||
+-- connection-service.js (WebSocket I/O + heartbeat)
|
||||
+-- event-bus.js (pub/sub decoupling)
|
||||
+-- game-state-service.js (clientId, roomId, turn, board state)
|
||||
```
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User click -> GameScene -> connection-service.send(CODE_GAME_MOVE, {row, col})
|
||||
|
|
||||
v
|
||||
WebSocket -> Server
|
||||
|
|
||||
v
|
||||
Server -> WebSocket -> connection-service.onMessage -> event-bus.emit(code, data)
|
||||
|
|
||||
v
|
||||
GameScene listener -> update board, play animation
|
||||
```
|
||||
|
||||
## Phases
|
||||
|
||||
| # | Phase | Status | Effort | Files |
|
||||
|---|-------|--------|--------|-------|
|
||||
| 1 | [Project scaffold](phase-01-project-scaffold.md) | Pending | 1h | package.json, vite.config.js, index.html, main.js, game-config.js |
|
||||
| 2 | [Services layer](phase-02-services-layer.md) | Pending | 2h | connection-service.js, event-bus.js, game-state-service.js, protocol-constants.js |
|
||||
| 3 | [Boot + Menu scenes](phase-03-boot-menu-scenes.md) | Pending | 2.5h | boot-scene.js, menu-scene.js, menu-ui.js, styles in index.html |
|
||||
| 4 | [Game scene + board](phase-04-game-scene-board.md) | Pending | 3h | game-scene.js, board.js, stone.js, game-ui.js |
|
||||
| 5 | [Game over + spectator](phase-05-gameover-spectator.md) | Pending | 2h | DOM overlay in game-ui.js, updates to game-scene.js |
|
||||
| 6 | [Polish + error handling](phase-06-polish-errors.md) | Pending | 1.5h | cross-cutting updates, reconnect logic, toast system |
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
Phase 1 (scaffold)
|
||||
+-> Phase 2 (services) -- no Phaser dependency, can start after scaffold
|
||||
+-> Phase 3 (boot+menu) -- needs scaffold + services
|
||||
+-> Phase 4 (game scene) -- needs menu to navigate + services for WS
|
||||
+-> Phase 5 (game over + spectator) -- needs game scene
|
||||
+-> Phase 6 (polish) -- cross-cutting, touches all
|
||||
```
|
||||
|
||||
## Key Decisions
|
||||
|
||||
1. **DOM overlays for menus** -- Phaser text/buttons too limited for forms and tables. Standard Phaser practice.
|
||||
2. **Plain WebSocket API** -- No socket.io. Server uses raw WS frames. `connection-service.js` wraps reconnect + heartbeat.
|
||||
3. **Event bus decoupling** -- Scenes subscribe to game events via event-bus, not direct WS references. Testable, replaceable.
|
||||
4. **No TypeScript** -- User preference. JSDoc `@typedef` for type documentation.
|
||||
5. **15x15 board** -- Hardcoded from server `Board.BOARD_SIZE = 15`. Client reads `boardSize` from `CODE_GAME_STARTING` anyway.
|
||||
6. **3 Phaser scenes only** -- BootScene, MenuScene, GameScene. Game over is a DOM overlay on GameScene (keeps board visible behind result card). No GameOverScene needed.
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
- **Server: zero changes.** Client connects via existing WS endpoint `/ratel`.
|
||||
- **Existing Java client: unaffected.** Web client is additive.
|
||||
- **Protocol verified** from server source: `Msg{code, data, info}` JSON format.
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
- `web-client/` is a standalone directory. `rm -rf web-client/` to revert.
|
||||
- No server code modified. No migration needed.
|
||||
|
||||
## Test Strategy
|
||||
|
||||
- **Manual integration test** with running server (Phase 6)
|
||||
- **Browser dev tools** for WS frame inspection
|
||||
- No unit test framework initially (YAGNI -- thin UI client with server-owned logic)
|
||||
- If needed later: Vitest for service modules
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| WS message format mismatch | Low | High | Protocol constants extracted from server source; verify with live server in Phase 2 |
|
||||
| Phaser canvas sizing on different screens | Medium | Medium | Use Phaser `Scale.FIT` + responsive CSS container |
|
||||
| Server heartbeat timeout (<60s idle) | Low | High | Client sends `CODE_CLIENT_HEAD_BEAT` every 50s via setInterval |
|
||||
| DOM overlay z-index conflicts with Phaser | Medium | Low | Explicit z-index layering; hide overlays during game scene |
|
||||
Reference in New Issue
Block a user