diff --git a/package-lock.json b/package-lock.json index 13a669c..32b4070 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,10 @@ "@react-three/cannon": "^6.6.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "lucide-react": "^1.14.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-icons": "^5.6.0", "react-router-dom": "^7.13.1", "three": "^0.183.2" }, @@ -1874,6 +1876,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/maath": { "version": "0.10.8", "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", @@ -1958,9 +1969,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -2023,6 +2034,15 @@ "react": "^19.2.4" } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-router": { "version": "7.13.1", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", diff --git a/package.json b/package.json index e4fbe4e..7bbff81 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,10 @@ "@react-three/cannon": "^6.6.0", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", + "lucide-react": "^1.14.0", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-icons": "^5.6.0", "react-router-dom": "^7.13.1", "three": "^0.183.2" }, diff --git a/public/acceleration_floor_se.mp3 b/public/acceleration_floor_se.mp3 new file mode 100644 index 0000000..1caea87 Binary files /dev/null and b/public/acceleration_floor_se.mp3 differ diff --git a/src/gamescene/components/AccelerationFloor.tsx b/src/gamescene/components/AccelerationFloor.tsx new file mode 100644 index 0000000..7a5d8dd --- /dev/null +++ b/src/gamescene/components/AccelerationFloor.tsx @@ -0,0 +1,91 @@ +import { useFrame } from "@react-three/fiber"; +import { useEffect, useMemo, useRef } from "react"; +import * as THREE from "three"; +import type { AccelerationFloorConfig } from "../constants/levels"; + +type AccelerationFloorProps = { + config: AccelerationFloorConfig; +}; + +export function AccelerationFloor({ config }: AccelerationFloorProps) { + // 矢印の流れるテクスチャを Canvas API で生成 + const texture = useMemo(() => { + const canvas = document.createElement("canvas"); + canvas.width = 256; + canvas.height = 256; + const ctx = canvas.getContext("2d"); + if (ctx) { + // 背景を少し暗く + ctx.fillStyle = "rgba(0, 0, 0, 0.4)"; + ctx.fillRect(0, 0, 256, 256); + + // 矢印の描画設定 + ctx.strokeStyle = "#ffaa00"; + ctx.lineWidth = 20; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + // 矢印を描画 + const drawArrow = (yOffset: number) => { + ctx.beginPath(); + ctx.moveTo(16, yOffset + 112); + ctx.lineTo(128, yOffset); + ctx.lineTo(240, yOffset + 112); + ctx.stroke(); + }; + + // y軸マイナス方向が進行方向になるように描画 + // 切れ目なくループするように配置 (16と144でちょうど128px間隔) + drawArrow(16); + drawArrow(144); + } + + const tex = new THREE.CanvasTexture(canvas); + tex.wrapS = THREE.RepeatWrapping; + tex.wrapT = THREE.RepeatWrapping; + // 幅に対して1回、高さはアスペクト比を維持してリピート + tex.repeat.set(1, config.size[1] / config.size[0]); + return tex; + }, [config.size]); + + // メモリリークを防ぐため、コンポーネントのアンマウント時またはテクスチャ再生成時に古いテクスチャを破棄 + useEffect(() => { + return () => { + texture.dispose(); + }; + }, [texture]); + + const materialRef = useRef(null); + + // 毎フレーム、テクスチャをずらして流れるアニメーションを実現 + useFrame((_, delta) => { + const map = materialRef.current?.map; + if (map) { + // テクスチャを上方向(V軸マイナス方向)へスクロールして、矢印の向きに流れるようにする + // RepeatWrapping を使っているため、オフセットは 0..1 の範囲に正規化して精度低下を防ぐ + map.offset.y = (((map.offset.y - delta * 2.5) % 1) + 1) % 1; + } + }); + + // direction ベクトルからY軸の回転角度を計算 + // Z軸(0, 0, 1) を基準とした角度 + const angle = Math.atan2(config.direction[0], config.direction[2]); + // 矢印(テクスチャの上部)が指定された方向を向くように補正 (+ Math.PI) + const visualAngle = angle + Math.PI; + + return ( + + + + + + + ); +} diff --git a/src/gamescene/components/Ball.tsx b/src/gamescene/components/Ball.tsx index 97ef69d..de072e8 100644 --- a/src/gamescene/components/Ball.tsx +++ b/src/gamescene/components/Ball.tsx @@ -1,9 +1,12 @@ import { useSphere } from "@react-three/cannon"; import { useTexture } from "@react-three/drei"; import { useFrame, useThree } from "@react-three/fiber"; -import { useCallback, useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import * as THREE from "three"; -import type { PortalConfig } from "../constants/levels"; +import type { + AccelerationFloorConfig, + PortalConfig, +} from "../constants/levels"; import { BALL_RADIUS } from "../constants/physics"; import { POCKET_Y_THRESHOLD } from "./billiardTable"; export type ShootFn = (power: number) => boolean; @@ -25,6 +28,7 @@ type BallProps = { portal?: PortalConfig; portals?: PortalConfig[]; allowMagnet?: boolean; + accelerationFloors?: AccelerationFloorConfig[]; }; function isInsidePortal( @@ -51,6 +55,7 @@ export function Ball({ portal, portals, allowMagnet, + accelerationFloors, }: BallProps) { const texture = useTexture(textureUrl); @@ -79,6 +84,7 @@ export function Ball({ const hasPocketed = useRef(false); const lastVelocityRef = useRef<[number, number, number]>([0, 0, 0]); const lastTeleportAtRef = useRef(0); + const floorsOnRef = useRef>(new Set()); const portalWarpAudioRef = useRef(null); const keys = useRef>({}); @@ -138,13 +144,38 @@ export function Ball({ ); } }); + const dashAudioRef = useRef(null); + + // 加速床の判定に必要な計算(角度、三角関数、半サイズ、正規化ベクトル)を事前計算 + const processedFloors = useMemo(() => { + if (!accelerationFloors) return null; + return accelerationFloors.map((floor) => { + const angle = Math.atan2(floor.direction[0], floor.direction[2]); + return { + ...floor, + sinAngle: Math.sin(-angle), + cosAngle: Math.cos(-angle), + halfWidth: floor.size[0] / 2, + halfLength: floor.size[1] / 2, + normalizedDir: new THREE.Vector3( + floor.direction[0], + floor.direction[1], + floor.direction[2], + ).normalize(), + }; + }); + }, [accelerationFloors]); useEffect(() => { portalWarpAudioRef.current = new Audio(PORTAL_WARP_SOUND_URL); portalWarpAudioRef.current.volume = 0.35; + dashAudioRef.current = new Audio("/acceleration_floor_se.mp3"); + dashAudioRef.current.volume = 0.5; + return () => { portalWarpAudioRef.current = null; + dashAudioRef.current = null; }; }, []); @@ -179,6 +210,10 @@ export function Ball({ }, [api.velocity, api.angularVelocity, id, onMovingChange]); useEffect(() => { + // 物理サブスクリプションが再生成される(床などの環境が更新された)タイミングで + // 過去に乗っていた床の履歴をクリアする + floorsOnRef.current.clear(); + const unsubscribe = api.position.subscribe((p) => { onPositionChange?.(id, [p[0], p[1], p[2]]); @@ -207,6 +242,42 @@ export function Ball({ } } + if (processedFloors) { + processedFloors.forEach((floor) => { + const dx = p[0] - floor.position[0]; + const dz = p[2] - floor.position[2]; + + // 事前計算済みの値を使ってローカル座標に変換 + const localX = dx * floor.cosAngle - dz * floor.sinAngle; + const localZ = dx * floor.sinAngle + dz * floor.cosAngle; + + const isInside = + Math.abs(localX) <= floor.halfWidth && + Math.abs(localZ) <= floor.halfLength; + const wasInside = floorsOnRef.current.has(floor.id); + + if (isInside && !wasInside) { + // 床に入った瞬間、速度とスピンを完全に上書きする + const dir = floor.normalizedDir; + + // 速度(velocity)を強制上書き。strength を直接のスピードとして扱う + api.velocity.set(dir.x * floor.strength, 0, dir.z * floor.strength); + + // 直進後に変なカーブを描かないように、ボールの回転(スピン)をリセット + api.angularVelocity.set(0, 0, 0); + floorsOnRef.current.add(floor.id); + + if (dashAudioRef.current) { + dashAudioRef.current.currentTime = 0; + void dashAudioRef.current.play(); + } + } else if (!isInside && wasInside) { + // 床から出た + floorsOnRef.current.delete(floor.id); + } + }); + } + if (hasPocketed.current) return; if (p[1] <= POCKET_Y_THRESHOLD) { @@ -226,6 +297,8 @@ export function Ball({ onPositionChange, portal, portals, + processedFloors, + api.angularVelocity, ]); useEffect(() => { diff --git a/src/gamescene/constants/levels.ts b/src/gamescene/constants/levels.ts index 275a15b..48836af 100644 --- a/src/gamescene/constants/levels.ts +++ b/src/gamescene/constants/levels.ts @@ -3,6 +3,8 @@ import poolballs1 from "@/assets/ballTexture/poolballs1.png"; import poolballs2 from "@/assets/ballTexture/poolballs2.png"; import poolballs3 from "@/assets/ballTexture/poolballs3.png"; import poolballs4 from "@/assets/ballTexture/poolballs4.png"; +import poolballs5 from "@/assets/ballTexture/poolballs5.png"; +import poolballs6 from "@/assets/ballTexture/poolballs6.png"; import tableIce from "@/assets/tableTexture/tableIce.svg"; import { OFFSET_Y, @@ -42,11 +44,20 @@ export type DividerConfig = { color?: string; }; +export type AccelerationFloorConfig = { + id: string; + position: [number, number, number]; + size: [number, number]; + direction: [number, number, number]; // Y成分は無視され、XZ平面上の方向のみ有効 + strength: number; +}; + export type LevelConfig = { id: string; name: string; shotLimit: number; description: string; + gimmic?: string; cueBallId: string; portals?: PortalConfig[]; table?: { @@ -57,6 +68,7 @@ export type LevelConfig = { gate?: GateConfig; dividers?: DividerConfig[]; bombs?: BombSpawnConfig[]; + accelerationFloors?: AccelerationFloorConfig[]; balls: BallSpawnConfig[]; }; @@ -93,16 +105,9 @@ export const LEVELS: LevelConfig[] = [ { id: "level2", name: "Level 2", - description: "3球を7打以内に落とす", - shotLimit: 7, + description: "5球を15打以内に落とす", + shotLimit: 15, cueBallId: "poolballs0", - portals: [ - { - entry: [-0.25, 0, -0.45], - exit: [0.5, 0, 0.55], - radius: 0.12, - }, - ], balls: [ { id: "poolballs0", @@ -113,25 +118,36 @@ export const LEVELS: LevelConfig[] = [ { id: "poolballs1", textureUrl: poolballs1, - position: [0.12, 0.2, 0], + position: [0.25, 0.2, -0.1], }, { id: "poolballs2", textureUrl: poolballs2, - position: [0.25, 0.2, 0], + position: [0.25, 0.2, 0.1], }, { id: "poolballs3", textureUrl: poolballs3, position: [0.38, 0.2, 0], }, + { + id: "poolballs4", + textureUrl: poolballs4, + position: [0.5, 0.2, 0.5], + }, + { + id: "poolballs5", + textureUrl: poolballs5, + position: [0.5, 0.2, -0.5], + }, ], }, { id: "level3", name: "Level 3 - Ice Floor", - description: "氷の床で4球を8打以内に落とす", - shotLimit: 8, + description: "氷の床で6球を15打以内に落とす", + gimmic: "氷の床のため、滑りやすくなっています", + shotLimit: 15, cueBallId: "poolballs0", table: { clothTextureUrl: tableIce, @@ -142,35 +158,47 @@ export const LEVELS: LevelConfig[] = [ { id: "poolballs0", textureUrl: poolballs0, - position: [-0.8, 0.2, 0], + position: [0, 0.2, 0], shootable: true, }, { id: "poolballs1", textureUrl: poolballs1, - position: [0.15, 0.2, -0.2], + position: [0.2, 0.2, -0.07], }, { id: "poolballs2", textureUrl: poolballs2, - position: [0.32, 0.2, 0], + position: [-0.2, 0.2, 0.07], }, { id: "poolballs3", textureUrl: poolballs3, - position: [0.15, 0.2, 0.2], + position: [0.2, 0.2, 0.07], }, { id: "poolballs4", textureUrl: poolballs4, - position: [0.52, 0.2, 0], + position: [-0.2, 0.2, -0.07], + }, + { + id: "poolballs5", + textureUrl: poolballs5, + position: [0.3, 0.2, 0], + }, + { + id: "poolballs6", + textureUrl: poolballs6, + position: [-0.3, 0.2, 0], }, ], }, { id: "level4", name: "Level 4 - Switch Gate", - description: "3球を10打以内に落とす", + description: "スイッチを起動させ3球を15打以内に落とす", + gimmic: + "動いているスイッチに球を強くぶつけると、\nポケットが1分間開きます\n開いたすきを狙ってボールをすべて落としてください", shotLimit: 15, cueBallId: "poolballs0", gate: { @@ -208,6 +236,8 @@ export const LEVELS: LevelConfig[] = [ id: "level5", name: "Level 5 - Bomb!", description: "爆弾を避けて2球を5打以内に落とす", + gimmic: + "爆弾に触ると爆発してゲームオーバーになります。\n爆弾に触らないように気を付けよう!", shotLimit: 5, cueBallId: "poolballs0", bombs: [{ id: "bomb0", position: [0.2, 0.2, -0.5] }], @@ -290,6 +320,87 @@ export const LEVELS: LevelConfig[] = [ }, ], }, + { + id: "level8", + name: "Level 8 - Bomb Trap", + description: "加速床の罠を避けて3球を20打以内に落とす", + shotLimit: 20, + cueBallId: "poolballs0", + bombs: [ + { id: "bomb0", position: [0, 0.2, -1.1] }, + { id: "bomb1", position: [0, 0.2, 1.1] }, + ], + accelerationFloors: [ + // 罠1: 爆弾(0, -1.1)を重心とする正三角形の各頂点に配置 + // 頂点1: テーブル中央側 (0, -0.6) → 爆弾へ [0, 0, -1] + { + id: "accel-l8-0", + position: [0, 0, -0.6], + size: [0.35, 0.3], + direction: [0, 0, -1], + strength: 8, + }, + // 頂点2: 左奥 (-0.43, -1.35) → 爆弾へ [0.87, 0, 0.5] + { + id: "accel-l8-1", + position: [-0.43, 0, -1.35], + size: [0.35, 0.3], + direction: [0.87, 0, 0.5], + strength: 8, + }, + // 頂点3: 右奥 (0.43, -1.35) → 爆弾へ [-0.87, 0, 0.5] + { + id: "accel-l8-2", + position: [0.43, 0, -1.35], + size: [0.35, 0.3], + direction: [-0.87, 0, 0.5], + strength: 8, + }, + // 罠2: 爆弾(0, 1.1)を重心とする正三角形の各頂点に配置 + // 頂点1: テーブル中央側 (0, 0.6) → 爆弾へ [0, 0, 1] + { + id: "accel-l8-3", + position: [0, 0, 0.6], + size: [0.35, 0.3], + direction: [0, 0, 1], + strength: 8, + }, + // 頂点2: 右奥 (0.43, 1.35) → 爆弾へ [-0.87, 0, -0.5] + { + id: "accel-l8-4", + position: [0.43, 0, 1.35], + size: [0.35, 0.3], + direction: [-0.87, 0, -0.5], + strength: 8, + }, + // 頂点3: 左奥 (-0.43, 1.35) → 爆弾へ [0.87, 0, -0.5] + { + id: "accel-l8-5", + position: [-0.43, 0, 1.35], + size: [0.35, 0.3], + direction: [0.87, 0, -0.5], + strength: 8, + }, + ], + balls: [ + { + id: "poolballs0", + textureUrl: poolballs0, + position: [-0.9, 0.2, 0], + shootable: true, + }, + { + id: "poolballs1", + textureUrl: poolballs1, + position: [0, 0.2, -2.0], + }, + { + id: "poolballs2", + textureUrl: poolballs2, + position: [0, 0.2, 2.0], + }, + ], + }, ]; export function getLevelConfig(levelId?: string) { diff --git a/src/gamescene/gimmicDocs/StartModal.tsx b/src/gamescene/gimmicDocs/StartModal.tsx new file mode 100644 index 0000000..1a857fb --- /dev/null +++ b/src/gamescene/gimmicDocs/StartModal.tsx @@ -0,0 +1,39 @@ +type Props = { + title: string; + description: string; + onClose: () => void; +}; + +/** + * ステージ開始時に表示されるギミック説明用のモーダル。 + * UIフレームは共通化し、内容はプロップスで受け取る。 + */ +export function StartModal({ title, description, onClose }: Props) { + return ( +
+
+
+ MISSION DETAILS +
+

+ {title} +

+
+ {description.split("\n").map((line, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: simple static text +

+ {line} +

+ ))} +
+ +
+
+ ); +} diff --git a/src/gamescene/index.tsx b/src/gamescene/index.tsx index c23d41c..b481249 100644 --- a/src/gamescene/index.tsx +++ b/src/gamescene/index.tsx @@ -9,8 +9,11 @@ import { useRef, useState, } from "react"; +import { IoInformationCircleOutline } from "react-icons/io5"; +import { PiMagnetFill } from "react-icons/pi"; import { useNavigate, useParams } from "react-router-dom"; import billiardHallHdr from "../assets/backgroundHDR/billiard_hall_1k.hdr"; +import { AccelerationFloor } from "./components/AccelerationFloor"; import { Ball, type ShootFn } from "./components/Ball"; import { BOMB_RADIUS, Bomb } from "./components/Bomb"; import { BilliardTable } from "./components/billiardTable"; @@ -26,6 +29,7 @@ import { StartBanner } from "./components/StartBanner"; import { TrajectoryLineRaycast } from "./components/TrajectoryLineRaycast"; import { getLevelConfig } from "./constants/levels"; import { BALL_RADIUS, calcStrikeDuration } from "./constants/physics"; +import { StartModal } from "./gimmicDocs/StartModal"; import { findCueRespawnPosition } from "./utils/cueRespawn"; type BallState = { @@ -102,7 +106,31 @@ export default function GameScene() { const [pendingShotResolution, setPendingShotResolution] = useState(false); const [ballStates, setBallStates] = useState>({}); const [bombStates, setBombStates] = useState>({}); + const [isStartModalOpen, setIsStartModalOpen] = useState(true); const [magnetEnabled] = useState(true); // マグネットコントロールのフラグ + const [pressedKey, setPressedKey] = useState(null); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (key === "a" || key === "d") { + setPressedKey(key); + } + }; + const handleKeyUp = (e: KeyboardEvent) => { + const key = e.key.toLowerCase(); + if (key === "a" || key === "d") { + setPressedKey(null); + } + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, []); + const ballPositionsRef = useRef>({}); const gameEndedRef = useRef(false); const hasSeenMovementSinceShotRef = useRef(false); @@ -111,6 +139,7 @@ export default function GameScene() { setBallStates(initialBallState); setBombStates(initialBombState); setMovingBalls({}); + setIsStartModalOpen(true); setIsCharging(false); setShowRoundStart(false); setShotCount(0); @@ -420,6 +449,7 @@ export default function GameScene() { position={ballPositionsRef.current[ball.id] ?? ball.position} velocity={isRespawnedCueBall ? [0, 0, 0] : ball.velocity} portals={portals} + accelerationFloors={level.accelerationFloors} respawnPosition={ ball.id === cueBallId ? state?.respawnPosition : undefined } @@ -430,6 +460,7 @@ export default function GameScene() { allowMagnet={ball.shootable && magnetEnabled} onSelect={ ball.shootable && + !isStartModalOpen && !isCharging && !isStrikeAnimating && !anyBallMoving && @@ -460,6 +491,12 @@ export default function GameScene() { key={`${portal.entry.join(",")}-${portal.exit.join(",")}`} /> ))} + {level.accelerationFloors?.map((floor) => ( + + ))} @@ -491,6 +528,25 @@ export default function GameScene() { remainingBalls={remainingTargetBalls} /> )} + {isStartModalOpen && ( + setIsStartModalOpen(false)} + /> + )} + {!isStartModalOpen && ( + + )} {bombExploded && (

@@ -501,6 +557,23 @@ export default function GameScene() { {isCharging && ( )} + {magnetEnabled && ( +

+
+ ← +
+
+ +
+
+ → +
+
+ )}
); } diff --git a/src/pages/HowToPlayModal.tsx b/src/pages/HowToPlayModal.tsx new file mode 100644 index 0000000..9bd652e --- /dev/null +++ b/src/pages/HowToPlayModal.tsx @@ -0,0 +1,123 @@ +import { X } from "lucide-react"; + +type Props = { + isOpen: boolean; + onClose: () => void; +}; + +export function HowToPlayModal({ isOpen, onClose }: Props) { + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+

+ 遊び方 +

+ +
+ + {/* Content */} +
+ {/* Basic Rule */} +
+

+ Basic Rules +

+

+ 白い球(キューボール)を打ち、ステージ上のすべての的球を6つのポケットのいずれかに落とすとクリアとなります。 + + ※各ステージには「打数制限(SHOT + LIMIT)」があります。制限内に全てのボールを落としましょう。 + +

+
+ + {/* Controls */} +
+

+ Controls +

+
+
+

+ 視点操作 (Mouse) +

+
    +
  • + + 右クリック + Drag: + {" "} + 平行移動 +
  • +
  • + + 左クリック + Drag: + {" "} + 視点回転 +
  • +
  • + Scroll:{" "} + 拡大・縮小 +
  • +
+
+
+

+ キュー注目時 (Keyboard) +

+
    +
  • + + W / S: + {" "} + 上下回転 +
  • +
  • + + A / D: + {" "} + 左右回転 +
  • +
  • + ※精密な角度調整が可能です +
  • +
+
+
+
+ + {/* Gimmicks */} +
+

+ Gimmicks +

+

+ ステージによっては特殊なギミックが登場します。 + 障害物の動きを見極め、反動や隙間を利用して戦略的にクリアを目指してください。 +

+
+
+ + {/* Footer */} +
+ +
+
+
+ ); +} diff --git a/src/pages/MenuScreen.tsx b/src/pages/MenuScreen.tsx index 512692f..e2655c0 100644 --- a/src/pages/MenuScreen.tsx +++ b/src/pages/MenuScreen.tsx @@ -1,7 +1,12 @@ +import { HelpCircle } from "lucide-react"; +import { useState } from "react"; import { Link } from "react-router-dom"; import { LEVELS } from "@/gamescene/constants/levels"; +import { HowToPlayModal } from "./HowToPlayModal"; export function MenuScreen() { + const [isHowToPlayOpen, setIsHowToPlayOpen] = useState(false); + return (
@@ -11,10 +16,21 @@ export function MenuScreen() {

BILLIARDS

-

- 指定打数以内で全ての的球を落とすソロモードです。まずはLevel - 1に挑戦してください。 -

+ +
+

+ 指定打数以内で全ての的球を落とすソロモードです。まずはLevel + 1に挑戦してください。 +

+ +
{LEVELS.map((level) => ( @@ -42,6 +58,11 @@ export function MenuScreen() { ))}
+ + setIsHowToPlayOpen(false)} + />
); } diff --git a/src/pages/howToPlay.tsx b/src/pages/howToPlay.tsx new file mode 100644 index 0000000..e69de29