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 };
+};