diff --git a/apps/client/src/scenes/game/GameInputManager.ts b/apps/client/src/scenes/game/GameInputManager.ts new file mode 100644 index 0000000..abf6670 --- /dev/null +++ b/apps/client/src/scenes/game/GameInputManager.ts @@ -0,0 +1,18 @@ +/** + * GameInputManager + * ゲーム側へ入力を集約して橋渡しする + */ +import { GameManager } from "./GameManager"; + +/** ジョイスティック入力をゲーム管理へ橋渡しするマネージャー */ +export class GameInputManager { + private gameManager: GameManager; + + constructor(gameManager: GameManager) { + this.gameManager = gameManager; + } + + public handleJoystickInput = (x: number, y: number) => { + this.gameManager.setJoystickInput(x, y); + }; +} diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index f0c2033..796697c 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -2,16 +2,16 @@ 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 { GameMap } from "./entities/map/GameMap"; +import { LocalPlayerController, RemotePlayerController } from "./entities/player/PlayerController"; +import { GameMapController } from "./entities/map/GameMapController"; export class GameManager { private app: Application; private worldContainer: Container; - private players: Record = {}; + private players: Record = {}; private myId: string; private container: HTMLDivElement; - private gameMap!: GameMap; + private gameMap!: GameMapController; private gameStartTime: number | null = null; // サーバーからゲーム開始通知(と開始時刻)を受け取った時に呼ぶ @@ -60,9 +60,9 @@ this.container.appendChild(this.app.canvas); // 背景マップの配置 - const gameMap = new GameMap(); + const gameMap = new GameMapController(); this.gameMap = gameMap; - this.worldContainer.addChild(gameMap); + this.worldContainer.addChild(gameMap.getDisplayObject()); this.app.stage.addChild(this.worldContainer); // ネットワークイベントの登録 @@ -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/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx index 5fbf5c2..96b9d84 100644 --- a/apps/client/src/scenes/game/GameScene.tsx +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -1,8 +1,15 @@ -import { useEffect, useRef, useState } from "react"; -import { Joystick } from "./input/joystick/Joystick"; +/** + * GameScene + * メインゲーム画面の表示とライフサイクルを管理する + * GameManagerの初期化と入力配線を行う + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { GameInputManager } from "./GameInputManager"; import { GameManager } from "./GameManager"; +import { GameView } from "./GameView"; import { config } from "@repo/shared"; +/** GameScene の入力プロパティ */ interface GameSceneProps { myId: string | null; } @@ -14,6 +21,7 @@ export function GameScene({ myId }: GameSceneProps) { const pixiContainerRef = useRef(null); const gameManagerRef = useRef(null); + const inputManagerRef = useRef(null); // gameConfig から初期表示時間文字列を生成する関数 const getInitialTimeDisplay = () => { @@ -33,8 +41,9 @@ const manager = new GameManager(pixiContainerRef.current, myId); manager.init(); - // 参照を保持(ジョイスティック入力を渡すため) + // 参照を保持(入力を渡すため) gameManagerRef.current = manager; + inputManagerRef.current = new GameInputManager(manager); // 描画用のタイマーループ (100msごとに更新して滑らかにする) const timerInterval = setInterval(() => { @@ -48,39 +57,20 @@ // コンポーネント破棄時のクリーンアップ return () => { manager.destroy(); + inputManagerRef.current = null; clearInterval(timerInterval); // クリーンアップ }; }, [myId]); + const handleInput = useCallback((x: number, y: number) => { + inputManagerRef.current?.handleJoystickInput(x, y); + }, []); + return ( -
- {/* タイマーUIの表示 */} -
- {timeLeft} -
- - {/* PixiJS Canvas 配置領域 */} -
- - {/* UI 配置領域 */} -
- { - // ジョイスティックの入力を毎フレーム Manager に渡す - gameManagerRef.current?.setJoystickInput(x, y); - }} - /> -
-
+ ); } \ No newline at end of file diff --git a/apps/client/src/scenes/game/GameView.tsx b/apps/client/src/scenes/game/GameView.tsx new file mode 100644 index 0000000..b2c3891 --- /dev/null +++ b/apps/client/src/scenes/game/GameView.tsx @@ -0,0 +1,58 @@ +/** + * GameView + * ゲーム画面の描画専用コンポーネント + * タイマー表示,PixiJSの描画領域,入力UIの配置のみを担当する + */ +import { JoystickInputPresenter } from "./input/joystick/JoystickInputPresenter"; + +/** 表示と入力に必要なプロパティ */ +type Props = { + timeLeft: string; + pixiContainerRef: React.RefObject; + onJoystickInput: (x: number, y: number) => void; +}; + +/** 画面描画と入力UIをまとめて描画する */ +export const GameView = ({ timeLeft, pixiContainerRef, onJoystickInput }: Props) => { + return ( +
+ {/* タイマーUIの表示 */} +
+ {timeLeft} +
+ + {/* PixiJS Canvas 配置領域 */} +
+ + {/* UI 配置領域 */} +
+ +
+
+ ); +}; diff --git a/apps/client/src/scenes/game/entities/map/GameMap.ts b/apps/client/src/scenes/game/entities/map/GameMap.ts deleted file mode 100644 index 5fe2144..0000000 --- a/apps/client/src/scenes/game/entities/map/GameMap.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { Container, Graphics } from "pixi.js"; -import { config } from "@repo/shared"; -import type { gridMapTypes } from "@repo/shared"; - -// 親クラスを Graphics から Container に変更し、レイヤー管理を可能にする -export class GameMap extends Container { - private bgGraphics: Graphics; - private gridGraphics: Graphics; - - // 400個(GRID_COLS * GRID_ROWS)のマス目描画用オブジェクトを保持する1次元配列 - private cells: Graphics[] = []; - - constructor() { - super(); - this.bgGraphics = new Graphics(); - this.gridGraphics = new Graphics(); - - // 描画順(追加した順に前面に描画される): 背景 -> マス目 -> グリッド線 - this.addChild(this.bgGraphics); - - // マス目の初期化と Container への追加 - this.initCells(); - - this.addChild(this.gridGraphics); - - // 背景と線の描画(静的なので1回だけ実行) - this.drawBaseMap(); - } - - // 設定値に基づき、空のマス目(Graphics)を400個生成して配列に格納する - private initCells() { - const { GRID_COLS, GRID_ROWS, GRID_CELL_SIZE } = config.GAME_CONFIG; - const totalCells = GRID_COLS * GRID_ROWS; - - for (let i = 0; i < totalCells; i++) { - const col = i % GRID_COLS; - const row = Math.floor(i / GRID_COLS); - - const cell = new Graphics(); - // マスの座標をあらかじめ設定しておく(描画の基準点になる) - cell.x = col * GRID_CELL_SIZE; - cell.y = row * GRID_CELL_SIZE; - - this.addChild(cell); - this.cells.push(cell); - } - } - - // 設定値参照によるマップ外観(背景・グリッド線)の組み立て処理 - private drawBaseMap() { - const { - MAP_WIDTH_PX, MAP_HEIGHT_PX, GRID_CELL_SIZE, - MAP_BG_COLOR, MAP_GRID_COLOR, MAP_BORDER_COLOR - } = config.GAME_CONFIG; - - // マップ全域背景レイヤー - this.bgGraphics.rect(0, 0, MAP_WIDTH_PX, MAP_HEIGHT_PX).fill(MAP_BG_COLOR); - - // 縦方向グリッド線 - for (let x = 0; x <= MAP_WIDTH_PX; x += GRID_CELL_SIZE) { - this.gridGraphics.moveTo(x, 0).lineTo(x, MAP_HEIGHT_PX).stroke({ width: 1, color: MAP_GRID_COLOR }); - } - // 横方向グリッド線 - for (let y = 0; y <= MAP_HEIGHT_PX; y += GRID_CELL_SIZE) { - this.gridGraphics.moveTo(0, y).lineTo(MAP_WIDTH_PX, y).stroke({ width: 1, color: MAP_GRID_COLOR }); - } - - // プレイ領域外枠 - this.gridGraphics.rect(0, 0, MAP_WIDTH_PX, MAP_HEIGHT_PX).stroke({ width: 5, color: MAP_BORDER_COLOR }); - } - - /** - * サーバー(またはテストロジック)から受け取った最新のマップ状態で色を更新する - */ - public updateMapState(state: gridMapTypes.MapState) { - const { GRID_CELL_SIZE, TEAM_COLORS } = config.GAME_CONFIG; - - for (let i = 0; i < state.gridColors.length; i++) { - const teamId = state.gridColors[i]; - const cell = this.cells[i]; - - // 一旦マスの描画をクリア - cell.clear(); - - // 塗布済み(-1以外)の場合のみ色を塗る - if (teamId !== -1) { - // Player.ts と同様に、文字列のカラーコードを PixiJS 用の数値に変換 - const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; - const hexColor = parseInt(colorString.replace("#", "0x"), 16); - - // cell.x, cell.y は設定済みなので、ローカル座標 (0,0) からサイズ分を塗りつぶす - cell.rect(0, 0, GRID_CELL_SIZE, GRID_CELL_SIZE).fill(hexColor); - } - } - } - - /** - * 差分データを受け取って指定のマスだけ色を更新する - */ - public updateCells(updates: gridMapTypes.CellUpdate[]) { - const { GRID_CELL_SIZE, TEAM_COLORS } = config.GAME_CONFIG; - - updates.forEach(({ index, teamId }) => { - const cell = this.cells[index]; - if (!cell) return; - - cell.clear(); - - if (teamId !== -1) { - const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; - const hexColor = parseInt(colorString.replace("#", "0x"), 16); - cell.rect(0, 0, GRID_CELL_SIZE, GRID_CELL_SIZE).fill(hexColor); - } - }); - } -} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/map/GameMapController.ts b/apps/client/src/scenes/game/entities/map/GameMapController.ts new file mode 100644 index 0000000..41ca4ef --- /dev/null +++ b/apps/client/src/scenes/game/entities/map/GameMapController.ts @@ -0,0 +1,49 @@ +/** + * GameMapController + * 外部からのマップ更新入力をModelとViewへ仲介するコントローラー + * 全体更新と差分更新を統一的に扱い,描画同期を提供する + */ +import type { gridMapTypes } from '@repo/shared'; +import type { Container } from 'pixi.js'; +import { GameMapModel } from './GameMapModel'; +import { GameMapView } from './GameMapView'; + +/** マップ更新の仲介責務を担うコントローラー */ +export class GameMapController { + private readonly model: GameMapModel; + private readonly view: GameMapView; + + /** モデルとビューを生成し初期同期する */ + constructor() { + this.model = new GameMapModel(); + this.view = new GameMapView(); + this.view.renderAll(this.model.getAllTeamIds()); + } + + /** 描画オブジェクトを取得する */ + public getDisplayObject(): Container { + return this.view.displayObject; + } + + /** 全体マップ状態を反映する */ + public updateMapState(state: gridMapTypes.MapState): void { + this.model.applyMapState(state); + this.view.renderAll(this.model.getAllTeamIds()); + } + + /** 差分セル更新を反映する */ + public updateCells(updates: gridMapTypes.CellUpdate[]): void { + this.model.applyUpdates(updates); + + updates.forEach(({ index }) => { + const teamId = this.model.getTeamId(index); + if (teamId === undefined) return; + this.view.renderCell(index, teamId); + }); + } + + /** 管理中の描画リソースを破棄する */ + public destroy(): void { + this.view.destroy(); + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/map/GameMapModel.ts b/apps/client/src/scenes/game/entities/map/GameMapModel.ts new file mode 100644 index 0000000..305f14d --- /dev/null +++ b/apps/client/src/scenes/game/entities/map/GameMapModel.ts @@ -0,0 +1,50 @@ +/** + * GameMapModel + * マップセルの色状態を管理する計算モデル + * 全体更新と差分更新を適用して描画入力用の状態を保持する + */ +import { config } from '@repo/shared'; +import type { gridMapTypes } from '@repo/shared'; + +/** マップセル状態の計算責務を担うモデル */ +export class GameMapModel { + private readonly cellTeamIds: number[]; + + /** 設定値に基づいて初期セル状態を構築する */ + constructor() { + const { GRID_COLS, GRID_ROWS } = config.GAME_CONFIG; + this.cellTeamIds = new Array(GRID_COLS * GRID_ROWS).fill(-1); + } + + /** 全体マップ状態を適用する */ + public applyMapState(state: gridMapTypes.MapState): void { + const maxLength = Math.min(this.cellTeamIds.length, state.gridColors.length); + for (let index = 0; index < maxLength; index++) { + this.cellTeamIds[index] = state.gridColors[index]; + } + } + + /** 差分セル更新を適用する */ + public applyUpdates(updates: gridMapTypes.CellUpdate[]): void { + updates.forEach(({ index, teamId }) => { + if (!this.isValidIndex(index)) return; + this.cellTeamIds[index] = teamId; + }); + } + + /** 指定セルのチームIDを取得する */ + public getTeamId(index: number): number | undefined { + if (!this.isValidIndex(index)) return undefined; + return this.cellTeamIds[index]; + } + + /** 描画用の全セル状態を取得する */ + public getAllTeamIds(): number[] { + return [...this.cellTeamIds]; + } + + /** セル添字が有効範囲内かを判定する */ + private isValidIndex(index: number): boolean { + return Number.isInteger(index) && index >= 0 && index < this.cellTeamIds.length; + } +} \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/map/GameMapView.ts b/apps/client/src/scenes/game/entities/map/GameMapView.ts new file mode 100644 index 0000000..5c5185b --- /dev/null +++ b/apps/client/src/scenes/game/entities/map/GameMapView.ts @@ -0,0 +1,105 @@ +/** + * GameMapView + * マップ背景,グリッド線,セル塗りの描画を担うビュー + * 計算済みセル状態を受けてPixi描画へ反映する + */ +import { Container, Graphics } from 'pixi.js'; +import { config } from '@repo/shared'; + +/** マップ描画責務を担うビュー */ +export class GameMapView { + public readonly displayObject: Container; + + private readonly bgGraphics: Graphics; + private readonly gridGraphics: Graphics; + private readonly cells: Graphics[] = []; + + /** 背景,グリッド線,セル描画オブジェクトを初期化する */ + constructor() { + this.displayObject = new Container(); + this.bgGraphics = new Graphics(); + this.gridGraphics = new Graphics(); + + this.displayObject.addChild(this.bgGraphics); + this.initCells(); + this.displayObject.addChild(this.gridGraphics); + this.drawBaseMap(); + } + + /** 全セル状態をまとめて描画へ反映する */ + public renderAll(teamIds: number[]): void { + const maxLength = Math.min(this.cells.length, teamIds.length); + for (let index = 0; index < maxLength; index++) { + this.renderCell(index, teamIds[index]); + } + } + + /** 指定セルの状態を描画へ反映する */ + public renderCell(index: number, teamId: number): void { + const cell = this.cells[index]; + if (!cell) return; + + const { GRID_CELL_SIZE } = config.GAME_CONFIG; + + // 対象セルをクリアしてから必要に応じて再塗布する + cell.clear(); + if (teamId === -1) return; + + const hexColor = this.toHexColor(teamId); + cell.rect(0, 0, GRID_CELL_SIZE, GRID_CELL_SIZE).fill(hexColor); + } + + /** 描画リソースを破棄する */ + public destroy(): void { + this.displayObject.destroy({ children: true }); + } + + /** 設定値に基づいてセル描画オブジェクトを初期化する */ + private initCells(): void { + const { GRID_COLS, GRID_ROWS, GRID_CELL_SIZE } = config.GAME_CONFIG; + const totalCells = GRID_COLS * GRID_ROWS; + + for (let index = 0; index < totalCells; index++) { + const col = index % GRID_COLS; + const row = Math.floor(index / GRID_COLS); + + const cell = new Graphics(); + cell.x = col * GRID_CELL_SIZE; + cell.y = row * GRID_CELL_SIZE; + + this.displayObject.addChild(cell); + this.cells.push(cell); + } + } + + /** マップ背景とグリッド線を描画する */ + private drawBaseMap(): void { + const { + MAP_WIDTH_PX, + MAP_HEIGHT_PX, + GRID_CELL_SIZE, + MAP_BG_COLOR, + MAP_GRID_COLOR, + MAP_BORDER_COLOR, + } = config.GAME_CONFIG; + + this.bgGraphics.rect(0, 0, MAP_WIDTH_PX, MAP_HEIGHT_PX).fill(MAP_BG_COLOR); + + for (let x = 0; x <= MAP_WIDTH_PX; x += GRID_CELL_SIZE) { + this.gridGraphics.moveTo(x, 0).lineTo(x, MAP_HEIGHT_PX).stroke({ width: 1, color: MAP_GRID_COLOR }); + } + + for (let y = 0; y <= MAP_HEIGHT_PX; y += GRID_CELL_SIZE) { + this.gridGraphics.moveTo(0, y).lineTo(MAP_WIDTH_PX, y).stroke({ width: 1, color: MAP_GRID_COLOR }); + } + + this.gridGraphics.rect(0, 0, MAP_WIDTH_PX, MAP_HEIGHT_PX).stroke({ width: 5, color: MAP_BORDER_COLOR }); + } + + /** チームIDから塗り色の16進数カラー値を取得する */ + private toHexColor(teamId: number): number { + const { TEAM_COLORS } = config.GAME_CONFIG; + const colorString = TEAM_COLORS[teamId] || '#FFFFFF'; + return parseInt(colorString.replace('#', '0x'), 16); + } +} \ No newline at end of file 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 diff --git a/apps/client/src/scenes/game/input/joystick/Joystick.tsx b/apps/client/src/scenes/game/input/joystick/Joystick.tsx deleted file mode 100644 index 691c5cc..0000000 --- a/apps/client/src/scenes/game/input/joystick/Joystick.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Joystick - * 画面上のジョイスティックUIの入口 - * ポインターイベントを受け取り,useJoystick に処理を委譲し,描画は JoystickView に渡す - */ -import { JoystickView } from "./JoystickView"; -import { MAX_DIST, useJoystick } from "./useJoystick"; - -/** 入力半径の既定値を外部から参照できるように再公開 */ -export { MAX_DIST } from "./useJoystick"; - -/** Joystick コンポーネントの入力コールバック */ -type Props = { - onInput: (moveX: number, moveY: number) => void; -}; - -/** ポインター入力と描画を結びつけるジョイスティックUI */ -export const Joystick = ({ onInput }: Props) => { - const { isMoving, center, knobOffset, radius, handleStart, handleMove, handleEnd } = - useJoystick({ onInput }); - - return ( -
- {/* 見た目のみの描画(入力は扱わない) */} - -
- ); -}; \ No newline at end of file diff --git a/apps/client/src/scenes/game/input/joystick/JoystickInputPresenter.tsx b/apps/client/src/scenes/game/input/joystick/JoystickInputPresenter.tsx new file mode 100644 index 0000000..e1db3b9 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/JoystickInputPresenter.tsx @@ -0,0 +1,39 @@ +/** + * JoystickInputPresenter + * ジョイスティック入力の受け取りと表示状態の橋渡しを担うプレゼンター + * 入力イベントをコントローラーへ委譲し,描画用状態をViewへ渡す + */ +import { useJoystickController } from "./useJoystickController"; +import { JoystickView } from "./JoystickView"; +import type { UseJoystickInputPresenterProps } from "./common"; + +/** 入力と表示状態の橋渡しを行う */ +export const JoystickInputPresenter = ({ onInput, maxDist }: UseJoystickInputPresenterProps) => { + const { isMoving, center, knobOffset, radius, handleStart, handleMove, handleEnd } = + useJoystickController({ onInput, maxDist }); + + return ( +
+ {/* 入力イベントをコントローラーへ渡し,描画用状態をViewへ渡す */} + +
+ ); +}; diff --git a/apps/client/src/scenes/game/input/joystick/JoystickModel.ts b/apps/client/src/scenes/game/input/joystick/JoystickModel.ts new file mode 100644 index 0000000..06193f5 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/JoystickModel.ts @@ -0,0 +1,38 @@ +/** + * JoystickModel + * ジョイスティック入力の座標計算を担うモデル + * ノブ表示オフセットと正規化入力ベクトルを算出する + */ +import type { NormalizedInput, Point } from './common'; + +/** ジョイスティック計算結果を表す型 */ +export type JoystickComputed = { + knobOffset: Point; + normalized: NormalizedInput; +}; + +/** 座標差分からノブオフセットと正規化入力を計算する */ +export const computeJoystick = ( + center: Point, + current: Point, + radius: number +): JoystickComputed => { + const safeRadius = radius > 0 ? radius : 1; + + const dx = current.x - center.x; + const dy = current.y - center.y; + const dist = Math.hypot(dx, dy); + const angle = Math.atan2(dy, dx); + + const limitedDist = Math.min(dist, safeRadius); + const offsetX = Math.cos(angle) * limitedDist; + const offsetY = Math.sin(angle) * limitedDist; + + return { + knobOffset: { x: offsetX, y: offsetY }, + normalized: { + x: offsetX / safeRadius, + y: offsetY / safeRadius, + }, + }; +}; diff --git a/apps/client/src/scenes/game/input/joystick/JoystickView.tsx b/apps/client/src/scenes/game/input/joystick/JoystickView.tsx index 5ef2feb..62d449f 100644 --- a/apps/client/src/scenes/game/input/joystick/JoystickView.tsx +++ b/apps/client/src/scenes/game/input/joystick/JoystickView.tsx @@ -3,18 +3,18 @@ * ジョイスティックの見た目だけを描画するコンポーネント * 入力処理は持たず,受け取った座標情報をもとにUIを描く */ -type Point = { x: number; y: number }; - -/** 表示に必要な座標と状態 */ -type Props = { - isActive: boolean; - center: Point; - knobOffset: Point; - radius: number; -}; +import { + JOYSTICK_BASE_BG_COLOR, + JOYSTICK_BASE_BORDER_COLOR, + JOYSTICK_BASE_BORDER_WIDTH, + JOYSTICK_KNOB_BG_COLOR, + JOYSTICK_KNOB_SHADOW, + JOYSTICK_KNOB_SIZE, +} from "./common"; +import type { UseJoystickViewProps } from "./common"; /** UIの見た目だけを描画するビュー */ -export const JoystickView = ({ isActive, center, knobOffset, radius }: Props) => { +export const JoystickView = ({ isActive, center, knobOffset, radius }: UseJoystickViewProps) => { if (!isActive) return null; // ベースリングとノブの描画 @@ -26,9 +26,9 @@ top: center.y - radius, width: radius * 2, height: radius * 2, - background: "rgba(255, 255, 255, 0.1)", + background: JOYSTICK_BASE_BG_COLOR, borderRadius: "50%", - border: "2px solid rgba(255, 255, 255, 0.3)", + border: `${JOYSTICK_BASE_BORDER_WIDTH}px solid ${JOYSTICK_BASE_BORDER_COLOR}`, pointerEvents: "none", }} > @@ -37,12 +37,12 @@ position: "absolute", left: "50%", top: "50%", - width: 40, - height: 40, - background: "rgba(255, 255, 255, 0.8)", + width: JOYSTICK_KNOB_SIZE, + height: JOYSTICK_KNOB_SIZE, + background: JOYSTICK_KNOB_BG_COLOR, borderRadius: "50%", transform: `translate(calc(-50% + ${knobOffset.x}px), calc(-50% + ${knobOffset.y}px))`, - boxShadow: "0 0 10px rgba(0,0,0,0.5)", + boxShadow: JOYSTICK_KNOB_SHADOW, }} />
diff --git a/apps/client/src/scenes/game/input/joystick/common/index.ts b/apps/client/src/scenes/game/input/joystick/common/index.ts new file mode 100644 index 0000000..2333f85 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/common/index.ts @@ -0,0 +1,31 @@ +/** + * index + * ジョイスティック入力の共通型と共通定数の再公開を担う + * 呼び出し側のimport先を共通化する + */ +/** 共有型を再公開する */ +export type { + JoystickPointerEvent, + NormalizedInput, + Point, + UseJoystickControllerProps, + UseJoystickControllerReturn, + UseJoystickInputPresenterProps, + UseJoystickStateProps, + UseJoystickStateReturn, + UseJoystickViewProps, +} from './joystick.types'; + +/** 共有定数を再公開する */ +export { + JOYSTICK_BASE_BG_COLOR, + JOYSTICK_BASE_BORDER_COLOR, + JOYSTICK_BASE_BORDER_WIDTH, + JOYSTICK_DEADZONE, + JOYSTICK_KNOB_BG_COLOR, + JOYSTICK_KNOB_SHADOW, + JOYSTICK_KNOB_SIZE, + JOYSTICK_MIN_MOVEMENT_DELTA, + JOYSTICK_SEND_ZERO_ON_END, + MAX_DIST, +} from './joystick.constants'; diff --git a/apps/client/src/scenes/game/input/joystick/common/joystick.constants.ts b/apps/client/src/scenes/game/input/joystick/common/joystick.constants.ts new file mode 100644 index 0000000..5d87d53 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/common/joystick.constants.ts @@ -0,0 +1,35 @@ +/** + * joystick.constants + * ジョイスティック入力に関する定数をまとめる + * 共有する半径の既定値などを定義する + */ + +/** UI側と共有する最大半径の既定値 */ +export const MAX_DIST = 60; + +/** 微小入力をゼロ扱いにするデッドゾーン閾値 */ +export const JOYSTICK_DEADZONE = 0.08; + +/** 連続送信を間引く最小移動量閾値 */ +export const JOYSTICK_MIN_MOVEMENT_DELTA = 0.02; + +/** 入力終了時にゼロ入力を送信するかどうかの方針 */ +export const JOYSTICK_SEND_ZERO_ON_END = true; + +/** ジョイスティックベースの背景色 */ +export const JOYSTICK_BASE_BG_COLOR = 'rgba(255, 255, 255, 0.1)'; + +/** ジョイスティックベースの枠線色 */ +export const JOYSTICK_BASE_BORDER_COLOR = 'rgba(255, 255, 255, 0.3)'; + +/** ジョイスティックベースの枠線太さ */ +export const JOYSTICK_BASE_BORDER_WIDTH = 2; + +/** ジョイスティックノブのサイズ */ +export const JOYSTICK_KNOB_SIZE = 40; + +/** ジョイスティックノブの背景色 */ +export const JOYSTICK_KNOB_BG_COLOR = 'rgba(255, 255, 255, 0.8)'; + +/** ジョイスティックノブの影 */ +export const JOYSTICK_KNOB_SHADOW = '0 0 10px rgba(0,0,0,0.5)'; diff --git a/apps/client/src/scenes/game/input/joystick/common/joystick.types.ts b/apps/client/src/scenes/game/input/joystick/common/joystick.types.ts new file mode 100644 index 0000000..be5b9b9 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/common/joystick.types.ts @@ -0,0 +1,63 @@ +/** + * joystick.types + * ジョイスティック入力で使う型をまとめる + * 座標や入力の共通表現を定義する + */ + +import type React from 'react'; + +/** 2D座標の簡易型 */ +export type Point = { x: number; y: number }; + +/** 正規化された入力ベクトル */ +export type NormalizedInput = { x: number; y: number }; + +/** ジョイスティックで扱うポインター入力イベント型 */ +export type JoystickPointerEvent = React.TouchEvent | React.MouseEvent; + +/** useJoystickState に渡す設定型 */ +export type UseJoystickStateProps = { + maxDist?: number; +}; + +/** useJoystickState が返すUI向けの状態とハンドラ型 */ +export type UseJoystickStateReturn = { + isMoving: boolean; + center: Point; + knobOffset: Point; + radius: number; + handleStart: (e: JoystickPointerEvent) => void; + handleMove: (e: JoystickPointerEvent) => NormalizedInput | null; + handleEnd: () => void; +}; + +/** useJoystickController に渡す入力設定型 */ +export type UseJoystickControllerProps = { + onInput: (moveX: number, moveY: number) => void; + maxDist?: number; +}; + +/** useJoystickController が返す描画状態と入力ハンドラ型 */ +export type UseJoystickControllerReturn = { + isMoving: boolean; + center: Point; + knobOffset: Point; + radius: number; + handleStart: (e: JoystickPointerEvent) => void; + handleMove: (e: JoystickPointerEvent) => void; + handleEnd: () => void; +}; + +/** JoystickInputPresenter に渡す入力設定型 */ +export type UseJoystickInputPresenterProps = { + onInput: (moveX: number, moveY: number) => void; + maxDist?: number; +}; + +/** JoystickView に渡す描画状態型 */ +export type UseJoystickViewProps = { + isActive: boolean; + center: Point; + knobOffset: Point; + radius: number; +}; diff --git a/apps/client/src/scenes/game/input/joystick/useJoystick.ts b/apps/client/src/scenes/game/input/joystick/useJoystick.ts deleted file mode 100644 index c8b0236..0000000 --- a/apps/client/src/scenes/game/input/joystick/useJoystick.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * useJoystick - * ジョイスティック入力を受け取り,座標計算と正規化ベクトルの出力を行うフック - * UI描画に必要な中心点・ノブ位置・半径も合わせて提供する - */ -import { useCallback, useState } from "react"; -import type React from "react"; - -/** UI側と共有する最大半径の既定値 */ -export const MAX_DIST = 60; - -/** フックに渡す入力コールバックと設定 */ -type Props = { - onInput: (moveX: number, moveY: number) => void; - maxDist?: number; -}; - -/** 2D座標の簡易型 */ -type Point = { x: number; y: number }; - -/** フックが返すUI向けの状態とハンドラ */ -type UseJoystickReturn = { - isMoving: boolean; - center: Point; - knobOffset: Point; - radius: number; - handleStart: (e: React.TouchEvent | React.MouseEvent) => void; - handleMove: (e: React.TouchEvent | React.MouseEvent) => void; - handleEnd: () => void; -}; - -/** タッチとマウスからクライアント座標を共通化して取得 */ -const getClientPoint = (e: React.TouchEvent | React.MouseEvent): Point | null => { - if ("touches" in e) { - const touch = e.touches[0]; - if (!touch) return null; - return { x: touch.clientX, y: touch.clientY }; - } - - return { x: e.clientX, y: e.clientY }; -}; - -/** 正規化ベクトルの出力とUI用の座標を提供するフック */ -export const useJoystick = ({ onInput, maxDist }: Props): UseJoystickReturn => { - const [isMoving, setIsMoving] = useState(false); - const [center, setCenter] = useState({ x: 0, y: 0 }); - const [knobOffset, setKnobOffset] = useState({ x: 0, y: 0 }); - const radius = maxDist ?? MAX_DIST; - - // 入力開始時の基準座標をセットする - const handleStart = useCallback((e: React.TouchEvent | React.MouseEvent) => { - const point = getClientPoint(e); - if (!point) return; - - setCenter(point); - setKnobOffset({ x: 0, y: 0 }); - setIsMoving(true); - }, []); - - // 入力座標からベクトルを計算し,半径でクランプして正規化出力する - const handleMove = useCallback( - (e: React.TouchEvent | React.MouseEvent) => { - if (!isMoving) return; - const point = getClientPoint(e); - if (!point) return; - - const dx = point.x - center.x; - const dy = point.y - center.y; - const dist = Math.sqrt(dx * dx + dy * dy); - const angle = Math.atan2(dy, dx); - - const limitedDist = Math.min(dist, radius); - const offsetX = Math.cos(angle) * limitedDist; - const offsetY = Math.sin(angle) * limitedDist; - const normalizedX = offsetX / radius; - const normalizedY = offsetY / radius; - - setKnobOffset({ x: offsetX, y: offsetY }); - onInput(normalizedX, normalizedY); - }, - [isMoving, center.x, center.y, radius, onInput] - ); - - // 入力終了時に状態をリセットして停止入力を通知する - const handleEnd = useCallback(() => { - setIsMoving(false); - setKnobOffset({ x: 0, y: 0 }); - onInput(0, 0); - }, [onInput]); - - return { isMoving, center, knobOffset, radius, handleStart, handleMove, handleEnd }; -}; diff --git a/apps/client/src/scenes/game/input/joystick/useJoystickController.ts b/apps/client/src/scenes/game/input/joystick/useJoystickController.ts new file mode 100644 index 0000000..be2f033 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/useJoystickController.ts @@ -0,0 +1,81 @@ +/** + * useJoystickController + * 入力イベントとジョイスティック計算結果の仲介を担うフック + * useJoystickState の出力を受けて onInput 通知と終了時リセット通知を統一する + */ +import { useCallback } from 'react'; +import { useRef } from 'react'; +import type { + JoystickPointerEvent, + NormalizedInput, + UseJoystickControllerProps, + UseJoystickControllerReturn, +} from './common'; +import { JOYSTICK_MIN_MOVEMENT_DELTA, JOYSTICK_SEND_ZERO_ON_END } from './common'; +import { useJoystickState } from './useJoystickState'; + +/** 入力イベントと通知処理を仲介するフック */ +export const useJoystickController = ({ + onInput, + maxDist, +}: UseJoystickControllerProps): UseJoystickControllerReturn => { + const { + isMoving, + center, + knobOffset, + radius, + handleStart, + handleMove: baseHandleMove, + handleEnd: baseHandleEnd, + } = useJoystickState({ maxDist }); + + const lastEmittedRef = useRef(null); + + const emitInput = useCallback( + (normalized: NormalizedInput) => { + onInput(normalized.x, normalized.y); + }, + [onInput] + ); + + const handleMove = useCallback( + (e: JoystickPointerEvent) => { + const normalized = baseHandleMove(e); + if (!normalized) return; + + const last = lastEmittedRef.current; + if (last) { + const delta = Math.hypot(normalized.x - last.x, normalized.y - last.y); + if (delta < JOYSTICK_MIN_MOVEMENT_DELTA) { + return; + } + } + + emitInput(normalized); + lastEmittedRef.current = normalized; + }, + [baseHandleMove, emitInput] + ); + + const handleEnd = useCallback(() => { + baseHandleEnd(); + + if (JOYSTICK_SEND_ZERO_ON_END) { + emitInput({ x: 0, y: 0 }); + lastEmittedRef.current = { x: 0, y: 0 }; + return; + } + + lastEmittedRef.current = null; + }, [baseHandleEnd, emitInput]); + + return { + isMoving, + center, + knobOffset, + radius, + handleStart, + handleMove, + handleEnd, + }; +}; diff --git a/apps/client/src/scenes/game/input/joystick/useJoystickState.ts b/apps/client/src/scenes/game/input/joystick/useJoystickState.ts new file mode 100644 index 0000000..7f38616 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/useJoystickState.ts @@ -0,0 +1,72 @@ +/** + * useJoystickState + * ジョイスティック入力状態の管理と入力ハンドラの提供を担うフック + * UI描画に必要な中心点,ノブ位置,半径を保持する + */ +import { useCallback, useState } from 'react'; +import { JOYSTICK_DEADZONE, MAX_DIST } from './common'; +import { computeJoystick } from './JoystickModel'; +import type { + JoystickPointerEvent, + Point, + UseJoystickStateProps, + UseJoystickStateReturn, +} from './common'; + +/** タッチとマウスからクライアント座標を共通化して取得する */ +const getClientPoint = (e: JoystickPointerEvent): Point | null => { + if ('touches' in e) { + const touch = e.touches[0]; + if (!touch) return null; + return { x: touch.clientX, y: touch.clientY }; + } + + return { x: e.clientX, y: e.clientY }; +}; + +/** ジョイスティック入力状態と入力ハンドラを提供する */ +export const useJoystickState = ({ maxDist }: UseJoystickStateProps): UseJoystickStateReturn => { + const [isMoving, setIsMoving] = useState(false); + const [center, setCenter] = useState({ x: 0, y: 0 }); + const [knobOffset, setKnobOffset] = useState({ x: 0, y: 0 }); + const radius = maxDist ?? MAX_DIST; + + // 入力開始時の基準座標をセットする + const handleStart = useCallback((e: JoystickPointerEvent) => { + const point = getClientPoint(e); + if (!point) return; + + setCenter(point); + setKnobOffset({ x: 0, y: 0 }); + setIsMoving(true); + }, []); + + // 入力座標からベクトルを計算し,半径でクランプして正規化する + const handleMove = useCallback( + (e: JoystickPointerEvent) => { + if (!isMoving) return null; + const point = getClientPoint(e); + if (!point) return null; + + const computed = computeJoystick(center, point, radius); + + const magnitude = Math.hypot(computed.normalized.x, computed.normalized.y); + if (magnitude < JOYSTICK_DEADZONE) { + setKnobOffset({ x: 0, y: 0 }); + return { x: 0, y: 0 }; + } + + setKnobOffset(computed.knobOffset); + return computed.normalized; + }, + [isMoving, center.x, center.y, radius] + ); + + // 入力終了時に状態をリセットする + const handleEnd = useCallback(() => { + setIsMoving(false); + setKnobOffset({ x: 0, y: 0 }); + }, []); + + return { isMoving, center, knobOffset, radius, handleStart, handleMove, handleEnd }; +};