diff --git a/apps/client/src/entities/Player.ts b/apps/client/src/entities/Player.ts index 428fe12..302b80e 100644 --- a/apps/client/src/entities/Player.ts +++ b/apps/client/src/entities/Player.ts @@ -1,37 +1,111 @@ import { Graphics } from 'pixi.js'; import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; +import type { PlayerData } from "@repo/shared/src/types/player"; -// プレイヤー見た目描画と移動処理の統合オブジェクト -export class Player extends Graphics { - constructor(color: number | string = 0xFF0000, isMe: boolean = false) { +/** + * プレイヤーの共通基底クラス(描画と基本データの保持) + */ +export abstract class BasePlayer extends Graphics { + public id: string; + public teamId: number; + + constructor(data: PlayerData) { super(); - - const { PLAYER_RADIUS } = GAME_CONFIG; + this.id = data.id; + this.teamId = data.teamId; - // 文字列色指定の Pixi 数値色形式変換 - const hexColor = typeof color === "string" - ? parseInt(color.replace("#", "0x"), 16) - : color; + // 初期座標のセット + this.position.set(data.x, data.y); - // プレイヤー本体円描画 + // gameConfigから定数を取得 + const { PLAYER_RADIUS, TEAM_COLORS } = GAME_CONFIG; + + // チームIDに対応する色をHEX文字列('#RRGGBB')で取得し、PixiJS用の数値(0xRRGGBB)に変換 + const colorString = TEAM_COLORS[this.teamId] || '#FFFFFF'; + const hexColor = parseInt(colorString.replace("#", "0x"), 16); + + // 共通の描画(円) this.circle(0, 0, PLAYER_RADIUS).fill(hexColor); - - // 自身プレイヤー識別用外周ライン - if (isMe) { - this.circle(0, 0, PLAYER_RADIUS).stroke({ width: 3, color: 0xffff00 }); - } } - // 入力ベクトルと経過時間基準の座標更新処理 - move(vx: number, vy: number, deltaTime: number) { + // 毎フレーム呼ばれる更新メソッド(サブクラスで具体的な処理を実装させる) + abstract update(deltaTime: number): void; +} + +/** + * 自プレイヤー(キー・ジョイスティック入力で移動・送信する) + */ +export class LocalPlayer extends BasePlayer { + constructor(data: PlayerData) { + super(data); + + // 自プレイヤーであることを示す黄色のハイライト(外枠) + const { PLAYER_RADIUS } = GAME_CONFIG; + this.circle(0, 0, PLAYER_RADIUS).stroke({ width: 3, color: 0xffff00 }); + } + + /** + * 入力ベクトルと経過時間基準の座標更新処理 + */ + public move(vx: number, vy: number, deltaTime: number) { 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(PLAYER_RADIUS, Math.min(MAP_WIDTH - PLAYER_RADIUS, this.x)); this.y = Math.max(PLAYER_RADIUS, Math.min(MAP_HEIGHT - PLAYER_RADIUS, this.y)); } + + public update(_deltaTime: number): void { + // 自プレイヤーは GameScene 側から move() を通じて動かすため、ここでは何もしない + } +} + +/** + * 他プレイヤー(サーバーからの通信を受信して補間・吸着移動する) + */ +export class RemotePlayer extends BasePlayer { + private targetX: number; + private targetY: number; + + constructor(data: PlayerData) { + super(data); + this.targetX = data.x; + this.targetY = data.y; + } + + /** + * サーバーから受信した最新の座標を目標としてセットする + */ + public setTargetPosition(x?: number, y?: number) { + if (x !== undefined) this.targetX = x; + if (y !== undefined) this.targetY = y; + } + + /** + * 毎フレームの更新処理(目標座標へのLerp補間) + */ + public update(deltaTime: number): void { + const { PLAYER_LERP_SNAP_THRESHOLD, PLAYER_LERP_SMOOTHNESS } = GAME_CONFIG; + + const diffX = this.targetX - this.x; + const diffY = this.targetY - this.y; + + // X軸の補間 + if (Math.abs(diffX) < PLAYER_LERP_SNAP_THRESHOLD) { + this.x = this.targetX; + } else { + this.x += diffX * PLAYER_LERP_SMOOTHNESS * deltaTime; + } + + // Y軸の補間 + if (Math.abs(diffY) < PLAYER_LERP_SNAP_THRESHOLD) { + this.y = this.targetY; + } else { + this.y += diffY * PLAYER_LERP_SMOOTHNESS * deltaTime; + } + } } \ No newline at end of file diff --git a/apps/client/src/managers/GameManager.ts b/apps/client/src/managers/GameManager.ts new file mode 100644 index 0000000..0611b10 --- /dev/null +++ b/apps/client/src/managers/GameManager.ts @@ -0,0 +1,157 @@ +import { Application, Container, Ticker } from "pixi.js"; +import { socketClient } from "../network/SocketClient"; +import { GAME_CONFIG } from "@repo/shared/src/config/gameConfig"; +import type { PlayerData } from "@repo/shared/src/types/player"; +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 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.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: PlayerData[] | Record) => { + const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as 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: PlayerData) => { + const playerSprite = new RemotePlayer(p); + this.worldContainer.addChild(playerSprite); + this.players[p.id] = playerSprite; + }); + + 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]; + } + }); + } + + /** + * 毎フレームの更新処理(メインゲームループ) + */ + private tick(ticker: Ticker) { + const me = this.players[this.myId]; + if (!me || !(me instanceof LocalPlayer)) return; + + // 1. 自プレイヤーの移動と送信 + const { x: dx, y: dy } = this.joystickInput; + const isMoving = dx !== 0 || dy !== 0; + + if (isMoving) { + me.move(dx / MAX_DIST, dy / MAX_DIST, ticker.deltaTime); + + const now = performance.now(); + if (now - this.lastPositionSentTime >= GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { + socketClient.sendMove(me.x, me.y); + this.lastPositionSentTime = now; + } + } else if (this.wasMoving) { + socketClient.sendMove(me.x, me.y); + } + this.wasMoving = isMoving; + + // 2. 全プレイヤーの更新(Lerpなど) + Object.values(this.players).forEach((player) => { + player.update(ticker.deltaTime); + }); + + // 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.socket.off("current_players"); + socketClient.socket.off("new_player"); + socketClient.socket.off("update_player"); + socketClient.socket.off("remove_player"); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/GameScene.tsx b/apps/client/src/scenes/GameScene.tsx index 20b6e13..479d959 100644 --- a/apps/client/src/scenes/GameScene.tsx +++ b/apps/client/src/scenes/GameScene.tsx @@ -1,15 +1,6 @@ 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"; +import { VirtualJoystick } from "../input/VirtualJoystick"; +import { GameManager } from "../managers/GameManager"; interface GameSceneProps { myId: string | null; @@ -17,179 +8,26 @@ /** * メインゲーム画面コンポーネント - * PixiJS 初期化・エンティティ管理・ソケット同期処理 + * UIの描画と GameManager への入力伝達のみを担当する */ 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 gameManagerRef = useRef(null); useEffect(() => { - if (!pixiContainerRef.current) return; + if (!pixiContainerRef.current || !myId) return; - // 非同期初期化中アンマウント判定フラグ - let isCancelled = false; - // Pixi 初期化完了フラグ - let isInitialized = false; - const app = new Application(); - // カメラ追従向けワールド親コンテナ - const worldContainer = new Container(); + // GameManager のインスタンス化と初期化 + const manager = new GameManager(pixiContainerRef.current, myId); + manager.init(); + + // 参照を保持(ジョイスティック入力を渡すため) + gameManagerRef.current = manager; - 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(GAME_CONFIG.TEAM_COLORS[p.teamId], 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(GAME_CONFIG.TEAM_COLORS[p.teamId], 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; - - // 初期化待機中アンマウント時の即時破棄分岐 - if (isCancelled) { - app.destroy(true, { children: true }); - return; - } - - // ルートステージへのワールド追加 - pixiContainerRef.current?.appendChild(app.canvas); - app.stage.addChild(worldContainer); - - // 画面準備完了通知送信 - socketClient.readyForGame(); - - // メインゲームループ登録 - 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) { - // 停止瞬間の確定座標単発送信 - socketClient.sendMove(me.x, me.y); - } - - // 次フレーム比較用移動状態保存 - wasMovingRef.current = isMoving; - - // 他プレイヤー座標の線形補間と閾値吸着 - 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"); + manager.destroy(); + gameManagerRef.current = null; }; }, [myId]); @@ -198,9 +36,14 @@ {/* PixiJS Canvas 配置領域 */}
- {/* 入力UI重畳用前面レイヤー */} + {/* UI 配置領域 */}
- { joystickInputRef.current = { x, y }; }} /> + { + // ジョイスティックの入力を毎フレーム Manager に渡す + gameManagerRef.current?.setJoystickInput(x, y); + }} + />
);