diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index f0c2033..a0c8987 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -2,13 +2,13 @@ import { socketManager } from "@client/network/SocketManager"; import { config } from "@repo/shared"; import type { playerTypes } from "@repo/shared"; -import { BasePlayer, LocalPlayer, RemotePlayer } from "./entities/player/Player"; +import { LocalPlayerController, RemotePlayerController } from "./entities/player/PlayerController"; import { GameMap } from "./entities/map/GameMap"; export class GameManager { private app: Application; private worldContainer: Container; - private players: Record = {}; + private players: Record = {}; private myId: string; private container: HTMLDivElement; private gameMap!: GameMap; @@ -90,16 +90,16 @@ socketManager.game.onCurrentPlayers((serverPlayers: playerTypes.PlayerData[] | Record) => { const playersArray = (Array.isArray(serverPlayers) ? serverPlayers : Object.values(serverPlayers)) as playerTypes.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; + const playerController = p.id === this.myId ? new LocalPlayerController(p) : new RemotePlayerController(p); + this.worldContainer.addChild(playerController.getDisplayObject()); + this.players[p.id] = playerController; }); }); socketManager.game.onNewPlayer((p: playerTypes.PlayerData) => { - const playerSprite = new RemotePlayer(p); - this.worldContainer.addChild(playerSprite); - this.players[p.id] = playerSprite; + const playerController = new RemotePlayerController(p); + this.worldContainer.addChild(playerController.getDisplayObject()); + this.players[p.id] = playerController; }); // サーバーからの GAME_START を検知して開始時刻をセットする @@ -113,15 +113,15 @@ socketManager.game.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); + if (target && target instanceof RemotePlayerController) { + target.applyRemoteUpdate({ x: data.x, y: data.y }); } }); socketManager.game.onRemovePlayer((id: string) => { const target = this.players[id]; if (target) { - this.worldContainer.removeChild(target); + this.worldContainer.removeChild(target.getDisplayObject()); target.destroy(); delete this.players[id]; } @@ -137,7 +137,7 @@ */ private tick(ticker: Ticker) { const me = this.players[this.myId]; - if (!me || !(me instanceof LocalPlayer)) return; + if (!me || !(me instanceof LocalPlayerController)) return; const deltaSeconds = ticker.deltaMS / 1000; @@ -146,27 +146,36 @@ const isMoving = dx !== 0 || dy !== 0; if (isMoving) { - me.move(dx, dy, deltaSeconds); + me.applyLocalInput({ axisX: dx, axisY: dy, deltaTime: deltaSeconds }); + me.tick(); const now = performance.now(); if (now - this.lastPositionSentTime >= config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { - socketManager.game.sendMove(me.gridX, me.gridY); + const position = me.getPosition(); + socketManager.game.sendMove(position.x, position.y); this.lastPositionSentTime = now; } } else if (this.wasMoving) { - socketManager.game.sendMove(me.gridX, me.gridY); + me.tick(); + const position = me.getPosition(); + socketManager.game.sendMove(position.x, position.y); + } else { + me.tick(); } this.wasMoving = isMoving; // 2. 全プレイヤーの更新(Lerpなど) Object.values(this.players).forEach((player) => { - player.update(deltaSeconds); + if (player instanceof RemotePlayerController) { + player.tick(deltaSeconds); + } }); // 3. カメラの追従(自分を中心に) + const meDisplay = me.getDisplayObject(); this.worldContainer.position.set( - -(me.x - this.app.screen.width / 2), - -(me.y - this.app.screen.height / 2) + -(meDisplay.x - this.app.screen.width / 2), + -(meDisplay.y - this.app.screen.height / 2) ); } diff --git a/apps/client/src/scenes/game/entities/map/GameMap.ts b/apps/client/src/scenes/game/entities/map/GameMap.ts index 5fe2144..95cb999 100644 --- a/apps/client/src/scenes/game/entities/map/GameMap.ts +++ b/apps/client/src/scenes/game/entities/map/GameMap.ts @@ -84,7 +84,7 @@ // 塗布済み(-1以外)の場合のみ色を塗る if (teamId !== -1) { - // Player.ts と同様に、文字列のカラーコードを PixiJS 用の数値に変換 + // 他の描画処理と同様に,文字列のカラーコードをPixiJS用の数値に変換 const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; const hexColor = parseInt(colorString.replace("#", "0x"), 16); diff --git a/apps/client/src/scenes/game/entities/player/Player.ts b/apps/client/src/scenes/game/entities/player/Player.ts deleted file mode 100644 index 1408491..0000000 --- a/apps/client/src/scenes/game/entities/player/Player.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { Graphics } from 'pixi.js'; -import { config } from "@repo/shared"; -import type { playerTypes } from "@repo/shared"; - -/** - * プレイヤーの共通基底クラス(描画と基本データの保持) - */ -export abstract class BasePlayer extends Graphics { - public id: string; - public teamId: number; - public gridX: number; - public gridY: number; - - constructor(data: playerTypes.PlayerData, isLocal: boolean = false) { - super(); - this.id = data.id; - this.teamId = data.teamId; - - // 初期座標のセット(内部はグリッド単位) - this.gridX = data.x; - this.gridY = data.y; - - // gameConfigから定数を取得 - const { - GRID_CELL_SIZE, - PLAYER_RADIUS_PX, - TEAM_COLORS, - PLAYER_LOCAL_STROKE_COLOR, - PLAYER_LOCAL_STROKE_WIDTH, - PLAYER_REMOTE_STROKE_COLOR, - PLAYER_REMOTE_STROKE_WIDTH - } = config.GAME_CONFIG; - - this.position.set(this.gridX * GRID_CELL_SIZE, this.gridY * GRID_CELL_SIZE); - - // チームIDに対応する色をHEX文字列('#RRGGBB')で取得し、PixiJS用の数値(0xRRGGBB)に変換 - const colorString = TEAM_COLORS[this.teamId] || '#FFFFFF'; - const hexColor = parseInt(colorString.replace("#", "0x"), 16); - - // 自プレイヤーか他プレイヤーかで枠線の設定を切り替え - const strokeColor = isLocal ? PLAYER_LOCAL_STROKE_COLOR : PLAYER_REMOTE_STROKE_COLOR; - const strokeWidth = isLocal ? PLAYER_LOCAL_STROKE_WIDTH : PLAYER_REMOTE_STROKE_WIDTH; - - // 塗りつぶしと枠線を同時に描画 - this.circle(0, 0, PLAYER_RADIUS_PX) - .fill(hexColor) - .stroke({ width: strokeWidth, color: strokeColor }); - } - - protected syncDisplayPosition() { - const { GRID_CELL_SIZE } = config.GAME_CONFIG; - this.position.set(this.gridX * GRID_CELL_SIZE, this.gridY * GRID_CELL_SIZE); - } - - // 毎フレーム呼ばれる更新メソッド(サブクラスで具体的な処理を実装させる) - abstract update(deltaTime: number): void; -} - -/** - * 自プレイヤー(キー・ジョイスティック入力で移動・送信する) - */ -export class LocalPlayer extends BasePlayer { - constructor(data: playerTypes.PlayerData) { - super(data, true); - } - - /** - * 入力ベクトルと経過時間基準の座標更新処理 - */ - public move(vx: number, vy: number, deltaTime: number) { - const { PLAYER_SPEED, GRID_COLS, GRID_ROWS, PLAYER_RADIUS } = config.GAME_CONFIG; - - const speed = PLAYER_SPEED * deltaTime; - this.gridX += vx * speed; - this.gridY += vy * speed; - - // 画面外に出ないようにクランプ - this.gridX = Math.max(PLAYER_RADIUS, Math.min(GRID_COLS - PLAYER_RADIUS, this.gridX)); - this.gridY = Math.max(PLAYER_RADIUS, Math.min(GRID_ROWS - PLAYER_RADIUS, this.gridY)); - - this.syncDisplayPosition(); - } - - public update(_deltaTime: number): void { - // 自プレイヤーは GameScene 側から move() を通じて動かすため、ここでは何もしない - } -} - -/** - * 他プレイヤー(サーバーからの通信を受信して補間・吸着移動する) - */ -export class RemotePlayer extends BasePlayer { - private targetGridX: number; - private targetGridY: number; - - constructor(data: playerTypes.PlayerData) { - super(data, false); - this.targetGridX = data.x; - this.targetGridY = data.y; - } - - /** - * サーバーから受信した最新の座標を目標としてセットする - */ - public setTargetPosition(x?: number, y?: number) { - if (x !== undefined) this.targetGridX = x; - if (y !== undefined) this.targetGridY = y; - } - - /** - * 毎フレームの更新処理(目標座標へのLerp補間) - */ - public update(deltaTime: number): void { - const { PLAYER_LERP_SNAP_THRESHOLD, PLAYER_LERP_SMOOTHNESS } = config.GAME_CONFIG; - - const diffX = this.targetGridX - this.gridX; - const diffY = this.targetGridY - this.gridY; - - // X軸の補間 - if (Math.abs(diffX) < PLAYER_LERP_SNAP_THRESHOLD) { - this.gridX = this.targetGridX; - } else { - this.gridX += diffX * PLAYER_LERP_SMOOTHNESS * deltaTime; - } - - // Y軸の補間 - if (Math.abs(diffY) < PLAYER_LERP_SNAP_THRESHOLD) { - this.gridY = this.targetGridY; - } else { - this.gridY += diffY * PLAYER_LERP_SMOOTHNESS * deltaTime; - } - - this.syncDisplayPosition(); - } -} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/player/PlayerController.ts b/apps/client/src/scenes/game/entities/player/PlayerController.ts new file mode 100644 index 0000000..409794a --- /dev/null +++ b/apps/client/src/scenes/game/entities/player/PlayerController.ts @@ -0,0 +1,94 @@ +/** + * PlayerController + * 外部入出力とModel/Viewの橋渡しを担うコントローラー群 + * ローカル入力適用,リモート更新適用,描画同期を分離して扱う + */ +import type { playerTypes } from '@repo/shared'; +import { PlayerModel } from './PlayerModel'; +import { PlayerView } from './PlayerView'; + +/** ローカル移動入力を表す型 */ +export type LocalInput = { + axisX: number; + axisY: number; + deltaTime: number; +}; + +/** リモート移動更新を表す型 */ +export type RemoteUpdate = Partial; + +/** + * ローカル用コントローラーとリモート用コントローラーの共通基底 + */ +abstract class BasePlayerController { + protected readonly model: PlayerModel; + protected readonly view: PlayerView; + + /** 共通初期化としてModelとViewを生成する */ + protected constructor(data: playerTypes.PlayerData, isLocal: boolean) { + this.model = new PlayerModel(data); + this.view = new PlayerView(data.teamId, isLocal); + + const pos = this.model.getPosition(); + this.view.syncPosition(pos.x, pos.y); + } + + /** 描画オブジェクトを取得する */ + public getDisplayObject() { + return this.view.displayObject; + } + + /** 現在座標を取得する */ + public getPosition(): playerTypes.MovePayload { + return this.model.getPosition(); + } + + /** 外部送信用スナップショットを取得する */ + public getSnapshot(): playerTypes.PlayerData { + return this.model.getSnapshot(); + } + + /** 管理中の描画リソースを破棄する */ + public destroy(): void { + this.view.destroy(); + } +} + +/** ローカルプレイヤーの入力適用と描画同期を担うコントローラー */ +export class LocalPlayerController extends BasePlayerController { + /** ローカルプレイヤー用コントローラーを初期化する */ + constructor(data: playerTypes.PlayerData) { + super(data, true); + } + + /** ローカル入力を座標計算へ適用する */ + public applyLocalInput(input: LocalInput): void { + this.model.moveLocal(input.axisX, input.axisY, input.deltaTime); + } + + /** 毎フレームの描画同期を行う */ + public tick(): void { + const pos = this.model.getPosition(); + this.view.syncPosition(pos.x, pos.y); + } +} + +/** リモートプレイヤーの更新適用と補間同期を担うコントローラー */ +export class RemotePlayerController extends BasePlayerController { + /** リモートプレイヤー用コントローラーを初期化する */ + constructor(data: playerTypes.PlayerData) { + super(data, false); + } + + /** ネットワーク更新を目標座標へ反映する */ + public applyRemoteUpdate(update: RemoteUpdate): void { + this.model.setRemoteTarget(update); + } + + /** 毎フレームの補間更新と描画同期を行う */ + public tick(deltaTime: number): void { + this.model.updateRemoteLerp(deltaTime); + const pos = this.model.getPosition(); + this.view.syncPosition(pos.x, pos.y); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/player/PlayerModel.ts b/apps/client/src/scenes/game/entities/player/PlayerModel.ts new file mode 100644 index 0000000..f6c1612 --- /dev/null +++ b/apps/client/src/scenes/game/entities/player/PlayerModel.ts @@ -0,0 +1,103 @@ +/** + * PlayerModel + * プレイヤーの座標計算と補間計算を担うモデル + * ローカル移動,リモート目標座標,送信スナップショットを管理する + */ +import { config } from '@repo/shared'; +import type { playerTypes } from '@repo/shared'; + +/** + * プレイヤーの計算責務を担うモデル + */ +export class PlayerModel { + public readonly id: string; + public readonly teamId: number; + + private gridX: number; + private gridY: number; + private targetGridX: number; + private targetGridY: number; + + /** 共有プレイヤー情報から初期状態を構築する */ + constructor(data: playerTypes.PlayerData) { + this.id = data.id; + this.teamId = data.teamId; + this.gridX = data.x; + this.gridY = data.y; + this.targetGridX = data.x; + this.targetGridY = data.y; + } + + /** 現在座標を取得する */ + public getPosition(): playerTypes.MovePayload { + return { x: this.gridX, y: this.gridY }; + } + + /** 送信用スナップショットを取得する */ + public getSnapshot(): playerTypes.PlayerData { + return { + id: this.id, + teamId: this.teamId, + x: this.gridX, + y: this.gridY, + }; + } + + /** ローカル入力に基づいて座標を更新する */ + public moveLocal(vx: number, vy: number, deltaTime: number): void { + if (!this.isFiniteNumber(vx) || !this.isFiniteNumber(vy) || !this.isFiniteNumber(deltaTime)) { + return; + } + + const { PLAYER_SPEED } = config.GAME_CONFIG; + const speed = PLAYER_SPEED * deltaTime; + + this.gridX += vx * speed; + this.gridY += vy * speed; + + this.clampToBounds(); + } + + /** リモート更新の目標座標を設定する */ + public setRemoteTarget(update: Partial): void { + if (update.x !== undefined && this.isFiniteNumber(update.x)) this.targetGridX = update.x; + if (update.y !== undefined && this.isFiniteNumber(update.y)) this.targetGridY = update.y; + } + + /** 目標座標に向けて補間更新する */ + public updateRemoteLerp(deltaTime: number): void { + if (!this.isFiniteNumber(deltaTime)) { + return; + } + + const { PLAYER_LERP_SNAP_THRESHOLD, PLAYER_LERP_SMOOTHNESS } = config.GAME_CONFIG; + + const diffX = this.targetGridX - this.gridX; + const diffY = this.targetGridY - this.gridY; + + if (Math.abs(diffX) < PLAYER_LERP_SNAP_THRESHOLD) { + this.gridX = this.targetGridX; + } else { + this.gridX += diffX * PLAYER_LERP_SMOOTHNESS * deltaTime; + } + + if (Math.abs(diffY) < PLAYER_LERP_SNAP_THRESHOLD) { + this.gridY = this.targetGridY; + } else { + this.gridY += diffY * PLAYER_LERP_SMOOTHNESS * deltaTime; + } + } + + /** マップ境界内へ座標をクランプする */ + private clampToBounds(): void { + const { GRID_COLS, GRID_ROWS, PLAYER_RADIUS } = config.GAME_CONFIG; + + this.gridX = Math.max(PLAYER_RADIUS, Math.min(GRID_COLS - PLAYER_RADIUS, this.gridX)); + this.gridY = Math.max(PLAYER_RADIUS, Math.min(GRID_ROWS - PLAYER_RADIUS, this.gridY)); + } + + /** 有限数かどうかを判定する */ + private isFiniteNumber(value: number): boolean { + return Number.isFinite(value); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/player/PlayerView.ts b/apps/client/src/scenes/game/entities/player/PlayerView.ts new file mode 100644 index 0000000..5adbb1d --- /dev/null +++ b/apps/client/src/scenes/game/entities/player/PlayerView.ts @@ -0,0 +1,48 @@ +/** + * PlayerView + * プレイヤーの描画責務を担うビュー + * Pixi Graphicsの生成と座標反映を行う + */ +import { Graphics } from 'pixi.js'; +import { config } from '@repo/shared'; + +/** + * プレイヤーの描画責務を担うビュー + */ +export class PlayerView { + public readonly displayObject: Graphics; + + /** チーム情報と種別に応じた描画オブジェクトを生成する */ + constructor(teamId: number, isLocal: boolean) { + const { + PLAYER_RADIUS_PX, + TEAM_COLORS, + PLAYER_LOCAL_STROKE_COLOR, + PLAYER_LOCAL_STROKE_WIDTH, + PLAYER_REMOTE_STROKE_COLOR, + PLAYER_REMOTE_STROKE_WIDTH, + } = config.GAME_CONFIG; + + const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; + const fillColor = parseInt(colorString.replace('#', '0x'), 16); + const strokeColor = isLocal ? PLAYER_LOCAL_STROKE_COLOR : PLAYER_REMOTE_STROKE_COLOR; + const strokeWidth = isLocal ? PLAYER_LOCAL_STROKE_WIDTH : PLAYER_REMOTE_STROKE_WIDTH; + + this.displayObject = new Graphics(); + this.displayObject + .circle(0, 0, PLAYER_RADIUS_PX) + .fill(fillColor) + .stroke({ width: strokeWidth, color: strokeColor }); + } + + /** グリッド座標を描画座標へ反映する */ + public syncPosition(gridX: number, gridY: number): void { + const { GRID_CELL_SIZE } = config.GAME_CONFIG; + this.displayObject.position.set(gridX * GRID_CELL_SIZE, gridY * GRID_CELL_SIZE); + } + + /** 描画オブジェクトを破棄する */ + public destroy(): void { + this.displayObject.destroy(); + } +} \ No newline at end of file