diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index ea6e82f..6392e30 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { socketClient } from "./network/SocketClient"; -// シーン(画面)コンポーネント +// 画面遷移先シーンコンポーネント群 import { TitleScene } from "./scenes/TitleScene"; import { LobbyScene } from "./scenes/LobbyScene"; import { GameScene } from "./scenes/GameScene"; @@ -9,10 +9,14 @@ import { GameState, type Room } from "@repo/shared/src/types/room"; 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) => { @@ -22,15 +26,16 @@ socketClient.onGameStart(() => setGameState(GameState.PLAYING)); }, []); - // レンダリング分岐 + // タイトル画面分岐 if (gameState === GameState.TITLE) { return socketClient.joinRoom(payload.roomId, payload.playerName)} />; } + // ロビー画面分岐 if (gameState === GameState.LOBBY) { return socketClient.startGame()} />; } - // playing 状態なら GameScene をレンダリング + // プレイ画面分岐 return ; } \ No newline at end of file diff --git a/apps/client/src/entities/GameMap.ts b/apps/client/src/entities/GameMap.ts index a5158ba..79577ff 100644 --- a/apps/client/src/entities/GameMap.ts +++ b/apps/client/src/entities/GameMap.ts @@ -1,27 +1,30 @@ import { Graphics } from "pixi.js"; import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; +// マップ背景・グリッド・外枠の一括描画オブジェクト export class GameMap extends Graphics { constructor() { super(); this.drawMap(); } + // 設定値参照によるマップ外観組み立て処理 private drawMap() { const { MAP_WIDTH, MAP_HEIGHT } = GAME_CONFIG; - // 背景 + // マップ全域背景レイヤー this.rect(0, 0, MAP_WIDTH, MAP_HEIGHT).fill(0x111111); - // グリッド線 + // 100px 間隔縦方向グリッド線 for (let x = 0; x <= MAP_WIDTH; x += 100) { this.moveTo(x, 0).lineTo(x, MAP_HEIGHT).stroke({ width: 1, color: 0x333333 }); } + // 100px 間隔横方向グリッド線 for (let y = 0; y <= MAP_HEIGHT; y += 100) { this.moveTo(0, y).lineTo(MAP_WIDTH, y).stroke({ width: 1, color: 0x333333 }); } - // 外枠 + // プレイ領域外枠 this.rect(0, 0, MAP_WIDTH, MAP_HEIGHT).stroke({ width: 5, color: 0xff4444 }); } } \ No newline at end of file diff --git a/apps/client/src/entities/Player.ts b/apps/client/src/entities/Player.ts index 357b8c7..428fe12 100644 --- a/apps/client/src/entities/Player.ts +++ b/apps/client/src/entities/Player.ts @@ -1,27 +1,28 @@ import { Graphics } from 'pixi.js'; import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; +// プレイヤー見た目描画と移動処理の統合オブジェクト export class Player extends Graphics { constructor(color: number | string = 0xFF0000, isMe: boolean = false) { super(); const { PLAYER_RADIUS } = GAME_CONFIG; - // 文字列("#FF0000"等)で来た場合にも対応 + // 文字列色指定の Pixi 数値色形式変換 const hexColor = typeof color === "string" ? parseInt(color.replace("#", "0x"), 16) : color; - // 本体の描画 + // プレイヤー本体円描画 this.circle(0, 0, PLAYER_RADIUS).fill(hexColor); - // 自分自身の場合は目印の枠線を追加 + // 自身プレイヤー識別用外周ライン if (isMe) { this.circle(0, 0, PLAYER_RADIUS).stroke({ width: 3, color: 0xffff00 }); } } - // 移動メソッド(壁ドン判定はMAPのサイズを使用) + // 入力ベクトルと経過時間基準の座標更新処理 move(vx: number, vy: number, deltaTime: number) { const { PLAYER_SPEED, MAP_WIDTH, MAP_HEIGHT, PLAYER_RADIUS } = GAME_CONFIG; @@ -29,7 +30,7 @@ this.x += vx * speed; this.y += vy * speed; - // はみ出し防止(半径分を考慮) + // 半径考慮の境界内クランプ処理 this.x = Math.max(PLAYER_RADIUS, Math.min(MAP_WIDTH - PLAYER_RADIUS, this.x)); this.y = Math.max(PLAYER_RADIUS, Math.min(MAP_HEIGHT - PLAYER_RADIUS, this.y)); } diff --git a/apps/client/src/index.css b/apps/client/src/index.css index 08a3ac9..49a9f22 100644 --- a/apps/client/src/index.css +++ b/apps/client/src/index.css @@ -1,3 +1,4 @@ +/* アプリ全体共通ベーススタイル */ :root { font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; @@ -22,6 +23,7 @@ color: #535bf2; } +/* キャンバス中央配置向け基本レイアウト */ body { margin: 0; display: flex; diff --git a/apps/client/src/input/VirtualJoystick.tsx b/apps/client/src/input/VirtualJoystick.tsx index 0890cda..90928a1 100644 --- a/apps/client/src/input/VirtualJoystick.tsx +++ b/apps/client/src/input/VirtualJoystick.tsx @@ -1,15 +1,20 @@ import { useState } from "react"; -export const MAX_DIST = 60; // ジョイスティックの可動範囲 +// ジョイスティック最大入力距離 +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) => { @@ -35,14 +40,15 @@ const moveY = Math.sin(angle) * limitedDist; setStickPos({ x: moveX, y: moveY }); - // app.tsx に計算結果だけを渡す + // 距離制限後入力ベクトル通知 onMove(moveX, moveY); }; const handleEnd = () => { setIsMoving(false); setStickPos({ x: 0, y: 0 }); - onMove(0, 0); // 👈 追加: 指を離したら移動量 0,0 を伝える + // 入力終了時停止ベクトル通知 + onMove(0, 0); }; return ( @@ -60,7 +66,8 @@ left: 0, width: "100%", height: "100%", - zIndex: 10, // キャンバスの上に透明なタッチエリアをかぶせる + // キャンバス前面入力キャプチャレイヤー + zIndex: 10, touchAction: "none", }} > diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 1b7c45c..e799399 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -1,8 +1,9 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import App from './app.tsx' // 小文字のままでOK +import App from './app.tsx' import './index.css' +// React ルートマウント処理 ReactDOM.createRoot(document.getElementById('app')!).render( diff --git a/apps/client/src/network/SocketClient.ts b/apps/client/src/network/SocketClient.ts index 45e863a..bdd00cd 100644 --- a/apps/client/src/network/SocketClient.ts +++ b/apps/client/src/network/SocketClient.ts @@ -3,66 +3,62 @@ import type { PlayerData } from "@repo/shared/src/types/player"; /** - * サーバーとのWebSocket通信を管理するクライアントクラス + * サーバー WebSocket 通信管理クラス */ export class SocketClient { public socket: Socket; constructor() { - // サーバーへの接続を開始 + // Socket.io クライアント接続初期化 this.socket = io(); } /** - * 1. 接続完了イベントの購読 - * @param callback 接続成功時に自身のソケットIDを受け取る関数 + * 接続完了イベント購読 + * @param callback 接続済みソケットID受け取りコールバック */ onConnect(callback: (id: string) => void) { - // 🌟 追加: 呼び出された時点ですでに接続済みの場合は、即座にコールバックを実行する + // 接続済み状態での即時通知 if (this.socket.connected) { callback(this.socket.id || ""); } - // まだ接続されていない場合、または再接続された時のためにイベントリスナーも登録しておく + // 初回接続・再接続イベント購読 this.socket.on(SocketEvents.CONNECT, () => { callback(this.socket.id || ""); }); } /** - * 2. 初期プレイヤー一覧の受信 - * ゲーム参加時、既に存在する全プレイヤーのデータを受け取る + * 初期プレイヤー一覧受信イベント購読 */ onCurrentPlayers(callback: (players: any) => void) { this.socket.on(SocketEvents.CURRENT_PLAYERS, callback); } /** - * 3. 新規プレイヤー参加イベントの購読 - * 他の誰かが新しく入室したときに通知される + * 新規プレイヤー参加イベント購読 */ onNewPlayer(callback: (player: PlayerData) => void) { this.socket.on(SocketEvents.NEW_PLAYER, callback); } /** - * 4. 他プレイヤーの状態更新の受信 - * 他のプレイヤーの移動などの座標データを受け取る + * 他プレイヤー状態更新イベント購読 */ onUpdatePlayer(callback: (data: any) => void) { this.socket.on(SocketEvents.UPDATE_PLAYER, callback); } /** - * 5. プレイヤー退出イベントの購読 - * 他のプレイヤーが切断したときに通知される + * プレイヤー退出イベント購読 */ onRemovePlayer(callback: (id: string) => void) { this.socket.on(SocketEvents.REMOVE_PLAYER, callback); } /** - * 6. 自身の移動データを送信 + * 自身移動データ送信 * @param x 現在のX座標 * @param y 現在のY座標 */ @@ -71,7 +67,7 @@ } /** - * 7. 特定のルームへの入室リクエスト + * ルーム入室リクエスト送信 * @param roomId 入室先のID * @param playerName 表示名 */ @@ -80,37 +76,33 @@ } /** - * 8. ルーム情報の更新を受信 - * ルーム内の人数や準備状況が変わるたびにサーバーから送られてくる + * ルーム情報更新イベント購読 */ onRoomUpdate(callback: (room: any) => void) { this.socket.on(SocketEvents.ROOM_UPDATE, callback); } /** - * 9. ゲーム開始通知の受信 - * サーバー側でゲーム開始が確定したときに呼ばれる + * ゲーム開始通知イベント購読 */ onGameStart(callback: () => void) { this.socket.on(SocketEvents.GAME_START, callback); } /** - * 10. ゲーム開始リクエスト - * ルームオーナーがゲームを開始させる際に送信する + * ゲーム開始リクエスト送信 */ startGame() { this.socket.emit(SocketEvents.START_GAME); } /** - * 11. ゲーム画面準備完了の通知 - * シーン遷移が完了し、データを受け取る準備ができたことをサーバーに伝える + * ゲーム画面準備完了通知 */ readyForGame() { this.socket.emit(SocketEvents.READY_FOR_GAME); } } -// シングルトンインスタンスとしてエクスポート +// シングルトン利用向け共有インスタンス export const socketClient = new SocketClient(); \ No newline at end of file diff --git a/apps/client/src/scenes/GameScene.tsx b/apps/client/src/scenes/GameScene.tsx index 9c784d9..20b6e13 100644 --- a/apps/client/src/scenes/GameScene.tsx +++ b/apps/client/src/scenes/GameScene.tsx @@ -1,13 +1,13 @@ import { useEffect, useRef } from "react"; import { Application, Container } from "pixi.js"; -// ネットワーク・入力 +// ネットワーク・入力関連 import { socketClient} from "../network/SocketClient"; import { VirtualJoystick, MAX_DIST } from "../input/VirtualJoystick"; import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; import { type PlayerData } from "@repo/shared/src/types/player"; -// ゲームオブジェクト +// ゲーム描画オブジェクト import { GameMap } from "../entities/GameMap"; import { Player } from "../entities/Player"; @@ -16,33 +16,41 @@ } /** - * メインのゲーム画面コンポーネント - * PixiJSの初期化、エンティティの管理、ソケット通信の同期を行う + * メインゲーム画面コンポーネント + * PixiJS 初期化・エンティティ管理・ソケット同期処理 */ export function GameScene({ myId }: GameSceneProps) { const pixiContainerRef = useRef(null); - const joystickInputRef = useRef({ x: 0, y: 0 }); // ジョイスティックの入力値を保持 - const playersRef = useRef>({}); // 全プレイヤーのスプライト参照 - const lastPositionSentTimeRef = useRef(0); // サーバーへの位置送信タイミングを制御 - const targetPositionsRef = useRef>({}); // 他プレイヤーの目標位置(補完用) - const wasMovingRef = useRef(false); // 移動状態の変化を検知するためのフラグ + // ジョイスティック入力値 + const joystickInputRef = useRef({ x: 0, y: 0 }); + // プレイヤースプライト参照テーブル + const playersRef = useRef>({}); + // 位置送信間隔制御用タイムスタンプ + const lastPositionSentTimeRef = useRef(0); + // 他プレイヤー補間用目標座標テーブル + const targetPositionsRef = useRef>({}); + // 前フレーム移動状態 + const wasMovingRef = useRef(false); useEffect(() => { if (!pixiContainerRef.current) return; - let isCancelled = false; // 非同期処理中のクリーンアップ判定用 + // 非同期初期化中アンマウント判定フラグ + let isCancelled = false; + // Pixi 初期化完了フラグ let isInitialized = false; const app = new Application(); - const worldContainer = new Container(); // カメラ移動を実現するための親コンテナ + // カメラ追従向けワールド親コンテナ + const worldContainer = new Container(); const initPixi = async () => { - // マップを一番下のレイヤー(背景)として追加 + // 背景マップ最背面配置 const gameMap = new GameMap(); worldContainer.addChild(gameMap); - // --- ソケットイベントリスナーの設定 --- + // ソケットイベント購読登録 - // 接続時:既存の全プレイヤーを生成 + // 参加済みプレイヤー初期スプライト生成 socketClient.onCurrentPlayers((serverPlayers: PlayerData[] | Record) => { console.log("🔥 プレイヤー一覧を受信:", serverPlayers); const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as PlayerData[]; @@ -56,7 +64,7 @@ }); }); - // 新規参加:新しいプレイヤーを画面に追加 + // 新規参加プレイヤー追加処理 socketClient.onNewPlayer((p: PlayerData) => { console.log("🔥 新規プレイヤー参加:", p); const playerSprite = new Player(GAME_CONFIG.TEAM_COLORS[p.teamId], false); @@ -66,7 +74,7 @@ targetPositionsRef.current[p.id] = { x: p.x, y: p.y }; }); - // 更新:他プレイヤーの移動目標地点を更新 + // 他プレイヤー目標座標更新 socketClient.onUpdatePlayer((data: Partial & { id: string }) => { if (data.id === myId) return; @@ -77,7 +85,7 @@ } }); - // 退出:プレイヤーを削除してメモリを解放 + // 退出プレイヤー参照削除とオブジェクト破棄 socketClient.onRemovePlayer((id: string) => { const target = playersRef.current[id]; if (target) { @@ -88,50 +96,51 @@ } }); - // PixiJS本体の初期化 + // PixiJS 本体初期化 await app.init({ resizeTo: window, backgroundColor: 0x111111, antialias: true }); isInitialized = true; - // 🚨 もし初期化を待っている間にReactがアンマウントされていたら、ここで破棄して終了する + // 初期化待機中アンマウント時の即時破棄分岐 if (isCancelled) { app.destroy(true, { children: true }); return; } + // ルートステージへのワールド追加 pixiContainerRef.current?.appendChild(app.canvas); app.stage.addChild(worldContainer); - // 全ての準備が整ったことをサーバーに通知 + // 画面準備完了通知送信 socketClient.readyForGame(); - // --- メインゲームループ(Ticker) --- + // メインゲームループ登録 app.ticker.add((ticker) => { if (!myId) return; const me = playersRef.current[myId]; if (!me) return; - // 自分の移動処理 + // 自プレイヤー移動処理 const { x: dx, y: dy } = joystickInputRef.current; const isMoving = dx !== 0 || dy !== 0; if (isMoving) { me.move(dx / MAX_DIST, dy / MAX_DIST, ticker.deltaTime); - // 通信負荷軽減のため、一定間隔でのみサーバーへ位置を送信 + // 通信負荷抑制向け間引き送信 const now = performance.now(); if (now - lastPositionSentTimeRef.current >= GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { socketClient.sendMove(me.x, me.y); lastPositionSentTimeRef.current = now; } } else if (wasMovingRef.current) { - // 💡 止まった瞬間の確定座標を1回だけ送信して他プレイヤーとのズレを防ぐ + // 停止瞬間の確定座標単発送信 socketClient.sendMove(me.x, me.y); } - // 今回の移動状態を次回ループのために保存 + // 次フレーム比較用移動状態保存 wasMovingRef.current = isMoving; - // 他プレイヤーの線形補間(Lerp)処理と吸着(Snap) + // 他プレイヤー座標の線形補間と閾値吸着 Object.entries(playersRef.current).forEach(([id, player]) => { if (id === myId) return; @@ -140,14 +149,14 @@ const diffX = targetPos.x - player.x; const diffY = targetPos.y - player.y; - // X軸の補完と吸着 + // X軸方向補間と吸着 if (Math.abs(diffX) < GAME_CONFIG.PLAYER_LERP_SNAP_THRESHOLD) { player.x = targetPos.x; } else { player.x += diffX * GAME_CONFIG.PLAYER_LERP_SMOOTHNESS * ticker.deltaTime; } - // Y軸の補完と吸着 + // Y軸方向補間と吸着 if (Math.abs(diffY) < GAME_CONFIG.PLAYER_LERP_SNAP_THRESHOLD) { player.y = targetPos.y; } else { @@ -156,7 +165,7 @@ } }); - // カメラ追従:自分を中心に世界を逆方向にずらす + // 自プレイヤー中心表示向けワールド逆方向オフセット worldContainer.position.set( -(me.x - app.screen.width / 2), -(me.y - app.screen.height / 2) @@ -166,7 +175,7 @@ initPixi(); - // クリーンアップ処理 (コンポーネントのアンマウント時に実行) + // コンポーネント破棄時クリーンアップ return () => { isCancelled = true; @@ -176,20 +185,20 @@ playersRef.current = {}; - // メモリリーク防止のためソケットのイベント登録を解除 + // メモリリーク防止向けイベント購読解除 socketClient.socket.off("current_players"); socketClient.socket.off("new_player"); socketClient.socket.off("update_player"); socketClient.socket.off("remove_player"); }; - }, [myId]); // 自分のIDが確定したタイミングで再実行 + }, [myId]); return (
- {/* PixiJSのCanvasが挿入される要素 */} + {/* PixiJS Canvas 配置領域 */}
- {/* UIレイヤー:ジョイスティック等 */} + {/* 入力UI重畳用前面レイヤー */}
{ joystickInputRef.current = { x, y }; }} />
diff --git a/apps/client/src/scenes/LobbyScene.tsx b/apps/client/src/scenes/LobbyScene.tsx index f5db60d..a8b7b3c 100644 --- a/apps/client/src/scenes/LobbyScene.tsx +++ b/apps/client/src/scenes/LobbyScene.tsx @@ -1,4 +1,4 @@ -import type { Room } from "@repo/shared/src/types/room"; // パスは適宜調整してください +import type { Room } from "@repo/shared/src/types/room"; type Props = { room: Room | null; @@ -7,10 +7,13 @@ }; export const LobbyScene = ({ room, myId, onStart }: Props) => { + // ルーム情報到着前ローディング表示 if (!room) return
読み込み中...
; + // 自身オーナー権限判定 const isMeOwner = room.ownerId === myId; + // ロビー画面本体 return (

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

@@ -20,6 +23,7 @@ 参加プレイヤー ({room.players.length}/{room.maxPlayers})
    + {/* 参加プレイヤー一覧描画 */} {room.players.map((p) => (
  • {p.id === myId ? "🟢" : "⚪"} @@ -32,6 +36,7 @@
+ {/* オーナー開始操作と待機表示の分岐 */} {isMeOwner ? (