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/scenes/GameScene.tsx b/apps/client/src/scenes/GameScene.tsx index 20b6e13..b9547be 100644 --- a/apps/client/src/scenes/GameScene.tsx +++ b/apps/client/src/scenes/GameScene.tsx @@ -2,14 +2,15 @@ import { Application, Container } from "pixi.js"; // ネットワーク・入力関連 -import { socketClient} from "../network/SocketClient"; +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"; +// 変更点: BasePlayer, LocalPlayer, RemotePlayer をインポート +import { BasePlayer, LocalPlayer, RemotePlayer } from "../entities/Player"; interface GameSceneProps { myId: string | null; @@ -23,24 +24,23 @@ const pixiContainerRef = useRef(null); // ジョイスティック入力値 const joystickInputRef = useRef({ x: 0, y: 0 }); - // プレイヤースプライト参照テーブル - const playersRef = useRef>({}); + + // 変更点: 型を共通基底クラス BasePlayer に変更 + const playersRef = useRef>({}); + // 位置送信間隔制御用タイムスタンプ const lastPositionSentTimeRef = useRef(0); - // 他プレイヤー補間用目標座標テーブル - const targetPositionsRef = useRef>({}); // 前フレーム移動状態 const wasMovingRef = useRef(false); + // 💡 削除: targetPositionsRef は RemotePlayer 内にカプセル化されたため不要になりました! + useEffect(() => { if (!pixiContainerRef.current) return; - // 非同期初期化中アンマウント判定フラグ let isCancelled = false; - // Pixi 初期化完了フラグ let isInitialized = false; const app = new Application(); - // カメラ追従向けワールド親コンテナ const worldContainer = new Container(); const initPixi = async () => { @@ -48,40 +48,38 @@ 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); + // 変更点: 自身か他プレイヤーかで生成するインスタンスを切り替え + const playerSprite = p.id === myId + ? new LocalPlayer(p) + : new RemotePlayer(p); + 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); + // 新規参加は必ず他人なので RemotePlayer を生成 + const playerSprite = new RemotePlayer(p); 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; + const target = playersRef.current[data.id]; + // 変更点: RemotePlayer のメソッドを呼び出して目標座標をセットするだけ + if (target && target instanceof RemotePlayer) { + target.setTargetPosition(data.x, data.y); } }); @@ -92,80 +90,53 @@ worldContainer.removeChild(target); target.destroy(); delete playersRef.current[id]; - delete targetPositionsRef.current[id]; } }); - // PixiJS 本体初期化 + // --- 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; + // 自身が LocalPlayer であることを担保 + if (!me || !(me instanceof LocalPlayer)) 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; - } - } + // 変更点: ループ内の補間処理がなくなり、一律に update を呼ぶだけになりました!(ポリモーフィズム) + Object.values(playersRef.current).forEach((player) => { + player.update(ticker.deltaTime); }); - // 自プレイヤー中心表示向けワールド逆方向オフセット + // 自プレイヤー中心表示向けワールド逆方向オフセット(カメラ追従) worldContainer.position.set( -(me.x - app.screen.width / 2), -(me.y - app.screen.height / 2) @@ -175,17 +146,13 @@ 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"); @@ -195,10 +162,7 @@ return (
- {/* PixiJS Canvas 配置領域 */}
- - {/* 入力UI重畳用前面レイヤー */}
{ joystickInputRef.current = { x, y }; }} />