diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index 0704e9d..8eb8a07 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -1,40 +1,24 @@ -import { useEffect, useState } from "react"; -import { socketClient } from "./network/SocketClient"; +import { socketManager } from "@client/network/SocketManager"; +import { useAppFlow } from "./hooks/useAppFlow"; // 画面遷移先シーンコンポーネント群 -import { TitleScene } from "./scenes/TitleScene"; -import { LobbyScene } from "./scenes/LobbyScene"; -import { GameScene } from "./scenes/GameScene"; +import { TitleScene } from "./scenes/title/TitleScene"; +import { LobbyScene } from "./scenes/lobby/LobbyScene"; +import { GameScene } from "./scenes/game/GameScene"; -import { GameState } from "@repo/shared"; -import type { GameStateType, Room } from "@repo/shared"; +import { appConsts } from "@repo/shared"; export default function App() { - // 現在シーン状態 - const [gameState, setGameState] = useState(GameState.TITLE); - // 参加中ルーム情報 - const [room, setRoom] = useState(null); - // 自身ソケットID - const [myId, setMyId] = useState(null); - - // 接続・ルーム更新・開始通知の購読処理 - useEffect(() => { - socketClient.onConnect((id) => setMyId(id)); - socketClient.onRoomUpdate((updatedRoom) => { - setRoom(updatedRoom); - setGameState(GameState.LOBBY); - }); - socketClient.onGameStart(() => setGameState(GameState.PLAYING)); - }, []); + const { scenePhase, room, myId } = useAppFlow(); // タイトル画面分岐 - if (gameState === GameState.TITLE) { - return socketClient.joinRoom(payload.roomId, payload.playerName)} />; + if (scenePhase === appConsts.ScenePhase.TITLE) { + return socketManager.title.joinRoom(payload)} />; } // ロビー画面分岐 - if (gameState === GameState.LOBBY) { - return socketClient.startGame()} />; + if (scenePhase === appConsts.ScenePhase.LOBBY) { + return socketManager.lobby.startGame()} />; } // プレイ画面分岐 diff --git a/apps/client/src/entities/GameMap.ts b/apps/client/src/entities/GameMap.ts deleted file mode 100644 index bf79d9c..0000000 --- a/apps/client/src/entities/GameMap.ts +++ /dev/null @@ -1,117 +0,0 @@ -// apps/client/src/game/map/GameMap.ts (パスは適宜読み替えてください) -import { Container, Graphics } from "pixi.js"; -import { config } from "@repo/shared"; -import type { gridMapTypes } from "@repo/shared"; - -// 親クラスを Graphics から Container に変更し、レイヤー管理を可能にする -export class GameMap extends Container { - private bgGraphics: Graphics; - private gridGraphics: Graphics; - - // 400個(GRID_COLS * GRID_ROWS)のマス目描画用オブジェクトを保持する1次元配列 - private cells: Graphics[] = []; - - constructor() { - super(); - this.bgGraphics = new Graphics(); - this.gridGraphics = new Graphics(); - - // 描画順(追加した順に前面に描画される): 背景 -> マス目 -> グリッド線 - this.addChild(this.bgGraphics); - - // マス目の初期化と Container への追加 - this.initCells(); - - this.addChild(this.gridGraphics); - - // 背景と線の描画(静的なので1回だけ実行) - this.drawBaseMap(); - } - - // 設定値に基づき、空のマス目(Graphics)を400個生成して配列に格納する - private initCells() { - const { GRID_COLS, GRID_ROWS, GRID_CELL_SIZE } = config.GAME_CONFIG; - const totalCells = GRID_COLS * GRID_ROWS; - - for (let i = 0; i < totalCells; i++) { - const col = i % GRID_COLS; - const row = Math.floor(i / GRID_COLS); - - const cell = new Graphics(); - // マスの座標をあらかじめ設定しておく(描画の基準点になる) - cell.x = col * GRID_CELL_SIZE; - cell.y = row * GRID_CELL_SIZE; - - this.addChild(cell); - this.cells.push(cell); - } - } - - // 設定値参照によるマップ外観(背景・グリッド線)の組み立て処理 - private drawBaseMap() { - const { - MAP_WIDTH_PX, MAP_HEIGHT_PX, GRID_CELL_SIZE, - MAP_BG_COLOR, MAP_GRID_COLOR, MAP_BORDER_COLOR - } = config.GAME_CONFIG; - - // マップ全域背景レイヤー - this.bgGraphics.rect(0, 0, MAP_WIDTH_PX, MAP_HEIGHT_PX).fill(MAP_BG_COLOR); - - // 縦方向グリッド線 - for (let x = 0; x <= MAP_WIDTH_PX; x += GRID_CELL_SIZE) { - this.gridGraphics.moveTo(x, 0).lineTo(x, MAP_HEIGHT_PX).stroke({ width: 1, color: MAP_GRID_COLOR }); - } - // 横方向グリッド線 - for (let y = 0; y <= MAP_HEIGHT_PX; y += GRID_CELL_SIZE) { - this.gridGraphics.moveTo(0, y).lineTo(MAP_WIDTH_PX, y).stroke({ width: 1, color: MAP_GRID_COLOR }); - } - - // プレイ領域外枠 - this.gridGraphics.rect(0, 0, MAP_WIDTH_PX, MAP_HEIGHT_PX).stroke({ width: 5, color: MAP_BORDER_COLOR }); - } - - /** - * サーバー(またはテストロジック)から受け取った最新のマップ状態で色を更新する - */ - public updateMapState(state: gridMapTypes.MapState) { - const { GRID_CELL_SIZE, TEAM_COLORS } = config.GAME_CONFIG; - - for (let i = 0; i < state.gridColors.length; i++) { - const teamId = state.gridColors[i]; - const cell = this.cells[i]; - - // 一旦マスの描画をクリア - cell.clear(); - - // 塗布済み(-1以外)の場合のみ色を塗る - if (teamId !== -1) { - // Player.ts と同様に、文字列のカラーコードを PixiJS 用の数値に変換 - const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; - const hexColor = parseInt(colorString.replace("#", "0x"), 16); - - // cell.x, cell.y は設定済みなので、ローカル座標 (0,0) からサイズ分を塗りつぶす - cell.rect(0, 0, GRID_CELL_SIZE, GRID_CELL_SIZE).fill(hexColor); - } - } - } - - /** - * 差分データを受け取って指定のマスだけ色を更新する - */ - public updateCells(updates: gridMapTypes.CellUpdate[]) { - const { GRID_CELL_SIZE, TEAM_COLORS } = config.GAME_CONFIG; - - updates.forEach(({ index, teamId }) => { - const cell = this.cells[index]; - if (!cell) return; - - cell.clear(); - - if (teamId !== -1) { - const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; - const hexColor = parseInt(colorString.replace("#", "0x"), 16); - cell.rect(0, 0, GRID_CELL_SIZE, GRID_CELL_SIZE).fill(hexColor); - } - }); - } -} \ No newline at end of file diff --git a/apps/client/src/entities/Player.ts b/apps/client/src/entities/Player.ts deleted file mode 100644 index 1408491..0000000 --- a/apps/client/src/entities/Player.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Graphics } from 'pixi.js'; -import { config } from "@repo/shared"; -import type { playerTypes } from "@repo/shared"; - -/** - * プレイヤーの共通基底クラス(描画と基本データの保持) - */ -export abstract class BasePlayer extends Graphics { - public id: string; - public teamId: number; - public gridX: number; - public gridY: number; - - constructor(data: playerTypes.PlayerData, isLocal: boolean = false) { - super(); - this.id = data.id; - this.teamId = data.teamId; - - // 初期座標のセット(内部はグリッド単位) - this.gridX = data.x; - this.gridY = data.y; - - // gameConfigから定数を取得 - const { - GRID_CELL_SIZE, - PLAYER_RADIUS_PX, - TEAM_COLORS, - PLAYER_LOCAL_STROKE_COLOR, - PLAYER_LOCAL_STROKE_WIDTH, - PLAYER_REMOTE_STROKE_COLOR, - PLAYER_REMOTE_STROKE_WIDTH - } = config.GAME_CONFIG; - - this.position.set(this.gridX * GRID_CELL_SIZE, this.gridY * GRID_CELL_SIZE); - - // チームIDに対応する色をHEX文字列('#RRGGBB')で取得し、PixiJS用の数値(0xRRGGBB)に変換 - const colorString = TEAM_COLORS[this.teamId] || '#FFFFFF'; - const hexColor = parseInt(colorString.replace("#", "0x"), 16); - - // 自プレイヤーか他プレイヤーかで枠線の設定を切り替え - const strokeColor = isLocal ? PLAYER_LOCAL_STROKE_COLOR : PLAYER_REMOTE_STROKE_COLOR; - const strokeWidth = isLocal ? PLAYER_LOCAL_STROKE_WIDTH : PLAYER_REMOTE_STROKE_WIDTH; - - // 塗りつぶしと枠線を同時に描画 - this.circle(0, 0, PLAYER_RADIUS_PX) - .fill(hexColor) - .stroke({ width: strokeWidth, color: strokeColor }); - } - - protected syncDisplayPosition() { - const { GRID_CELL_SIZE } = config.GAME_CONFIG; - this.position.set(this.gridX * GRID_CELL_SIZE, this.gridY * GRID_CELL_SIZE); - } - - // 毎フレーム呼ばれる更新メソッド(サブクラスで具体的な処理を実装させる) - abstract update(deltaTime: number): void; -} - -/** - * 自プレイヤー(キー・ジョイスティック入力で移動・送信する) - */ -export class LocalPlayer extends BasePlayer { - constructor(data: playerTypes.PlayerData) { - super(data, true); - } - - /** - * 入力ベクトルと経過時間基準の座標更新処理 - */ - public move(vx: number, vy: number, deltaTime: number) { - const { PLAYER_SPEED, GRID_COLS, GRID_ROWS, PLAYER_RADIUS } = config.GAME_CONFIG; - - const speed = PLAYER_SPEED * deltaTime; - this.gridX += vx * speed; - this.gridY += vy * speed; - - // 画面外に出ないようにクランプ - this.gridX = Math.max(PLAYER_RADIUS, Math.min(GRID_COLS - PLAYER_RADIUS, this.gridX)); - this.gridY = Math.max(PLAYER_RADIUS, Math.min(GRID_ROWS - PLAYER_RADIUS, this.gridY)); - - this.syncDisplayPosition(); - } - - public update(_deltaTime: number): void { - // 自プレイヤーは GameScene 側から move() を通じて動かすため、ここでは何もしない - } -} - -/** - * 他プレイヤー(サーバーからの通信を受信して補間・吸着移動する) - */ -export class RemotePlayer extends BasePlayer { - private targetGridX: number; - private targetGridY: number; - - constructor(data: playerTypes.PlayerData) { - super(data, false); - this.targetGridX = data.x; - this.targetGridY = data.y; - } - - /** - * サーバーから受信した最新の座標を目標としてセットする - */ - public setTargetPosition(x?: number, y?: number) { - if (x !== undefined) this.targetGridX = x; - if (y !== undefined) this.targetGridY = y; - } - - /** - * 毎フレームの更新処理(目標座標へのLerp補間) - */ - public update(deltaTime: number): void { - const { PLAYER_LERP_SNAP_THRESHOLD, PLAYER_LERP_SMOOTHNESS } = config.GAME_CONFIG; - - const diffX = this.targetGridX - this.gridX; - const diffY = this.targetGridY - this.gridY; - - // X軸の補間 - if (Math.abs(diffX) < PLAYER_LERP_SNAP_THRESHOLD) { - this.gridX = this.targetGridX; - } else { - this.gridX += diffX * PLAYER_LERP_SMOOTHNESS * deltaTime; - } - - // Y軸の補間 - if (Math.abs(diffY) < PLAYER_LERP_SNAP_THRESHOLD) { - this.gridY = this.targetGridY; - } else { - this.gridY += diffY * PLAYER_LERP_SMOOTHNESS * deltaTime; - } - - this.syncDisplayPosition(); - } -} \ No newline at end of file diff --git a/apps/client/src/hooks/useAppFlow.ts b/apps/client/src/hooks/useAppFlow.ts new file mode 100644 index 0000000..a891fc9 --- /dev/null +++ b/apps/client/src/hooks/useAppFlow.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import { socketManager } from "@client/network/SocketManager"; +import { appConsts } from "@repo/shared"; +import type { appTypes, roomTypes } from "@repo/shared"; + +type AppFlowState = { + scenePhase: appTypes.ScenePhase; + room: roomTypes.Room | null; + myId: string | null; +}; + +export const useAppFlow = (): AppFlowState => { + const [scenePhase, setScenePhase] = useState(appConsts.ScenePhase.TITLE); + const [room, setRoom] = useState(null); + const [myId, setMyId] = useState(null); + + useEffect(() => { + socketManager.common.onConnect((id) => setMyId(id)); + socketManager.lobby.onRoomUpdate((updatedRoom) => { + setRoom(updatedRoom); + setScenePhase(appConsts.ScenePhase.LOBBY); + }); + socketManager.game.onGameStart(() => setScenePhase(appConsts.ScenePhase.PLAYING)); + }, []); + + return { scenePhase, room, myId }; +}; diff --git a/apps/client/src/input/VirtualJoystick.tsx b/apps/client/src/input/VirtualJoystick.tsx deleted file mode 100644 index 90928a1..0000000 --- a/apps/client/src/input/VirtualJoystick.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useState } from "react"; - -// ジョイスティック最大入力距離 -export const MAX_DIST = 60; - -type Props = { - // 正規化前入力ベクトル通知コールバック - onMove: (moveX: number, moveY: number) => void; -}; - -// タッチ・マウス両対応仮想ジョイスティック -export const VirtualJoystick = ({ onMove }: Props) => { - // 入力中フラグ - const [isMoving, setIsMoving] = useState(false); - // ジョイスティック基準座標 - const [basePos, setBasePos] = useState({ x: 0, y: 0 }); - // ノブ描画オフセット座標 - const [stickPos, setStickPos] = useState({ x: 0, y: 0 }); - - const handleStart = (e: React.TouchEvent | React.MouseEvent) => { - const clientX = "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; - const clientY = "touches" in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; - setBasePos({ x: clientX, y: clientY }); - setStickPos({ x: 0, y: 0 }); - setIsMoving(true); - }; - - const handleMove = (e: React.TouchEvent | React.MouseEvent) => { - if (!isMoving) return; - const clientX = "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; - const clientY = "touches" in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; - - const dx = clientX - basePos.x; - const dy = clientY - basePos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - const angle = Math.atan2(dy, dx); - - const limitedDist = Math.min(dist, MAX_DIST); - const moveX = Math.cos(angle) * limitedDist; - const moveY = Math.sin(angle) * limitedDist; - - setStickPos({ x: moveX, y: moveY }); - // 距離制限後入力ベクトル通知 - onMove(moveX, moveY); - }; - - const handleEnd = () => { - setIsMoving(false); - setStickPos({ x: 0, y: 0 }); - // 入力終了時停止ベクトル通知 - onMove(0, 0); - }; - - return ( -
- {isMoving && ( -
-
-
- )} -
- ); -}; \ No newline at end of file diff --git a/apps/client/src/managers/GameManager.ts b/apps/client/src/managers/GameManager.ts deleted file mode 100644 index 63eb182..0000000 --- a/apps/client/src/managers/GameManager.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Application, Container, Ticker } from "pixi.js"; -import { socketClient } from "../network/SocketClient"; -import { config } from "@repo/shared"; -import type { playerTypes } from "@repo/shared"; -import { BasePlayer, LocalPlayer, RemotePlayer } from "../entities/Player"; -import { GameMap } from "../entities/GameMap"; -import { MAX_DIST } from "../input/VirtualJoystick"; - -export class GameManager { - private app: Application; - private worldContainer: Container; - private players: Record = {}; - private myId: string; - private container: HTMLDivElement; - private gameMap!: GameMap; - private gameStartTime: number | null = null; - - // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ - public setGameStart(startTime: number) { - this.gameStartTime = startTime; - } - - // 現在の残り秒数を取得する - public getRemainingTime(): number { - if (!this.gameStartTime) return config.GAME_CONFIG.GAME_DURATION_SEC; - - // 現在のサーバー時刻を推定 (Date.now() + サーバーとの誤差補正が理想ですが、まずは簡易版で) - const elapsedMs = Date.now() - this.gameStartTime; - const remainingSec = config.GAME_CONFIG.GAME_DURATION_SEC - (elapsedMs / 1000); - - return Math.max(0, remainingSec); - } - - // 入力と状態管理 - private joystickInput = { x: 0, y: 0 }; - private lastPositionSentTime = 0; - private wasMoving = false; - private isInitialized = false; - private isDestroyed = false; - - constructor(container: HTMLDivElement, myId: string) { - this.container = container; // 明示的に代入 - this.myId = myId; - this.app = new Application(); - this.worldContainer = new Container(); - } - - /** - * ゲームエンジンの初期化 - */ - public async init() { - // PixiJS本体の初期化 - await this.app.init({ resizeTo: window, backgroundColor: 0x111111, antialias: true }); - - // 初期化完了前に destroy() が呼ばれていたら、ここで処理を中断して破棄する - if (this.isDestroyed) { - this.app.destroy(true, { children: true }); - return; - } - - this.container.appendChild(this.app.canvas); - - // 背景マップの配置 - const gameMap = new GameMap(); - this.gameMap = gameMap; - this.worldContainer.addChild(gameMap); - this.app.stage.addChild(this.worldContainer); - - // ネットワークイベントの登録 - this.setupSocketListeners(); - - // サーバーへゲーム準備完了を通知 - socketClient.readyForGame(); - - // メインループの登録 - this.app.ticker.add(this.tick.bind(this)); - this.isInitialized = true; - } - - /** - * React側からジョイスティックの入力を受け取る - */ - public setJoystickInput(x: number, y: number) { - this.joystickInput = { x, y }; - } - - /** - * ソケットイベントの登録 - */ - private setupSocketListeners() { - socketClient.onCurrentPlayers((serverPlayers: playerTypes.PlayerData[] | Record) => { - const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as playerTypes.PlayerData[]; - playersArray.forEach((p) => { - const playerSprite = p.id === this.myId ? new LocalPlayer(p) : new RemotePlayer(p); - this.worldContainer.addChild(playerSprite); - this.players[p.id] = playerSprite; - }); - }); - - socketClient.onNewPlayer((p: playerTypes.PlayerData) => { - const playerSprite = new RemotePlayer(p); - this.worldContainer.addChild(playerSprite); - this.players[p.id] = playerSprite; - }); - - // サーバーからの GAME_START を検知して開始時刻をセットする - socketClient.onGameStart((data) => { - if (data && data.startTime) { - this.setGameStart(data.startTime); - console.log(`[GameManager] ゲーム開始時刻同期完了: ${data.startTime}`); - } - }); - - socketClient.onUpdatePlayer((data: Partial & { id: string }) => { - if (data.id === this.myId) return; - const target = this.players[data.id]; - if (target && target instanceof RemotePlayer) { - target.setTargetPosition(data.x, data.y); - } - }); - - socketClient.onRemovePlayer((id: string) => { - const target = this.players[id]; - if (target) { - this.worldContainer.removeChild(target); - target.destroy(); - delete this.players[id]; - } - }); - - socketClient.onUpdateMapCells((updates) => { - this.gameMap.updateCells(updates); - }); - } - - /** - * 毎フレームの更新処理(メインゲームループ) - */ - private tick(ticker: Ticker) { - const me = this.players[this.myId]; - if (!me || !(me instanceof LocalPlayer)) return; - - const deltaSeconds = ticker.deltaMS / 1000; - - // 1. 自プレイヤーの移動と送信 - const { x: dx, y: dy } = this.joystickInput; - const isMoving = dx !== 0 || dy !== 0; - - if (isMoving) { - me.move(dx / MAX_DIST, dy / MAX_DIST, deltaSeconds); - - const now = performance.now(); - if (now - this.lastPositionSentTime >= config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { - socketClient.sendMove(me.gridX, me.gridY); - this.lastPositionSentTime = now; - } - } else if (this.wasMoving) { - socketClient.sendMove(me.gridX, me.gridY); - } - this.wasMoving = isMoving; - - // 2. 全プレイヤーの更新(Lerpなど) - Object.values(this.players).forEach((player) => { - player.update(deltaSeconds); - }); - - // 3. カメラの追従(自分を中心に) - this.worldContainer.position.set( - -(me.x - this.app.screen.width / 2), - -(me.y - this.app.screen.height / 2) - ); - } - - /** - * クリーンアップ処理(コンポーネントアンマウント時) - */ - public destroy() { - this.isDestroyed = true; - if (this.isInitialized) { - this.app.destroy(true, { children: true }); - } - this.players = {}; - - // イベント購読の解除 - socketClient.removeAllListeners(); - } -} \ No newline at end of file diff --git a/apps/client/src/network/SocketClient.ts b/apps/client/src/network/SocketClient.ts deleted file mode 100644 index c2702a2..0000000 --- a/apps/client/src/network/SocketClient.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { io, Socket } from "socket.io-client"; -import { protocol, config } from "@repo/shared"; -import type { playerTypes, gridMapTypes, roomTypes } from "@repo/shared"; - -/** - * サーバー WebSocket 通信管理クラス - */ -export class SocketClient { - public socket: Socket; - - constructor() { - // サーバー(バックエンド)のURLを直接指定する - // 本番環境(Render)のURLを指定することで、プロキシなしで直接通信させます - const SERVER_URL = import.meta.env.PROD - ? config.NETWORK_CONFIG.PROD_SERVER_URL - : config.NETWORK_CONFIG.DEV_SERVER_URL; - - this.socket = io(SERVER_URL, { - transports: [...config.NETWORK_CONFIG.SOCKET_TRANSPORTS], // 接続の安定性を高める - withCredentials: true - }); - } - - /** - * 接続完了イベント購読 - * @param callback 接続済みソケットID受け取りコールバック - */ - onConnect(callback: (id: string) => void) { - // 接続済み状態での即時通知 - if (this.socket.connected) { - callback(this.socket.id || ""); - } - - // 初回接続・再接続イベント購読 - this.socket.on(protocol.SocketEvents.CONNECT, () => { - callback(this.socket.id || ""); - }); - } - - /** - * 初期プレイヤー一覧受信イベント購読 - */ - onCurrentPlayers(callback: (players: playerTypes.PlayerData[] | Record) => void) { - this.socket.on(protocol.SocketEvents.CURRENT_PLAYERS, callback); - } - - /** - * 新規プレイヤー参加イベント購読 - */ - onNewPlayer(callback: (player: playerTypes.PlayerData) => void) { - this.socket.on(protocol.SocketEvents.NEW_PLAYER, callback); - } - - /** - * 他プレイヤー状態更新イベント購読 - */ - onUpdatePlayer(callback: (data: Partial & { id: string }) => void) { - this.socket.on(protocol.SocketEvents.UPDATE_PLAYER, callback); - } - - /** - * プレイヤー退出イベント購読 - */ - onRemovePlayer(callback: (id: string) => void) { - this.socket.on(protocol.SocketEvents.REMOVE_PLAYER, callback); - } - - /** - * 自身移動データ送信 - * @param x 現在のX座標 - * @param y 現在のY座標 - */ - sendMove(x: number, y: number) { - const payload: playerTypes.MovePayload = { x, y }; - this.socket.emit(protocol.SocketEvents.MOVE, payload); - } - - /** - * マス目の差分更新イベント購読 - */ - onUpdateMapCells(callback: (updates: gridMapTypes.CellUpdate[]) => void) { - this.socket.on(protocol.SocketEvents.UPDATE_MAP_CELLS, callback); - } - - /** - * ルーム入室リクエスト送信 - * @param roomId 入室先のID - * @param playerName 表示名 - */ - joinRoom(roomId: string, playerName: string) { - const payload: roomTypes.JoinRoomPayload = { roomId, playerName }; - this.socket.emit(protocol.SocketEvents.JOIN_ROOM, payload); - } - - /** - * ルーム情報更新イベント購読 - */ - onRoomUpdate(callback: (room: roomTypes.Room) => void) { - this.socket.on(protocol.SocketEvents.ROOM_UPDATE, callback); - } - - /** - * ゲーム開始通知イベント購読 - */ - onGameStart(callback: (data: { startTime: number }) => void) { - this.socket.on(protocol.SocketEvents.GAME_START, callback); - } - - /** - * ゲーム開始リクエスト送信 - */ - startGame() { - this.socket.emit(protocol.SocketEvents.START_GAME); - } - - /** - * ゲーム画面準備完了通知 - */ - readyForGame() { - this.socket.emit(protocol.SocketEvents.READY_FOR_GAME); - } - - /** - * 全てのゲームプレイ関連イベントを解除(一括解除用) - */ - removeAllListeners() { - this.socket.off(protocol.SocketEvents.CURRENT_PLAYERS); - this.socket.off(protocol.SocketEvents.NEW_PLAYER); - this.socket.off(protocol.SocketEvents.UPDATE_PLAYER); - this.socket.off(protocol.SocketEvents.REMOVE_PLAYER); - this.socket.off(protocol.SocketEvents.UPDATE_MAP_CELLS); - } -} - -// シングルトン利用向け共有インスタンス -export const socketClient = new SocketClient(); \ No newline at end of file diff --git a/apps/client/src/network/SocketManager.ts b/apps/client/src/network/SocketManager.ts new file mode 100644 index 0000000..f22bea7 --- /dev/null +++ b/apps/client/src/network/SocketManager.ts @@ -0,0 +1,32 @@ +import { io, Socket } from "socket.io-client"; +import { config } from "@repo/shared"; +import { createCommonHandler, type CommonHandler } from "./handlers/CommonHandler"; +import { createTitleHandler, type TitleHandler } from "./handlers/TitleHandler"; +import { createLobbyHandler, type LobbyHandler } from "./handlers/LobbyHandler"; +import { createGameHandler, type GameHandler } from "./handlers/GameHandler"; + +export class SocketManager { + public socket: Socket; + public common: CommonHandler; + public title: TitleHandler; + public lobby: LobbyHandler; + public game: GameHandler; + + constructor() { + const serverUrl = import.meta.env.PROD + ? config.NETWORK_CONFIG.PROD_SERVER_URL + : config.NETWORK_CONFIG.DEV_SERVER_URL; + + this.socket = io(serverUrl, { + transports: [...config.NETWORK_CONFIG.SOCKET_TRANSPORTS], + withCredentials: true + }); + + this.common = createCommonHandler(this.socket); + this.title = createTitleHandler(this.socket); + this.lobby = createLobbyHandler(this.socket); + this.game = createGameHandler(this.socket); + } +} + +export const socketManager = new SocketManager(); diff --git a/apps/client/src/network/handlers/CommonHandler.ts b/apps/client/src/network/handlers/CommonHandler.ts new file mode 100644 index 0000000..d76fe24 --- /dev/null +++ b/apps/client/src/network/handlers/CommonHandler.ts @@ -0,0 +1,22 @@ +import type { Socket } from "socket.io-client"; +import { protocol } from "@repo/shared"; + +type CommonHandler = { + onConnect: (callback: (id: string) => void) => void; +}; + +export const createCommonHandler = (socket: Socket): CommonHandler => { + return { + onConnect: (callback: (id: string) => void) => { + if (socket.connected) { + callback(socket.id || ""); + } + + socket.on(protocol.SocketEvents.CONNECT, () => { + callback(socket.id || ""); + }); + } + }; +}; + +export type { CommonHandler }; diff --git a/apps/client/src/network/handlers/GameHandler.ts b/apps/client/src/network/handlers/GameHandler.ts new file mode 100644 index 0000000..77abe3d --- /dev/null +++ b/apps/client/src/network/handlers/GameHandler.ts @@ -0,0 +1,54 @@ +import type { Socket } from "socket.io-client"; +import { protocol } from "@repo/shared"; +import type { playerTypes, gridMapTypes } from "@repo/shared"; + +type GameHandler = { + onCurrentPlayers: (callback: (players: playerTypes.PlayerData[] | Record) => void) => void; + onNewPlayer: (callback: (player: playerTypes.PlayerData) => void) => void; + onUpdatePlayer: (callback: (data: Partial & { id: string }) => void) => void; + onRemovePlayer: (callback: (id: string) => void) => void; + onUpdateMapCells: (callback: (updates: gridMapTypes.CellUpdate[]) => void) => void; + onGameStart: (callback: (data: { startTime: number }) => void) => void; + sendMove: (x: number, y: number) => void; + readyForGame: () => void; + removeAllListeners: () => void; +}; + +export const createGameHandler = (socket: Socket): GameHandler => { + return { + onCurrentPlayers: (callback) => { + socket.on(protocol.SocketEvents.CURRENT_PLAYERS, callback); + }, + onNewPlayer: (callback) => { + socket.on(protocol.SocketEvents.NEW_PLAYER, callback); + }, + onUpdatePlayer: (callback) => { + socket.on(protocol.SocketEvents.UPDATE_PLAYER, callback); + }, + onRemovePlayer: (callback) => { + socket.on(protocol.SocketEvents.REMOVE_PLAYER, callback); + }, + onUpdateMapCells: (callback) => { + socket.on(protocol.SocketEvents.UPDATE_MAP_CELLS, callback); + }, + onGameStart: (callback) => { + socket.on(protocol.SocketEvents.GAME_START, callback); + }, + sendMove: (x, y) => { + const payload: playerTypes.MovePayload = { x, y }; + socket.emit(protocol.SocketEvents.MOVE, payload); + }, + readyForGame: () => { + socket.emit(protocol.SocketEvents.READY_FOR_GAME); + }, + removeAllListeners: () => { + socket.off(protocol.SocketEvents.CURRENT_PLAYERS); + socket.off(protocol.SocketEvents.NEW_PLAYER); + socket.off(protocol.SocketEvents.UPDATE_PLAYER); + socket.off(protocol.SocketEvents.REMOVE_PLAYER); + socket.off(protocol.SocketEvents.UPDATE_MAP_CELLS); + } + }; +}; + +export type { GameHandler }; diff --git a/apps/client/src/network/handlers/LobbyHandler.ts b/apps/client/src/network/handlers/LobbyHandler.ts new file mode 100644 index 0000000..863ab5b --- /dev/null +++ b/apps/client/src/network/handlers/LobbyHandler.ts @@ -0,0 +1,21 @@ +import type { Socket } from "socket.io-client"; +import { protocol } from "@repo/shared"; +import type { roomTypes } from "@repo/shared"; + +type LobbyHandler = { + onRoomUpdate: (callback: (room: roomTypes.Room) => void) => void; + startGame: () => void; +}; + +export const createLobbyHandler = (socket: Socket): LobbyHandler => { + return { + onRoomUpdate: (callback: (room: roomTypes.Room) => void) => { + socket.on(protocol.SocketEvents.ROOM_UPDATE, callback); + }, + startGame: () => { + socket.emit(protocol.SocketEvents.START_GAME); + } + }; +}; + +export type { LobbyHandler }; diff --git a/apps/client/src/network/handlers/TitleHandler.ts b/apps/client/src/network/handlers/TitleHandler.ts new file mode 100644 index 0000000..32248a8 --- /dev/null +++ b/apps/client/src/network/handlers/TitleHandler.ts @@ -0,0 +1,17 @@ +import type { Socket } from "socket.io-client"; +import { protocol } from "@repo/shared"; +import type { roomTypes } from "@repo/shared"; + +type TitleHandler = { + joinRoom: (payload: roomTypes.JoinRoomPayload) => void; +}; + +export const createTitleHandler = (socket: Socket): TitleHandler => { + return { + joinRoom: (payload: roomTypes.JoinRoomPayload) => { + socket.emit(protocol.SocketEvents.JOIN_ROOM, payload); + } + }; +}; + +export type { TitleHandler }; diff --git a/apps/client/src/scenes/GameScene.tsx b/apps/client/src/scenes/GameScene.tsx deleted file mode 100644 index c4bca7f..0000000 --- a/apps/client/src/scenes/GameScene.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { VirtualJoystick } from "../input/VirtualJoystick"; -import { GameManager } from "../managers/GameManager"; -import { config } from "@repo/shared"; - -interface GameSceneProps { - myId: string | null; -} - -/** - * メインゲーム画面コンポーネント - * UIの描画と GameManager への入力伝達のみを担当する - */ -export function GameScene({ myId }: GameSceneProps) { - const pixiContainerRef = useRef(null); - const gameManagerRef = useRef(null); - - // gameConfig から初期表示時間文字列を生成する関数 - const getInitialTimeDisplay = () => { - const totalSec = config.GAME_CONFIG.GAME_DURATION_SEC; - const mins = Math.floor(totalSec / 60); - const secs = Math.floor(totalSec % 60); - return `${mins}:${secs.toString().padStart(2, '0')}`; - }; - - // 初期値に関数を使用 - const [timeLeft, setTimeLeft] = useState(getInitialTimeDisplay()); - - useEffect(() => { - if (!pixiContainerRef.current || !myId) return; - - // GameManager のインスタンス化と初期化 - const manager = new GameManager(pixiContainerRef.current, myId); - manager.init(); - - // 参照を保持(ジョイスティック入力を渡すため) - gameManagerRef.current = manager; - - // 描画用のタイマーループ (100msごとに更新して滑らかにする) - const timerInterval = setInterval(() => { - const remaining = manager.getRemainingTime(); - const mins = Math.floor(remaining / 60); - const secs = Math.floor(remaining % 60); - // 2:59 の形式にフォーマット - setTimeLeft(`${mins}:${secs.toString().padStart(2, '0')}`); - }, 100); - - // コンポーネント破棄時のクリーンアップ - return () => { - manager.destroy(); - clearInterval(timerInterval); // クリーンアップ - }; - }, [myId]); - - return ( -
- {/* タイマーUIの表示 */} -
- {timeLeft} -
- - {/* PixiJS Canvas 配置領域 */} -
- - {/* UI 配置領域 */} -
- { - // ジョイスティックの入力を毎フレーム Manager に渡す - gameManagerRef.current?.setJoystickInput(x, y); - }} - /> -
-
- ); -} \ No newline at end of file diff --git a/apps/client/src/scenes/LobbyScene.tsx b/apps/client/src/scenes/LobbyScene.tsx deleted file mode 100644 index 6653788..0000000 --- a/apps/client/src/scenes/LobbyScene.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { roomTypes } from "@repo/shared"; - -type Props = { - room: roomTypes.Room | null; - myId: string | null; - onStart: () => void; -}; - -export const LobbyScene = ({ room, myId, onStart }: Props) => { - // ルーム情報到着前ローディング表示 - if (!room) return
読み込み中...
; - - // 自身オーナー権限判定 - const isMeOwner = room.ownerId === myId; - - // ロビー画面本体 - return ( -
-

ルーム: {room.roomId} (待機中)

- -
-

- 参加プレイヤー ({room.players.length}/{room.maxPlayers}) -

-
    - {/* 参加プレイヤー一覧描画 */} - {room.players.map((p: roomTypes.RoomMember) => ( -
  • - {p.id === myId ? "🟢" : "⚪"} - {p.name} - {p.isOwner && 👑} - {p.isReady && } -
  • - ))} -
-
- -
- {/* オーナー開始操作と待機表示の分岐 */} - {isMeOwner ? ( - - ) : ( -

オーナーがゲームを開始するのを待っています...

- )} -
-
- ); -}; \ No newline at end of file diff --git a/apps/client/src/scenes/TitleScene.tsx b/apps/client/src/scenes/TitleScene.tsx deleted file mode 100644 index d475c08..0000000 --- a/apps/client/src/scenes/TitleScene.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useState } from "react"; -// ルーム参加時送信ペイロード型 -import type { roomTypes } from "@repo/shared"; - -type Props = { - // 入室実行時呼び出しコールバック - onJoin: (payload: roomTypes.JoinRoomPayload) => void; -}; - -export const TitleScene = ({ onJoin }: Props) => { - // プレイヤー名入力値 - const [playerName, setPlayerName] = useState(""); - // ルームID入力値 - const [roomIdInput, setRoomIdInput] = useState(""); - - // 入室ボタン活性条件 - const canJoin = playerName !== "" && roomIdInput !== ""; - - // 入室実行ハンドラ - const handleJoin = () => { - if (canJoin) { - onJoin({ roomId: roomIdInput, playerName }); - } - }; - - return ( -
-

Pixel Paint War

- -
- setPlayerName(e.target.value)} - style={{ padding: "12px", fontSize: "1.2rem", borderRadius: "5px", border: "none" }} - /> - setRoomIdInput(e.target.value)} - style={{ padding: "12px", fontSize: "1.2rem", borderRadius: "5px", border: "none" }} - /> -
- - -
- ); -}; \ No newline at end of file diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts new file mode 100644 index 0000000..2d60ceb --- /dev/null +++ b/apps/client/src/scenes/game/GameManager.ts @@ -0,0 +1,187 @@ +import { Application, Container, Ticker } from "pixi.js"; +import { socketManager } from "@client/network/SocketManager"; +import { config } from "@repo/shared"; +import type { playerTypes } from "@repo/shared"; +import { BasePlayer, LocalPlayer, RemotePlayer } from "./Player"; +import { GameMap } from "./GameMap"; +import { MAX_DIST } from "./VirtualJoystick"; + +export class GameManager { + private app: Application; + private worldContainer: Container; + private players: Record = {}; + private myId: string; + private container: HTMLDivElement; + private gameMap!: GameMap; + private gameStartTime: number | null = null; + + // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ + public setGameStart(startTime: number) { + this.gameStartTime = startTime; + } + + // 現在の残り秒数を取得する + public getRemainingTime(): number { + if (!this.gameStartTime) return config.GAME_CONFIG.GAME_DURATION_SEC; + + // 現在のサーバー時刻を推定 (Date.now() + サーバーとの誤差補正が理想ですが、まずは簡易版で) + const elapsedMs = Date.now() - this.gameStartTime; + const remainingSec = config.GAME_CONFIG.GAME_DURATION_SEC - (elapsedMs / 1000); + + return Math.max(0, remainingSec); + } + + // 入力と状態管理 + private joystickInput = { x: 0, y: 0 }; + private lastPositionSentTime = 0; + private wasMoving = false; + private isInitialized = false; + private isDestroyed = false; + + constructor(container: HTMLDivElement, myId: string) { + this.container = container; // 明示的に代入 + this.myId = myId; + this.app = new Application(); + this.worldContainer = new Container(); + } + + /** + * ゲームエンジンの初期化 + */ + public async init() { + // PixiJS本体の初期化 + await this.app.init({ resizeTo: window, backgroundColor: 0x111111, antialias: true }); + + // 初期化完了前に destroy() が呼ばれていたら、ここで処理を中断して破棄する + if (this.isDestroyed) { + this.app.destroy(true, { children: true }); + return; + } + + this.container.appendChild(this.app.canvas); + + // 背景マップの配置 + const gameMap = new GameMap(); + this.gameMap = gameMap; + this.worldContainer.addChild(gameMap); + this.app.stage.addChild(this.worldContainer); + + // ネットワークイベントの登録 + this.setupSocketListeners(); + + // サーバーへゲーム準備完了を通知 + socketManager.game.readyForGame(); + + // メインループの登録 + this.app.ticker.add(this.tick.bind(this)); + this.isInitialized = true; + } + + /** + * React側からジョイスティックの入力を受け取る + */ + public setJoystickInput(x: number, y: number) { + this.joystickInput = { x, y }; + } + + /** + * ソケットイベントの登録 + */ + private setupSocketListeners() { + socketManager.game.onCurrentPlayers((serverPlayers: playerTypes.PlayerData[] | Record) => { + const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as playerTypes.PlayerData[]; + playersArray.forEach((p) => { + const playerSprite = p.id === this.myId ? new LocalPlayer(p) : new RemotePlayer(p); + this.worldContainer.addChild(playerSprite); + this.players[p.id] = playerSprite; + }); + }); + + socketManager.game.onNewPlayer((p: playerTypes.PlayerData) => { + const playerSprite = new RemotePlayer(p); + this.worldContainer.addChild(playerSprite); + this.players[p.id] = playerSprite; + }); + + // サーバーからの GAME_START を検知して開始時刻をセットする + socketManager.game.onGameStart((data) => { + if (data && data.startTime) { + this.setGameStart(data.startTime); + console.log(`[GameManager] ゲーム開始時刻同期完了: ${data.startTime}`); + } + }); + + socketManager.game.onUpdatePlayer((data: Partial & { id: string }) => { + if (data.id === this.myId) return; + const target = this.players[data.id]; + if (target && target instanceof RemotePlayer) { + target.setTargetPosition(data.x, data.y); + } + }); + + socketManager.game.onRemovePlayer((id: string) => { + const target = this.players[id]; + if (target) { + this.worldContainer.removeChild(target); + target.destroy(); + delete this.players[id]; + } + }); + + socketManager.game.onUpdateMapCells((updates) => { + this.gameMap.updateCells(updates); + }); + } + + /** + * 毎フレームの更新処理(メインゲームループ) + */ + private tick(ticker: Ticker) { + const me = this.players[this.myId]; + if (!me || !(me instanceof LocalPlayer)) return; + + const deltaSeconds = ticker.deltaMS / 1000; + + // 1. 自プレイヤーの移動と送信 + const { x: dx, y: dy } = this.joystickInput; + const isMoving = dx !== 0 || dy !== 0; + + if (isMoving) { + me.move(dx / MAX_DIST, dy / MAX_DIST, deltaSeconds); + + const now = performance.now(); + if (now - this.lastPositionSentTime >= config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { + socketManager.game.sendMove(me.gridX, me.gridY); + this.lastPositionSentTime = now; + } + } else if (this.wasMoving) { + socketManager.game.sendMove(me.gridX, me.gridY); + } + this.wasMoving = isMoving; + + // 2. 全プレイヤーの更新(Lerpなど) + Object.values(this.players).forEach((player) => { + player.update(deltaSeconds); + }); + + // 3. カメラの追従(自分を中心に) + this.worldContainer.position.set( + -(me.x - this.app.screen.width / 2), + -(me.y - this.app.screen.height / 2) + ); + } + + /** + * クリーンアップ処理(コンポーネントアンマウント時) + */ + public destroy() { + this.isDestroyed = true; + if (this.isInitialized) { + this.app.destroy(true, { children: true }); + } + this.players = {}; + + // イベント購読の解除 + socketManager.game.removeAllListeners(); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/GameMap.ts b/apps/client/src/scenes/game/GameMap.ts new file mode 100644 index 0000000..19d3cae --- /dev/null +++ b/apps/client/src/scenes/game/GameMap.ts @@ -0,0 +1,117 @@ +// apps/client/src/scenes/game/GameMap.ts +import { Container, Graphics } from "pixi.js"; +import { config } from "@repo/shared"; +import type { gridMapTypes } from "@repo/shared"; + +// 親クラスを Graphics から Container に変更し、レイヤー管理を可能にする +export class GameMap extends Container { + private bgGraphics: Graphics; + private gridGraphics: Graphics; + + // 400個(GRID_COLS * GRID_ROWS)のマス目描画用オブジェクトを保持する1次元配列 + private cells: Graphics[] = []; + + constructor() { + super(); + this.bgGraphics = new Graphics(); + this.gridGraphics = new Graphics(); + + // 描画順(追加した順に前面に描画される): 背景 -> マス目 -> グリッド線 + this.addChild(this.bgGraphics); + + // マス目の初期化と Container への追加 + this.initCells(); + + this.addChild(this.gridGraphics); + + // 背景と線の描画(静的なので1回だけ実行) + this.drawBaseMap(); + } + + // 設定値に基づき、空のマス目(Graphics)を400個生成して配列に格納する + private initCells() { + const { GRID_COLS, GRID_ROWS, GRID_CELL_SIZE } = config.GAME_CONFIG; + const totalCells = GRID_COLS * GRID_ROWS; + + for (let i = 0; i < totalCells; i++) { + const col = i % GRID_COLS; + const row = Math.floor(i / GRID_COLS); + + const cell = new Graphics(); + // マスの座標をあらかじめ設定しておく(描画の基準点になる) + cell.x = col * GRID_CELL_SIZE; + cell.y = row * GRID_CELL_SIZE; + + this.addChild(cell); + this.cells.push(cell); + } + } + + // 設定値参照によるマップ外観(背景・グリッド線)の組み立て処理 + private drawBaseMap() { + const { + MAP_WIDTH_PX, MAP_HEIGHT_PX, GRID_CELL_SIZE, + MAP_BG_COLOR, MAP_GRID_COLOR, MAP_BORDER_COLOR + } = config.GAME_CONFIG; + + // マップ全域背景レイヤー + this.bgGraphics.rect(0, 0, MAP_WIDTH_PX, MAP_HEIGHT_PX).fill(MAP_BG_COLOR); + + // 縦方向グリッド線 + for (let x = 0; x <= MAP_WIDTH_PX; x += GRID_CELL_SIZE) { + this.gridGraphics.moveTo(x, 0).lineTo(x, MAP_HEIGHT_PX).stroke({ width: 1, color: MAP_GRID_COLOR }); + } + // 横方向グリッド線 + for (let y = 0; y <= MAP_HEIGHT_PX; y += GRID_CELL_SIZE) { + this.gridGraphics.moveTo(0, y).lineTo(MAP_WIDTH_PX, y).stroke({ width: 1, color: MAP_GRID_COLOR }); + } + + // プレイ領域外枠 + this.gridGraphics.rect(0, 0, MAP_WIDTH_PX, MAP_HEIGHT_PX).stroke({ width: 5, color: MAP_BORDER_COLOR }); + } + + /** + * サーバー(またはテストロジック)から受け取った最新のマップ状態で色を更新する + */ + public updateMapState(state: gridMapTypes.MapState) { + const { GRID_CELL_SIZE, TEAM_COLORS } = config.GAME_CONFIG; + + for (let i = 0; i < state.gridColors.length; i++) { + const teamId = state.gridColors[i]; + const cell = this.cells[i]; + + // 一旦マスの描画をクリア + cell.clear(); + + // 塗布済み(-1以外)の場合のみ色を塗る + if (teamId !== -1) { + // Player.ts と同様に、文字列のカラーコードを PixiJS 用の数値に変換 + const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; + const hexColor = parseInt(colorString.replace("#", "0x"), 16); + + // cell.x, cell.y は設定済みなので、ローカル座標 (0,0) からサイズ分を塗りつぶす + cell.rect(0, 0, GRID_CELL_SIZE, GRID_CELL_SIZE).fill(hexColor); + } + } + } + + /** + * 差分データを受け取って指定のマスだけ色を更新する + */ + public updateCells(updates: gridMapTypes.CellUpdate[]) { + const { GRID_CELL_SIZE, TEAM_COLORS } = config.GAME_CONFIG; + + updates.forEach(({ index, teamId }) => { + const cell = this.cells[index]; + if (!cell) return; + + cell.clear(); + + if (teamId !== -1) { + const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; + const hexColor = parseInt(colorString.replace("#", "0x"), 16); + cell.rect(0, 0, GRID_CELL_SIZE, GRID_CELL_SIZE).fill(hexColor); + } + }); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx new file mode 100644 index 0000000..2b04995 --- /dev/null +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, useState } from "react"; +import { VirtualJoystick } from "./VirtualJoystick"; +import { GameManager } from "./GameManager"; +import { config } from "@repo/shared"; + +interface GameSceneProps { + myId: string | null; +} + +/** + * メインゲーム画面コンポーネント + * UIの描画と GameManager への入力伝達のみを担当する + */ +export function GameScene({ myId }: GameSceneProps) { + const pixiContainerRef = useRef(null); + const gameManagerRef = useRef(null); + + // gameConfig から初期表示時間文字列を生成する関数 + const getInitialTimeDisplay = () => { + const totalSec = config.GAME_CONFIG.GAME_DURATION_SEC; + const mins = Math.floor(totalSec / 60); + const secs = Math.floor(totalSec % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + // 初期値に関数を使用 + const [timeLeft, setTimeLeft] = useState(getInitialTimeDisplay()); + + useEffect(() => { + if (!pixiContainerRef.current || !myId) return; + + // GameManager のインスタンス化と初期化 + const manager = new GameManager(pixiContainerRef.current, myId); + manager.init(); + + // 参照を保持(ジョイスティック入力を渡すため) + gameManagerRef.current = manager; + + // 描画用のタイマーループ (100msごとに更新して滑らかにする) + const timerInterval = setInterval(() => { + const remaining = manager.getRemainingTime(); + const mins = Math.floor(remaining / 60); + const secs = Math.floor(remaining % 60); + // 2:59 の形式にフォーマット + setTimeLeft(`${mins}:${secs.toString().padStart(2, '0')}`); + }, 100); + + // コンポーネント破棄時のクリーンアップ + return () => { + manager.destroy(); + clearInterval(timerInterval); // クリーンアップ + }; + }, [myId]); + + return ( +
+ {/* タイマーUIの表示 */} +
+ {timeLeft} +
+ + {/* PixiJS Canvas 配置領域 */} +
+ + {/* UI 配置領域 */} +
+ { + // ジョイスティックの入力を毎フレーム Manager に渡す + gameManagerRef.current?.setJoystickInput(x, y); + }} + /> +
+
+ ); +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/Player.ts b/apps/client/src/scenes/game/Player.ts new file mode 100644 index 0000000..1408491 --- /dev/null +++ b/apps/client/src/scenes/game/Player.ts @@ -0,0 +1,135 @@ +import { Graphics } from 'pixi.js'; +import { config } from "@repo/shared"; +import type { playerTypes } from "@repo/shared"; + +/** + * プレイヤーの共通基底クラス(描画と基本データの保持) + */ +export abstract class BasePlayer extends Graphics { + public id: string; + public teamId: number; + public gridX: number; + public gridY: number; + + constructor(data: playerTypes.PlayerData, isLocal: boolean = false) { + super(); + this.id = data.id; + this.teamId = data.teamId; + + // 初期座標のセット(内部はグリッド単位) + this.gridX = data.x; + this.gridY = data.y; + + // gameConfigから定数を取得 + const { + GRID_CELL_SIZE, + PLAYER_RADIUS_PX, + TEAM_COLORS, + PLAYER_LOCAL_STROKE_COLOR, + PLAYER_LOCAL_STROKE_WIDTH, + PLAYER_REMOTE_STROKE_COLOR, + PLAYER_REMOTE_STROKE_WIDTH + } = config.GAME_CONFIG; + + this.position.set(this.gridX * GRID_CELL_SIZE, this.gridY * GRID_CELL_SIZE); + + // チームIDに対応する色をHEX文字列('#RRGGBB')で取得し、PixiJS用の数値(0xRRGGBB)に変換 + const colorString = TEAM_COLORS[this.teamId] || '#FFFFFF'; + const hexColor = parseInt(colorString.replace("#", "0x"), 16); + + // 自プレイヤーか他プレイヤーかで枠線の設定を切り替え + const strokeColor = isLocal ? PLAYER_LOCAL_STROKE_COLOR : PLAYER_REMOTE_STROKE_COLOR; + const strokeWidth = isLocal ? PLAYER_LOCAL_STROKE_WIDTH : PLAYER_REMOTE_STROKE_WIDTH; + + // 塗りつぶしと枠線を同時に描画 + this.circle(0, 0, PLAYER_RADIUS_PX) + .fill(hexColor) + .stroke({ width: strokeWidth, color: strokeColor }); + } + + protected syncDisplayPosition() { + const { GRID_CELL_SIZE } = config.GAME_CONFIG; + this.position.set(this.gridX * GRID_CELL_SIZE, this.gridY * GRID_CELL_SIZE); + } + + // 毎フレーム呼ばれる更新メソッド(サブクラスで具体的な処理を実装させる) + abstract update(deltaTime: number): void; +} + +/** + * 自プレイヤー(キー・ジョイスティック入力で移動・送信する) + */ +export class LocalPlayer extends BasePlayer { + constructor(data: playerTypes.PlayerData) { + super(data, true); + } + + /** + * 入力ベクトルと経過時間基準の座標更新処理 + */ + public move(vx: number, vy: number, deltaTime: number) { + const { PLAYER_SPEED, GRID_COLS, GRID_ROWS, PLAYER_RADIUS } = config.GAME_CONFIG; + + const speed = PLAYER_SPEED * deltaTime; + this.gridX += vx * speed; + this.gridY += vy * speed; + + // 画面外に出ないようにクランプ + this.gridX = Math.max(PLAYER_RADIUS, Math.min(GRID_COLS - PLAYER_RADIUS, this.gridX)); + this.gridY = Math.max(PLAYER_RADIUS, Math.min(GRID_ROWS - PLAYER_RADIUS, this.gridY)); + + this.syncDisplayPosition(); + } + + public update(_deltaTime: number): void { + // 自プレイヤーは GameScene 側から move() を通じて動かすため、ここでは何もしない + } +} + +/** + * 他プレイヤー(サーバーからの通信を受信して補間・吸着移動する) + */ +export class RemotePlayer extends BasePlayer { + private targetGridX: number; + private targetGridY: number; + + constructor(data: playerTypes.PlayerData) { + super(data, false); + this.targetGridX = data.x; + this.targetGridY = data.y; + } + + /** + * サーバーから受信した最新の座標を目標としてセットする + */ + public setTargetPosition(x?: number, y?: number) { + if (x !== undefined) this.targetGridX = x; + if (y !== undefined) this.targetGridY = y; + } + + /** + * 毎フレームの更新処理(目標座標へのLerp補間) + */ + public update(deltaTime: number): void { + const { PLAYER_LERP_SNAP_THRESHOLD, PLAYER_LERP_SMOOTHNESS } = config.GAME_CONFIG; + + const diffX = this.targetGridX - this.gridX; + const diffY = this.targetGridY - this.gridY; + + // X軸の補間 + if (Math.abs(diffX) < PLAYER_LERP_SNAP_THRESHOLD) { + this.gridX = this.targetGridX; + } else { + this.gridX += diffX * PLAYER_LERP_SMOOTHNESS * deltaTime; + } + + // Y軸の補間 + if (Math.abs(diffY) < PLAYER_LERP_SNAP_THRESHOLD) { + this.gridY = this.targetGridY; + } else { + this.gridY += diffY * PLAYER_LERP_SMOOTHNESS * deltaTime; + } + + this.syncDisplayPosition(); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/VirtualJoystick.tsx b/apps/client/src/scenes/game/VirtualJoystick.tsx new file mode 100644 index 0000000..90928a1 --- /dev/null +++ b/apps/client/src/scenes/game/VirtualJoystick.tsx @@ -0,0 +1,105 @@ +import { useState } from "react"; + +// ジョイスティック最大入力距離 +export const MAX_DIST = 60; + +type Props = { + // 正規化前入力ベクトル通知コールバック + onMove: (moveX: number, moveY: number) => void; +}; + +// タッチ・マウス両対応仮想ジョイスティック +export const VirtualJoystick = ({ onMove }: Props) => { + // 入力中フラグ + const [isMoving, setIsMoving] = useState(false); + // ジョイスティック基準座標 + const [basePos, setBasePos] = useState({ x: 0, y: 0 }); + // ノブ描画オフセット座標 + const [stickPos, setStickPos] = useState({ x: 0, y: 0 }); + + const handleStart = (e: React.TouchEvent | React.MouseEvent) => { + const clientX = "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; + const clientY = "touches" in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; + setBasePos({ x: clientX, y: clientY }); + setStickPos({ x: 0, y: 0 }); + setIsMoving(true); + }; + + const handleMove = (e: React.TouchEvent | React.MouseEvent) => { + if (!isMoving) return; + const clientX = "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; + const clientY = "touches" in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; + + const dx = clientX - basePos.x; + const dy = clientY - basePos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + + const limitedDist = Math.min(dist, MAX_DIST); + const moveX = Math.cos(angle) * limitedDist; + const moveY = Math.sin(angle) * limitedDist; + + setStickPos({ x: moveX, y: moveY }); + // 距離制限後入力ベクトル通知 + onMove(moveX, moveY); + }; + + const handleEnd = () => { + setIsMoving(false); + setStickPos({ x: 0, y: 0 }); + // 入力終了時停止ベクトル通知 + onMove(0, 0); + }; + + return ( +
+ {isMoving && ( +
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/client/src/scenes/lobby/LobbyScene.tsx b/apps/client/src/scenes/lobby/LobbyScene.tsx new file mode 100644 index 0000000..6653788 --- /dev/null +++ b/apps/client/src/scenes/lobby/LobbyScene.tsx @@ -0,0 +1,53 @@ +import type { roomTypes } from "@repo/shared"; + +type Props = { + room: roomTypes.Room | null; + myId: string | null; + onStart: () => void; +}; + +export const LobbyScene = ({ room, myId, onStart }: Props) => { + // ルーム情報到着前ローディング表示 + if (!room) return
読み込み中...
; + + // 自身オーナー権限判定 + const isMeOwner = room.ownerId === myId; + + // ロビー画面本体 + return ( +
+

ルーム: {room.roomId} (待機中)

+ +
+

+ 参加プレイヤー ({room.players.length}/{room.maxPlayers}) +

+
    + {/* 参加プレイヤー一覧描画 */} + {room.players.map((p: roomTypes.RoomMember) => ( +
  • + {p.id === myId ? "🟢" : "⚪"} + {p.name} + {p.isOwner && 👑} + {p.isReady && } +
  • + ))} +
+
+ +
+ {/* オーナー開始操作と待機表示の分岐 */} + {isMeOwner ? ( + + ) : ( +

オーナーがゲームを開始するのを待っています...

+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/apps/client/src/scenes/title/TitleScene.tsx b/apps/client/src/scenes/title/TitleScene.tsx new file mode 100644 index 0000000..d475c08 --- /dev/null +++ b/apps/client/src/scenes/title/TitleScene.tsx @@ -0,0 +1,64 @@ +import { useState } from "react"; +// ルーム参加時送信ペイロード型 +import type { roomTypes } from "@repo/shared"; + +type Props = { + // 入室実行時呼び出しコールバック + onJoin: (payload: roomTypes.JoinRoomPayload) => void; +}; + +export const TitleScene = ({ onJoin }: Props) => { + // プレイヤー名入力値 + const [playerName, setPlayerName] = useState(""); + // ルームID入力値 + const [roomIdInput, setRoomIdInput] = useState(""); + + // 入室ボタン活性条件 + const canJoin = playerName !== "" && roomIdInput !== ""; + + // 入室実行ハンドラ + const handleJoin = () => { + if (canJoin) { + onJoin({ roomId: roomIdInput, playerName }); + } + }; + + return ( +
+

Pixel Paint War

+ +
+ setPlayerName(e.target.value)} + style={{ padding: "12px", fontSize: "1.2rem", borderRadius: "5px", border: "none" }} + /> + setRoomIdInput(e.target.value)} + style={{ padding: "12px", fontSize: "1.2rem", borderRadius: "5px", border: "none" }} + /> +
+ + +
+ ); +}; \ No newline at end of file diff --git a/apps/client/tsconfig.app.json b/apps/client/tsconfig.app.json index ed628d5..20bf754 100644 --- a/apps/client/tsconfig.app.json +++ b/apps/client/tsconfig.app.json @@ -17,6 +17,11 @@ // ↓ ここが重要:React用に設定 "jsx": "react-jsx", + + "baseUrl": ".", + "paths": { + "@client/*": ["src/*"] + }, // ⚠️ 削除したもの: // - "paths": { ... } (Preactへの誘導を削除) diff --git a/apps/client/vite.config.ts b/apps/client/vite.config.ts index 04fb3d8..6af4a54 100644 --- a/apps/client/vite.config.ts +++ b/apps/client/vite.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vite' +import path from 'node:path' import react from '@vitejs/plugin-react' import { config } from '@repo/shared' @@ -8,6 +9,11 @@ return { plugins: [react()], + resolve: { + alias: { + '@client': path.resolve(__dirname, 'src'), + }, + }, server: isProd ? undefined : { diff --git a/apps/server/src/domains/game/GameHandler.ts b/apps/server/src/domains/game/GameHandler.ts index 98a7f8f..2a45b4e 100644 --- a/apps/server/src/domains/game/GameHandler.ts +++ b/apps/server/src/domains/game/GameHandler.ts @@ -1,7 +1,7 @@ import { Server, Socket } from "socket.io"; import { GameManager } from "./GameManager"; import { RoomManager } from "../room/RoomManager"; -import { protocol, RoomStatus } from "@repo/shared"; +import { protocol, roomConsts } from "@repo/shared"; import type { playerTypes } from "@repo/shared"; export const registerGameHandlers = (io: Server, socket: Socket, gameManager: GameManager, roomManager: RoomManager) => { @@ -19,7 +19,7 @@ return; } - if (room.status === RoomStatus.PLAYING) { + if (room.status === roomConsts.RoomPhase.PLAYING) { console.log("[GameHandler] START_GAME ignored (already playing)", { roomId: room.roomId }); return; } @@ -31,7 +31,7 @@ }); if (room) { - room.status = RoomStatus.PLAYING; + room.status = roomConsts.RoomPhase.PLAYING; const playerIds = room.players.map((p: { id: string }) => p.id); @@ -60,7 +60,7 @@ // 3分経過時に GameLoop から呼ばれる処理 console.log(`[GameHandler] ルーム ${room.roomId} のゲームが終了しました (3分経過)`); io.to(room.roomId).emit(protocol.SocketEvents.GAME_END); // クライアントへ終了通知 - room.status = RoomStatus.WAITING; // ルーム状態を待機に戻す + room.status = roomConsts.RoomPhase.WAITING; // ルーム状態を待機に戻す } ); diff --git a/apps/server/src/domains/room/RoomManager.ts b/apps/server/src/domains/room/RoomManager.ts index efe0b7b..48832ec 100644 --- a/apps/server/src/domains/room/RoomManager.ts +++ b/apps/server/src/domains/room/RoomManager.ts @@ -1,25 +1,25 @@ -import { RoomStatus, config } from "@repo/shared"; -import type { Room, RoomMember } from "@repo/shared"; +import { roomConsts, config } from "@repo/shared"; +import type { roomTypes } from "@repo/shared"; export class RoomManager { - private rooms: Map = new Map(); + private rooms: Map = new Map(); // ルームにプレイヤーを追加(なければ作成) - public addPlayerToRoom(roomId: string, socketId: string, playerName: string): Room { + public addPlayerToRoom(roomId: string, socketId: string, playerName: string): roomTypes.Room { let room = this.rooms.get(roomId); if (!room) { room = { roomId: roomId, ownerId: socketId, players: [], - status: RoomStatus.WAITING, + status: roomConsts.RoomPhase.WAITING, maxPlayers: config.GAME_CONFIG.MAX_PLAYERS_PER_ROOM }; this.rooms.set(roomId, room); console.log("[RoomManager] created room", { roomId, ownerId: socketId }); } - const newPlayer: RoomMember = { + const newPlayer: roomTypes.RoomMember = { id: socketId, name: playerName, isOwner: room.ownerId === socketId, @@ -37,8 +37,8 @@ } // プレイヤーをルームから削除し、更新があったルームの配列を返す - public removePlayer(socketId: string): Room[] { - const updatedRooms: Room[] = []; + public removePlayer(socketId: string): roomTypes.Room[] { + const updatedRooms: roomTypes.Room[] = []; for (const [roomId, room] of this.rooms.entries()) { const playerIndex = room.players.findIndex(p => p.id === socketId); @@ -72,7 +72,7 @@ } // オーナーIDからルームを取得 - public getRoomByOwnerId(ownerId: string): Room | undefined { + public getRoomByOwnerId(ownerId: string): roomTypes.Room | undefined { for (const room of this.rooms.values()) { if (room.ownerId === ownerId) { return room; diff --git a/packages/shared/src/domains/app/app.const.ts b/packages/shared/src/domains/app/app.const.ts new file mode 100644 index 0000000..f8beb8d --- /dev/null +++ b/packages/shared/src/domains/app/app.const.ts @@ -0,0 +1,9 @@ +import type { ScenePhase as ScenePhaseType } from "./app.type"; + +// クライアント画面遷移利用フェーズの値 +export const ScenePhase = { + TITLE: "title", + LOBBY: "lobby", + PLAYING: "playing", + RESULT: "result", +} as const satisfies Record; diff --git a/packages/shared/src/domains/app/app.type.ts b/packages/shared/src/domains/app/app.type.ts new file mode 100644 index 0000000..83777d0 --- /dev/null +++ b/packages/shared/src/domains/app/app.type.ts @@ -0,0 +1,2 @@ +// クライアント画面遷移利用フェーズ型 +export type ScenePhase = "title" | "lobby" | "playing" | "result"; diff --git a/packages/shared/src/domains/room/room.const.ts b/packages/shared/src/domains/room/room.const.ts index a4a757f..bf0f189 100644 --- a/packages/shared/src/domains/room/room.const.ts +++ b/packages/shared/src/domains/room/room.const.ts @@ -1,15 +1,8 @@ -import type { GameState as GameStateType, RoomStatus as RoomStatusType } from "./room.type"; - -// クライアント画面遷移利用ゲーム状態の値 -export const GameState = { - TITLE: "title", - LOBBY: "lobby", - PLAYING: "playing", -} as const satisfies Record; +import type { RoomPhase as RoomPhaseType } from "./room.type"; // ルーム進行フェーズ状態の値 -export const RoomStatus = { +export const RoomPhase = { WAITING: "waiting", PLAYING: "playing", RESULT: "result", -} as const satisfies Record; +} as const satisfies Record; diff --git a/packages/shared/src/domains/room/room.type.ts b/packages/shared/src/domains/room/room.type.ts index e97dd65..4c1ac67 100644 --- a/packages/shared/src/domains/room/room.type.ts +++ b/packages/shared/src/domains/room/room.type.ts @@ -1,8 +1,5 @@ -// クライアント画面遷移利用ゲーム状態型 -export type GameState = "title" | "lobby" | "playing"; - // ルーム進行フェーズ状態型 -export type RoomStatus = "waiting" | "playing" | "result"; +export type RoomPhase = "waiting" | "playing" | "result"; // ルーム所属プレイヤー情報型 export interface RoomMember { @@ -17,7 +14,7 @@ roomId: string; ownerId: string; players: RoomMember[]; - status: RoomStatus; + status: RoomPhase; maxPlayers: number; } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 70c8d84..266bcd1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,14 +2,9 @@ export * as gridMapTypes from "./domains/gridMap/gridMap.type"; export * as gridMapLogic from "./domains/gridMap/gridMap.logic"; export * as playerTypes from "./domains/player/player.type"; +export * as appTypes from "./domains/app/app.type"; +export * as appConsts from "./domains/app/app.const"; export * as roomTypes from "./domains/room/room.type"; -export { GameState, RoomStatus } from "./domains/room/room.const"; -export type { - GameState as GameStateType, - RoomStatus as RoomStatusType, - Room, - RoomMember, - JoinRoomPayload, -} from "./domains/room/room.type"; +export * as roomConsts from "./domains/room/room.const"; export * as protocol from "./protocol/events"; export * as config from "./config"; \ No newline at end of file