diff --git a/apps/client/src/app.tsx b/apps/client/src/app.tsx index 3e30c73..d9d20ac 100644 --- a/apps/client/src/app.tsx +++ b/apps/client/src/app.tsx @@ -1,183 +1,151 @@ import { useEffect, useState, useRef } from "react"; -import { Stage, Container } from "@pixi/react"; +import { Application, Container } from "pixi.js"; -// ネットワーク・エンティティ・UIコンポーネントのインポート -import { socketClient } from "./network/SocketClient"; -import { GameMap } from "./entities/GameMap"; -import { PlayerSprite } from "./entities/PlayerSprite"; +// ネットワーク・入力 +import { socketClient, type PlayerData } from "./network/SocketClient"; import { VirtualJoystick, MAX_DIST } from "./input/VirtualJoystick"; // シーン(画面)コンポーネント import { TitleScene } from "./scenes/TitleScene"; import { LobbyScene } from "./scenes/LobbyScene"; -// 共有設定(マップサイズやプレイヤーの当たり判定サイズなど) -import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; +// ゲームオブジェクト(純粋なPixiJSクラス) +import { GameMap } from "./entities/GameMap"; +import { Player } from "./entities/Player"; + import type { Room } from "@repo/shared/src/types/room"; -const { MAP_WIDTH, MAP_HEIGHT, PLAYER_RADIUS } = GAME_CONFIG; - -// プレイヤーの型定義 -type Player = { id: string; x: number; y: number; color: string; }; - export default function App() { - /** - * 1. 状態管理 (State) - */ - // ゲームの現在のフェーズ(タイトル、ロビー、プレイ中) const [gameState, setGameState] = useState<"title" | "lobby" | "playing">("title"); const [room, setRoom] = useState(null); const [myId, setMyId] = useState(null); - const [players, setPlayers] = useState>({}); - const [viewport, setViewport] = useState({ w: window.innerWidth, h: window.innerHeight }); - /** - * 2. 参照管理 (Refs) - * 頻繁に更新される値(座標や入力)をStateに入れると再レンダリングが走りすぎるため、 - * useRef で保持して requestAnimationFrame 内で処理します。 - */ - const myPosRef = useRef({ x: MAP_WIDTH / 2, y: MAP_HEIGHT / 2 }); + const pixiContainerRef = useRef(null); const joystickInputRef = useRef({ x: 0, y: 0 }); + const playersRef = useRef>({}); - // ジョイスティックからの入力をRefに保存 - const handleJoystickMove = (moveX: number, moveY: number) => { - joystickInputRef.current = { x: moveX, y: moveY }; - }; - - /** - * 3. ゲームループ (Game Loop) - * プレイ中のみ動作し、自身の移動計算とサーバーへの送信を行います。 - */ useEffect(() => { - if (gameState !== "playing") return; - - let animationFrameId: number; - const tick = () => { - const { x: dx, y: dy } = joystickInputRef.current; - - // 入力がある場合のみ移動処理を実行 - if (myId && (dx !== 0 || dy !== 0)) { - const speed = 5.0; - // 入力値(0〜MAX_DIST)を正規化して座標を計算 - let nextX = myPosRef.current.x + (dx / MAX_DIST) * speed; - let nextY = myPosRef.current.y + (dy / MAX_DIST) * speed; - - // マップの境界線から出ないように制限(クランプ処理) - nextX = Math.max(PLAYER_RADIUS, Math.min(MAP_WIDTH - PLAYER_RADIUS, nextX)); - nextY = Math.max(PLAYER_RADIUS, Math.min(MAP_HEIGHT - PLAYER_RADIUS, nextY)); - - // ローカルの座標情報を更新 - myPosRef.current = { x: nextX, y: nextY }; - - // サーバーに最新の座標を送信 - socketClient.sendMove(nextX, nextY); - - // 自分の描画位置を即座に反映 - setPlayers((prev) => { - if (!prev[myId]) return prev; - return { ...prev, [myId]: { ...prev[myId], x: nextX, y: nextY } }; - }); - } - animationFrameId = requestAnimationFrame(tick); - }; - - animationFrameId = requestAnimationFrame(tick); - return () => cancelAnimationFrame(animationFrameId); - }, [myId, gameState]); - - /** - * 4. ネットワークイベントの購読 - */ - useEffect(() => { - // 接続時に自分のIDを保持 socketClient.onConnect((id) => setMyId(id)); - - // ルビーの状態更新を受け取ったらロビー画面へ socketClient.onRoomUpdate((updatedRoom) => { setRoom(updatedRoom); setGameState("lobby"); }); - - // ホストがゲームを開始した際の通知 - socketClient.onGameStart(() => { - setGameState("playing"); - }); - - // 初期プレイヤーリストの同期 - socketClient.onCurrentPlayers((serverPlayers: any) => { - const pMap: Record = {}; - if (Array.isArray(serverPlayers)) serverPlayers.forEach((p) => (pMap[p.id] = p)); - else Object.assign(pMap, serverPlayers); - setPlayers(pMap); - }); - - // 他プレイヤーの入室 - socketClient.onNewPlayer((p: Player) => setPlayers((prev) => ({ ...prev, [p.id]: p }))); - - // 他プレイヤーの移動反映(自分自身の更新は Game Loop で行うため除外) - socketClient.onUpdatePlayer((data: any) => { - if (data.id === socketClient.socket.id) return; - setPlayers((prev) => { - if (!prev[data.id]) return prev; - return { ...prev, [data.id]: { ...prev[data.id], ...data } }; - }); - }); - - // 他プレイヤーの退室 - socketClient.onRemovePlayer((id: string) => { - setPlayers((prev) => { - const next = { ...prev }; - delete next[id]; - return next; - }); - }); - - // ウィンドウサイズ変更への対応 - const handleResize = () => setViewport({ w: window.innerWidth, h: window.innerHeight }); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); + socketClient.onGameStart(() => setGameState("playing")); }, []); /** - * 5. レンダリング分岐(シーン切り替え) + * ゲーム本体(PixiJSの実行環境) */ - // タイトル画面 - if (gameState === "title") { - return socketClient.joinRoom(roomId, playerName)} />; - } + useEffect(() => { + if (gameState !== "playing" || !pixiContainerRef.current) return; - // ロビー画面 - if (gameState === "lobby") { - return socketClient.startGame()} />; - } + let isCancelled = false; + const app = new Application(); + const worldContainer = new Container(); - /** - * 6. ゲーム本編の描画 - */ - const me = myId ? players[myId] : null; - // カメラの座標計算:自分を中心に据えるよう計算(マップ端でも追従) - const camX = (me?.x ?? MAP_WIDTH / 2) - viewport.w / 2; - const camY = (me?.y ?? MAP_HEIGHT / 2) - viewport.h / 2; + 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 ( -
- {/* PixiJS の描画エリア */} -
- - {/* コンテナをカメラの逆方向に動かすことで「カメラ追従」を実現 */} - - {/* 背景マップ */} - - {/* 全プレイヤーの描画 */} - {Object.values(players).map((p) => ( - - ))} - - +
+
+
+ {/* ジョイスティックはReact UIとして管理 */} + { joystickInputRef.current = { x, y }; }} />
- - {/* UIレイヤー:ジョイスティック(Canvasの上に重ねて配置) */} -
); } \ No newline at end of file diff --git a/apps/client/src/entities/GameMap.ts b/apps/client/src/entities/GameMap.ts new file mode 100644 index 0000000..a5158ba --- /dev/null +++ b/apps/client/src/entities/GameMap.ts @@ -0,0 +1,27 @@ +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); + + // グリッド線 + for (let x = 0; x <= MAP_WIDTH; x += 100) { + this.moveTo(x, 0).lineTo(x, MAP_HEIGHT).stroke({ width: 1, color: 0x333333 }); + } + 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/GameMap.tsx b/apps/client/src/entities/GameMap.tsx deleted file mode 100644 index 1462420..0000000 --- a/apps/client/src/entities/GameMap.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useCallback } from "react"; -import { Graphics } from "@pixi/react"; -import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; - -export const GameMap = () => { - const draw = useCallback((g: any) => { - const { MAP_WIDTH, MAP_HEIGHT } = GAME_CONFIG; - - g.clear(); - // 背景 - g.beginFill(0x111111); - g.drawRect(0, 0, MAP_WIDTH, MAP_HEIGHT); - g.endFill(); - - // グリッド線 - g.lineStyle(1, 0x333333); - // 垂直線 - for (let x = 0; x <= MAP_WIDTH; x += 100) { - g.moveTo(x, 0).lineTo(x, MAP_HEIGHT); - } - // 水平線 - for (let y = 0; y <= MAP_HEIGHT; y += 100) { - g.moveTo(0, y).lineTo(MAP_WIDTH, y); - } - - // 外枠 - g.lineStyle(5, 0xff4444); - g.drawRect(0, 0, MAP_WIDTH, MAP_HEIGHT); - }, []); - - return ; -}; \ No newline at end of file diff --git a/apps/client/src/entities/Player.ts b/apps/client/src/entities/Player.ts index 4546dde..357b8c7 100644 --- a/apps/client/src/entities/Player.ts +++ b/apps/client/src/entities/Player.ts @@ -2,21 +2,35 @@ import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; export class Player extends Graphics { - constructor(color: number = 0xFF0000) { + constructor(color: number | string = 0xFF0000, isMe: boolean = false) { super(); - // 半径10の赤い丸を描く(直接数値を指定) - this.circle(0, 0, 10).fill(color); + + const { PLAYER_RADIUS } = GAME_CONFIG; + + // 文字列("#FF0000"等)で来た場合にも対応 + 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 speed = GAME_CONFIG.PLAYER_SPEED * deltaTime; + const { PLAYER_SPEED, MAP_WIDTH, MAP_HEIGHT, PLAYER_RADIUS } = GAME_CONFIG; + + const speed = PLAYER_SPEED * deltaTime; this.x += vx * speed; this.y += vy * speed; - // 画面外に出ないように制限 - this.x = Math.max(0, Math.min(window.innerWidth, this.x)); - this.y = Math.max(0, Math.min(window.innerHeight, this.y)); + // はみ出し防止(半径分を考慮) + 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)); } } \ No newline at end of file diff --git a/apps/client/src/entities/PlayerSprite.tsx b/apps/client/src/entities/PlayerSprite.tsx deleted file mode 100644 index 956c630..0000000 --- a/apps/client/src/entities/PlayerSprite.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { useCallback } from "react"; -import { Graphics } from "@pixi/react"; - -// 受け取るデータの型を定義 -type PlayerProps = { - x: number; - y: number; - color: string; - isMe: boolean; -}; - -export const PlayerSprite = ({ x, y, color, isMe }: PlayerProps) => { - const draw = useCallback( - (g: any) => { - g.clear(); - const hexColor = parseInt(color.replace("#", "0x"), 16) || 0xff0000; - - g.beginFill(hexColor); - g.drawCircle(0, 0, 10); - g.endFill(); - - // 自分の場合は黄色い枠線をつける - if (isMe) { - g.lineStyle(3, 0xffff00); - g.drawCircle(0, 0, 10); - } - }, - [color, isMe] - ); - - return ; -}; \ No newline at end of file diff --git a/apps/client/src/input/Joystick.ts b/apps/client/src/input/Joystick.ts deleted file mode 100644 index 4ec4640..0000000 --- a/apps/client/src/input/Joystick.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { Container, Graphics, Point } from 'pixi.js'; - -export class Joystick extends Container { - private outer: Graphics; - private inner: Graphics; - private pointerId: number | null = null; - private startPos: Point = new Point(); - private currentPos: Point = new Point(); - - // 入力ベクトル (x, y: -1.0 ~ 1.0) - public input: Point = new Point(0, 0); - - constructor() { - super(); - - // 外枠(グレーの丸) - this.outer = new Graphics().circle(0, 0, 60).fill({ color: 0xffffff, alpha: 0.3 }); - // 内側(白い丸) - this.inner = new Graphics().circle(0, 0, 30).fill({ color: 0xffffff, alpha: 0.8 }); - - this.addChild(this.outer); - this.addChild(this.inner); - - // 最初は非表示 - this.visible = false; - - // イベントリスナー設定 - window.addEventListener('pointerdown', this.onDown.bind(this)); - window.addEventListener('pointermove', this.onMove.bind(this)); - window.addEventListener('pointerup', this.onUp.bind(this)); - } - - private onDown(e: PointerEvent) { - // 画面の左半分だけ反応させるなどの制御もここで可能 - this.pointerId = e.pointerId; - this.startPos.set(e.clientX, e.clientY); - this.currentPos.copyFrom(this.startPos); - - // ジョイスティックを表示 - this.position.copyFrom(this.startPos); - this.inner.position.set(0, 0); - this.visible = true; - this.input.set(0, 0); - } - - private onMove(e: PointerEvent) { - if (this.pointerId !== e.pointerId) return; - - const maxDist = 60; //スティックが動ける最大半径 - const dx = e.clientX - this.startPos.x; - const dy = e.clientY - this.startPos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - - // 入力ベクトルの計算 - if (dist > 0) { - const scale = Math.min(dist, maxDist); - const angle = Math.atan2(dy, dx); - - // 内側の丸を移動 - this.inner.x = Math.cos(angle) * scale; - this.inner.y = Math.sin(angle) * scale; - - // 正規化された入力値 (-1.0 ~ 1.0) - this.input.x = this.inner.x / maxDist; - this.input.y = this.inner.y / maxDist; - } - } - - private onUp(e: PointerEvent) { - if (this.pointerId !== e.pointerId) return; - this.pointerId = null; - this.input.set(0, 0); - this.visible = false; - } -} diff --git a/apps/client/src/main.ts b/apps/client/src/main.ts deleted file mode 100644 index bb8cce3..0000000 --- a/apps/client/src/main.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Application } from 'pixi.js'; -import { Joystick } from './input/Joystick'; -import { Player } from './entities/Player'; -import { GAME_CONFIG } from '@repo/shared/src/config/gameConfig'; - -// アプリケーション初期化 -const app = new Application(); - -async function init() { - await app.init({ - resizeTo: window, - backgroundColor: 0x1099bb - }); - document.body.appendChild(app.canvas); - - // 1. ジョイスティック作成 - const joystick = new Joystick(); - - // 2. プレイヤー作成 - const player = new Player(0xFF4444); - player.x = app.screen.width / 2; - player.y = app.screen.height / 2; - - // 3. ステージに追加 - app.stage.addChild(player); - app.stage.addChild(joystick); // ジョイスティックは手前に表示 - - // 4. ゲームループ (毎フレーム実行) - app.ticker.add((ticker) => { - // 秒単位の経過時間 (Delta Time) - const dt = ticker.deltaTime / GAME_CONFIG.TARGET_FPS; - - // ジョイスティックの入力があれば移動 - if (joystick.input.x !== 0 || joystick.input.y !== 0) { - player.move(joystick.input.x, joystick.input.y, dt); - } - }); -} - -init(); \ No newline at end of file diff --git a/apps/client/src/network/SocketClient.ts b/apps/client/src/network/SocketClient.ts index c4fb5bf..6338614 100644 --- a/apps/client/src/network/SocketClient.ts +++ b/apps/client/src/network/SocketClient.ts @@ -69,6 +69,11 @@ startGame() { this.socket.emit("start-game"); } + + // 👇 11. 【新規追加】ゲーム画面の準備完了をサーバーに伝える + readyForGame() { + this.socket.emit("ready-for-game"); + } } export const socketClient = new SocketClient(); \ No newline at end of file diff --git a/apps/server/src/network/SocketManager.ts b/apps/server/src/network/SocketManager.ts index 9f51e19..5139c82 100644 --- a/apps/server/src/network/SocketManager.ts +++ b/apps/server/src/network/SocketManager.ts @@ -59,28 +59,33 @@ // 🚀 ゲーム開始イベント socket.on("start-game", () => { - // 自分がオーナーのルームを探す for (const [roomId, room] of this.rooms.entries()) { if (room.ownerId === socket.id) { room.status = 'playing'; - // 📢 全員に「ゲーム画面に切り替えて!」と指示 - this.io.to(roomId).emit("game-start"); - - // 🎮 ここで初めて全員を GameManager (PixiJSの世界) に追加する + // 🎮 全員を GameManager に追加する room.players.forEach(p => { this.gameManager.addPlayer(p.id); }); - // ゲーム用の初期データを送信 (参加した全員分の座標など) - // getAllPlayers() は Map を配列に変換して返すメソッドと仮定 - const allPlayers = Array.from(this.gameManager['players'].values()); - this.io.to(roomId).emit("current_players", allPlayers); + // 📢 全員に「ゲーム画面に切り替えて!」と指示 + this.io.to(roomId).emit("game-start"); + + // 🚨 【削除】ここでは current_players を送らない!(すれ違い防止) break; } } }); + // 🌟 【新規追加】 クライアントから「画面の準備完了」が通知されたらデータを送る + socket.on("ready-for-game", () => { + // 全プレイヤー情報を取得 + const allPlayers = this.gameManager.getAllPlayers(); + + // 準備が完了した「この通信主(socket)」に対してのみ初期データを送る + socket.emit("current_players", allPlayers); + }); + // ========================================== // 🎮 ゲームプレイ中のイベント // ========================================== diff --git a/packages/shared/src/config/gameConfig.ts b/packages/shared/src/config/gameConfig.ts index 4139e4c..a8c17aa 100644 --- a/packages/shared/src/config/gameConfig.ts +++ b/packages/shared/src/config/gameConfig.ts @@ -8,9 +8,6 @@ MAP_HEIGHT: 2000, // プレイヤー設定 - PLAYER_RADIUS: 20, // キャラの大きさ - PLAYER_SPEED: 300, // 移動速度 (ピクセル/秒) - - // システム設定 - TARGET_FPS: 60, // 基準となるFPS + PLAYER_RADIUS: 10, // キャラの大きさ + PLAYER_SPEED: 5, // 移動速度 (ピクセル/秒) } as const; \ No newline at end of file