diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index 0704e9d..bf71d0f 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -1,31 +1,15 @@ -import { useEffect, useState } from "react"; import { socketClient } from "./network/SocketClient"; +import { useGameFlow } from "./hooks/useGameFlow"; // 画面遷移先シーンコンポーネント群 -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"; 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 { gameState, room, myId } = useGameFlow(); // タイトル画面分岐 if (gameState === GameState.TITLE) { 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/useGameFlow.ts b/apps/client/src/hooks/useGameFlow.ts new file mode 100644 index 0000000..6b1fa3d --- /dev/null +++ b/apps/client/src/hooks/useGameFlow.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from "react"; +import { socketClient } from "../network/SocketClient"; +import { GameState } from "@repo/shared"; +import type { GameStateType, Room } from "@repo/shared"; + +type GameFlowState = { + gameState: GameStateType; + room: Room | null; + myId: string | null; +}; + +export const useGameFlow = (): GameFlowState => { + const [gameState, setGameState] = useState(GameState.TITLE); + const [room, setRoom] = useState(null); + const [myId, setMyId] = useState(null); + + useEffect(() => { + socketClient.onConnect((id) => setMyId(id)); + socketClient.onRoomUpdate((updatedRoom) => { + setRoom(updatedRoom); + setGameState(GameState.LOBBY); + }); + socketClient.onGameStart(() => setGameState(GameState.PLAYING)); + }, []); + + return { gameState, 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/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..e8b881d --- /dev/null +++ b/apps/client/src/scenes/game/GameManager.ts @@ -0,0 +1,187 @@ +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 "./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(); + + // サーバーへゲーム準備完了を通知 + 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/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