diff --git a/apps/client/src/scenes/game/GameManager.ts b/apps/client/src/scenes/game/GameManager.ts index 2d60ceb..f0c2033 100644 --- a/apps/client/src/scenes/game/GameManager.ts +++ b/apps/client/src/scenes/game/GameManager.ts @@ -2,9 +2,8 @@ import { socketManager } from "@client/network/SocketManager"; import { config } from "@repo/shared"; import type { playerTypes } from "@repo/shared"; -import { BasePlayer, LocalPlayer, RemotePlayer } from "./Player"; -import { GameMap } from "./GameMap"; -import { MAX_DIST } from "./VirtualJoystick"; +import { BasePlayer, LocalPlayer, RemotePlayer } from "./entities/player/Player"; +import { GameMap } from "./entities/map/GameMap"; export class GameManager { private app: Application; @@ -147,7 +146,7 @@ const isMoving = dx !== 0 || dy !== 0; if (isMoving) { - me.move(dx / MAX_DIST, dy / MAX_DIST, deltaSeconds); + me.move(dx, dy, deltaSeconds); const now = performance.now(); if (now - this.lastPositionSentTime >= config.GAME_CONFIG.PLAYER_POSITION_UPDATE_MS) { diff --git a/apps/client/src/scenes/game/GameMap.ts b/apps/client/src/scenes/game/GameMap.ts deleted file mode 100644 index 19d3cae..0000000 --- a/apps/client/src/scenes/game/GameMap.ts +++ /dev/null @@ -1,117 +0,0 @@ -// apps/client/src/scenes/game/GameMap.ts -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/GameScene.tsx b/apps/client/src/scenes/game/GameScene.tsx index 2b04995..5fbf5c2 100644 --- a/apps/client/src/scenes/game/GameScene.tsx +++ b/apps/client/src/scenes/game/GameScene.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { VirtualJoystick } from "./VirtualJoystick"; +import { Joystick } from "./input/joystick/Joystick"; import { GameManager } from "./GameManager"; import { config } from "@repo/shared"; @@ -74,8 +74,8 @@ {/* UI 配置領域 */}
- { + { // ジョイスティックの入力を毎フレーム Manager に渡す gameManagerRef.current?.setJoystickInput(x, y); }} diff --git a/apps/client/src/scenes/game/Player.ts b/apps/client/src/scenes/game/Player.ts deleted file mode 100644 index 1408491..0000000 --- a/apps/client/src/scenes/game/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/VirtualJoystick.tsx b/apps/client/src/scenes/game/VirtualJoystick.tsx deleted file mode 100644 index 90928a1..0000000 --- a/apps/client/src/scenes/game/VirtualJoystick.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import { useState } from "react"; - -// ジョイスティック最大入力距離 -export const MAX_DIST = 60; - -type Props = { - // 正規化前入力ベクトル通知コールバック - onMove: (moveX: number, moveY: number) => void; -}; - -// タッチ・マウス両対応仮想ジョイスティック -export const VirtualJoystick = ({ onMove }: Props) => { - // 入力中フラグ - const [isMoving, setIsMoving] = useState(false); - // ジョイスティック基準座標 - const [basePos, setBasePos] = useState({ x: 0, y: 0 }); - // ノブ描画オフセット座標 - const [stickPos, setStickPos] = useState({ x: 0, y: 0 }); - - const handleStart = (e: React.TouchEvent | React.MouseEvent) => { - const clientX = "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; - const clientY = "touches" in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; - setBasePos({ x: clientX, y: clientY }); - setStickPos({ x: 0, y: 0 }); - setIsMoving(true); - }; - - const handleMove = (e: React.TouchEvent | React.MouseEvent) => { - if (!isMoving) return; - const clientX = "touches" in e ? e.touches[0].clientX : (e as React.MouseEvent).clientX; - const clientY = "touches" in e ? e.touches[0].clientY : (e as React.MouseEvent).clientY; - - const dx = clientX - basePos.x; - const dy = clientY - basePos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - const angle = Math.atan2(dy, dx); - - const limitedDist = Math.min(dist, MAX_DIST); - const moveX = Math.cos(angle) * limitedDist; - const moveY = Math.sin(angle) * limitedDist; - - setStickPos({ x: moveX, y: moveY }); - // 距離制限後入力ベクトル通知 - onMove(moveX, moveY); - }; - - const handleEnd = () => { - setIsMoving(false); - setStickPos({ x: 0, y: 0 }); - // 入力終了時停止ベクトル通知 - onMove(0, 0); - }; - - return ( -
- {isMoving && ( -
-
-
- )} -
- ); -}; \ No newline at end of file diff --git a/apps/client/src/scenes/game/entities/map/GameMap.ts b/apps/client/src/scenes/game/entities/map/GameMap.ts new file mode 100644 index 0000000..5fe2144 --- /dev/null +++ b/apps/client/src/scenes/game/entities/map/GameMap.ts @@ -0,0 +1,116 @@ +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/player/Player.ts b/apps/client/src/scenes/game/entities/player/Player.ts new file mode 100644 index 0000000..1408491 --- /dev/null +++ b/apps/client/src/scenes/game/entities/player/Player.ts @@ -0,0 +1,135 @@ +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/input/joystick/Joystick.tsx b/apps/client/src/scenes/game/input/joystick/Joystick.tsx new file mode 100644 index 0000000..691c5cc --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/Joystick.tsx @@ -0,0 +1,51 @@ +/** + * 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/JoystickView.tsx b/apps/client/src/scenes/game/input/joystick/JoystickView.tsx new file mode 100644 index 0000000..5ef2feb --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/JoystickView.tsx @@ -0,0 +1,50 @@ +/** + * JoystickView + * ジョイスティックの見た目だけを描画するコンポーネント + * 入力処理は持たず,受け取った座標情報をもとにUIを描く + */ +type Point = { x: number; y: number }; + +/** 表示に必要な座標と状態 */ +type Props = { + isActive: boolean; + center: Point; + knobOffset: Point; + radius: number; +}; + +/** UIの見た目だけを描画するビュー */ +export const JoystickView = ({ isActive, center, knobOffset, radius }: Props) => { + if (!isActive) return null; + + // ベースリングとノブの描画 + return ( +
+
+
+ ); +}; diff --git a/apps/client/src/scenes/game/input/joystick/useJoystick.ts b/apps/client/src/scenes/game/input/joystick/useJoystick.ts new file mode 100644 index 0000000..c8b0236 --- /dev/null +++ b/apps/client/src/scenes/game/input/joystick/useJoystick.ts @@ -0,0 +1,92 @@ +/** + * 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 }; +};