diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index d9d20ac..e4393d1 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -1,17 +1,10 @@ -import { useEffect, useState, useRef } from "react"; -import { Application, Container } from "pixi.js"; - -// ネットワーク・入力 -import { socketClient, type PlayerData } from "./network/SocketClient"; -import { VirtualJoystick, MAX_DIST } from "./input/VirtualJoystick"; +import { useEffect, useState } from "react"; +import { socketClient } from "./network/SocketClient"; // シーン(画面)コンポーネント import { TitleScene } from "./scenes/TitleScene"; import { LobbyScene } from "./scenes/LobbyScene"; - -// ゲームオブジェクト(純粋なPixiJSクラス) -import { GameMap } from "./entities/GameMap"; -import { Player } from "./entities/Player"; +import { GameScene } from "./scenes/GameScene"; // 👈 追加 import type { Room } from "@repo/shared/src/types/room"; @@ -20,10 +13,6 @@ const [room, setRoom] = useState(null); const [myId, setMyId] = useState(null); - const pixiContainerRef = useRef(null); - const joystickInputRef = useRef({ x: 0, y: 0 }); - const playersRef = useRef>({}); - useEffect(() => { socketClient.onConnect((id) => setMyId(id)); socketClient.onRoomUpdate((updatedRoom) => { @@ -33,119 +22,15 @@ socketClient.onGameStart(() => setGameState("playing")); }, []); - /** - * ゲーム本体(PixiJSの実行環境) - */ - useEffect(() => { - if (gameState !== "playing" || !pixiContainerRef.current) return; + // レンダリング分岐 + if (gameState === "title") { + return socketClient.joinRoom(roomId, playerName)} />; + } + + if (gameState === "lobby") { + return socketClient.startGame()} />; + } - let isCancelled = false; - const app = new Application(); - 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[]; - playersArray.forEach((p) => { - const isMe = p.id === myId; - const playerSprite = new Player(p.color, isMe); - playerSprite.position.set(p.x, p.y); - worldContainer.addChild(playerSprite); - playersRef.current[p.id] = playerSprite; - }); - }); - - socketClient.onNewPlayer((p: PlayerData) => { - console.log("🔥 新規プレイヤー参加:", p); - const playerSprite = new Player(p.color, false); - playerSprite.position.set(p.x, p.y); - worldContainer.addChild(playerSprite); - playersRef.current[p.id] = playerSprite; - }); - - socketClient.onUpdatePlayer((data: Partial & { id: string }) => { - if (data.id === myId) return; - const target = playersRef.current[data.id]; - if (target) { - if (data.x !== undefined) target.x = data.x; - if (data.y !== undefined) target.y = data.y; - } - }); - - socketClient.onRemovePlayer((id: string) => { - const target = playersRef.current[id]; - if (target) { - worldContainer.removeChild(target); - target.destroy(); - delete playersRef.current[id]; - } - }); - - // データの受け取り口を作った後で、ゆっくりPixiを初期化する - await app.init({ resizeTo: window, backgroundColor: 0x111111, antialias: true }); - if (isCancelled) 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; - - if (dx !== 0 || dy !== 0) { - me.move(dx / MAX_DIST, dy / MAX_DIST, ticker.deltaTime); - socketClient.sendMove(me.x, me.y); - } - - // カメラ追従 - worldContainer.position.set( - -(me.x - window.innerWidth / 2), - -(me.y - window.innerHeight / 2) - ); - }); - }; - - initPixi(); - - // 🚨 【修正ポイント2】 クリーンアップ処理を追加 - return () => { - isCancelled = true; - app.destroy(true, { children: true }); - playersRef.current = {}; - - // コンポーネントが破棄される時に、ソケットの受信設定も解除する(重複防止) - socketClient.socket.off("current_players"); - socketClient.socket.off("new_player"); - socketClient.socket.off("update_player"); - socketClient.socket.off("remove_player"); - }; - }, [gameState, myId]); - - // レンダリング分岐(省略:TitleScene / LobbyScene) - if (gameState === "title") return socketClient.joinRoom(roomId, playerName)} />; - if (gameState === "lobby") return socketClient.startGame()} />; - - return ( -
-
-
- {/* ジョイスティックはReact UIとして管理 */} - { joystickInputRef.current = { x, y }; }} /> -
-
- ); + // playing 状態なら GameScene をレンダリング + return ; } \ No newline at end of file diff --git a/apps/client/src/scenes/GameScene.tsx b/apps/client/src/scenes/GameScene.tsx new file mode 100644 index 0000000..d425e65 --- /dev/null +++ b/apps/client/src/scenes/GameScene.tsx @@ -0,0 +1,197 @@ +import { useEffect, useRef } from "react"; +import { Application, Container } from "pixi.js"; + +// ネットワーク・入力 +import { socketClient, type PlayerData } from "../network/SocketClient"; +import { VirtualJoystick, MAX_DIST } from "../input/VirtualJoystick"; +import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; + +// ゲームオブジェクト +import { GameMap } from "../entities/GameMap"; +import { Player } from "../entities/Player"; + +interface GameSceneProps { + myId: string | null; +} + +/** + * メインのゲーム画面コンポーネント + * 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); // 移動状態の変化を検知するためのフラグ + + useEffect(() => { + if (!pixiContainerRef.current) return; + + let isCancelled = false; // 非同期処理中のクリーンアップ判定用 + let isInitialized = false; + const app = new Application(); + 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[]; + playersArray.forEach((p) => { + const isMe = p.id === myId; + const playerSprite = new Player(p.color, isMe); + playerSprite.position.set(p.x, p.y); + worldContainer.addChild(playerSprite); + playersRef.current[p.id] = playerSprite; + targetPositionsRef.current[p.id] = { x: p.x, y: p.y }; + }); + }); + + // 新規参加:新しいプレイヤーを画面に追加 + socketClient.onNewPlayer((p: PlayerData) => { + console.log("🔥 新規プレイヤー参加:", p); + const playerSprite = new Player(p.color, false); + playerSprite.position.set(p.x, p.y); + worldContainer.addChild(playerSprite); + playersRef.current[p.id] = playerSprite; + targetPositionsRef.current[p.id] = { x: p.x, y: p.y }; + }); + + // 更新:他プレイヤーの移動目標地点を更新 + socketClient.onUpdatePlayer((data: Partial & { id: string }) => { + if (data.id === myId) return; + + const targetPos = targetPositionsRef.current[data.id]; + if (targetPos) { + if (data.x !== undefined) targetPos.x = data.x; + if (data.y !== undefined) targetPos.y = data.y; + } + }); + + // 退出:プレイヤーを削除してメモリを解放 + socketClient.onRemovePlayer((id: string) => { + const target = playersRef.current[id]; + if (target) { + worldContainer.removeChild(target); + target.destroy(); + delete playersRef.current[id]; + delete targetPositionsRef.current[id]; + } + }); + + // 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; + + const targetPos = targetPositionsRef.current[id]; + if (targetPos) { + const diffX = targetPos.x - player.x; + const diffY = targetPos.y - player.y; + + // 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軸の補完と吸着 + if (Math.abs(diffY) < GAME_CONFIG.PLAYER_LERP_SNAP_THRESHOLD) { + player.y = targetPos.y; + } else { + player.y += diffY * GAME_CONFIG.PLAYER_LERP_SMOOTHNESS * ticker.deltaTime; + } + } + }); + + // カメラ追従:自分を中心に世界を逆方向にずらす + worldContainer.position.set( + -(me.x - app.screen.width / 2), + -(me.y - app.screen.height / 2) + ); + }); + }; + + initPixi(); + + // クリーンアップ処理 (コンポーネントのアンマウント時に実行) + return () => { + isCancelled = true; + + if (isInitialized) { + app.destroy(true, { children: true }); + } + + playersRef.current = {}; + + // メモリリーク防止のためソケットのイベント登録を解除 + socketClient.socket.off("current_players"); + socketClient.socket.off("new_player"); + socketClient.socket.off("update_player"); + socketClient.socket.off("remove_player"); + }; + }, [myId]); // 自分のIDが確定したタイミングで再実行 + + return ( +
+ {/* PixiJSのCanvasが挿入される要素 */} +
+ + {/* UIレイヤー:ジョイスティック等 */} +
+ { joystickInputRef.current = { x, y }; }} /> +
+
+ ); +} \ No newline at end of file diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index a8c17aa..6ed6666 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -10,4 +10,9 @@ // プレイヤー設定 PLAYER_RADIUS: 10, // キャラの大きさ PLAYER_SPEED: 5, // 移動速度 (ピクセル/秒) + + // ネットワーク・描画設定 + PLAYER_POSITION_UPDATE_MS: 50, // 座標送信の間隔 (20Hz = 50ms) + PLAYER_LERP_SMOOTHNESS: 0.3, // 他プレイヤーの動きの滑らかさ (0.1〜0.5程度で調整) + PLAYER_LERP_SNAP_THRESHOLD: 0.5, // これ以下の距離になったら座標を強制的に合わせる } as const; \ No newline at end of file