From 79b400fb00f18f8c4bad5c14252670b2df4de1f7 Mon Sep 17 00:00:00 2001 From: tknkaa Date: Thu, 19 Mar 2026 22:20:39 +0800 Subject: [PATCH 1/8] refactor(simulation): extract camera follow and registry classes --- .../components/CameraController.tsx | 63 +++++-------------- .../Simulation/components/PlanetMesh.tsx | 21 ++----- .../Simulation/core/CameraFollowController.ts | 53 ++++++++++++++++ src/pages/Simulation/core/PlanetRegistry.ts | 42 +++++++++++++ src/pages/Simulation/index.tsx | 8 +-- 5 files changed, 118 insertions(+), 69 deletions(-) create mode 100644 src/pages/Simulation/core/CameraFollowController.ts create mode 100644 src/pages/Simulation/core/PlanetRegistry.ts diff --git a/src/pages/Simulation/components/CameraController.tsx b/src/pages/Simulation/components/CameraController.tsx index 569d4de..22a621f 100644 --- a/src/pages/Simulation/components/CameraController.tsx +++ b/src/pages/Simulation/components/CameraController.tsx @@ -1,17 +1,12 @@ import { useFrame, useThree } from "@react-three/fiber"; -import { useMemo, useRef } from "react"; -import * as THREE from "three"; +import { useEffect, useMemo } from "react"; import type { OrbitControls } from "three-stdlib"; - -// 惑星レジストリのエントリの型を定義 -type PlanetRegistryEntry = { - mesh: THREE.Mesh; - position: React.MutableRefObject; -}; +import { CameraFollowController } from "../core/CameraFollowController"; +import type { PlanetRegistry } from "../core/PlanetRegistry"; type CameraControllerProps = { followedPlanetId: string | null; - planetRegistry: React.MutableRefObject>; + planetRegistry: PlanetRegistry; orbitControlsRef: React.MutableRefObject; }; @@ -21,47 +16,19 @@ export function CameraController({ orbitControlsRef, }: CameraControllerProps) { const { camera } = useThree(); - // useMemo を使ってベクターを初期化し、毎フレームのインスタンス生成を避ける - const previousPos = useMemo(() => new THREE.Vector3(), []); - const currentPos = useMemo(() => new THREE.Vector3(), []); - const delta = useMemo(() => new THREE.Vector3(), []); - const hasPrev = useRef(false); - // 前回のフレームで追尾していた惑星IDを保持する - const prevFollowedPlanetId = useRef(null); - - useFrame(() => { - // 追尾対象が変更されたかチェック - if (prevFollowedPlanetId.current !== followedPlanetId) { - // 変更された場合、カメラ移動の差分計算をリセット - hasPrev.current = false; - prevFollowedPlanetId.current = followedPlanetId; - } - - const controls = orbitControlsRef.current; - if (!controls) return; + const followController = useMemo(() => new CameraFollowController(), []); - // 追尾対象がなければ何もしない - if (!followedPlanetId) return; + useEffect(() => { + return () => followController.reset(); + }, [followController]); - // 毎フレーム、レジストリから最新の追尾対象を取得 - const target = planetRegistry.current.get(followedPlanetId); - if (!target) return; - - // number[] を Vector3 にセット (fromArray を使用してより簡潔に) - currentPos.fromArray(target.position.current); - - if (hasPrev.current) { - delta.copy(currentPos).sub(previousPos); - camera.position.add(delta); - } - - // OrbitControlsのターゲット更新 - controls.target.copy(currentPos); - controls.update(); - - // 前回位置更新 - previousPos.copy(currentPos); - hasPrev.current = true; + useFrame(() => { + followController.update({ + followedPlanetId, + planetRegistry, + camera, + controls: orbitControlsRef.current, + }); }); return null; } diff --git a/src/pages/Simulation/components/PlanetMesh.tsx b/src/pages/Simulation/components/PlanetMesh.tsx index 7c310d4..73fe9b9 100644 --- a/src/pages/Simulation/components/PlanetMesh.tsx +++ b/src/pages/Simulation/components/PlanetMesh.tsx @@ -1,20 +1,15 @@ import { useSphere } from "@react-three/cannon"; import { Trail, useTexture } from "@react-three/drei"; import { useFrame } from "@react-three/fiber"; -import type React from "react"; import { useEffect, useMemo, useRef } from "react"; import * as THREE from "three"; import type { Planet } from "@/types/planet"; +import type { PlanetRegistry } from "../core/PlanetRegistry"; import { calcGravityForce } from "../utils/gravityUtils"; type PlanetMeshProps = { planet: Planet; - planetRegistry: React.MutableRefObject< - Map< - string, - { mesh: THREE.Mesh; position: React.MutableRefObject } - > - >; + planetRegistry: PlanetRegistry; onExplosion: (position: THREE.Vector3, radius: number) => void; onSelect: (planetId: string) => void; }; @@ -67,8 +62,6 @@ export function PlanetMesh({ // マウント時に自分のMeshをレジストリに登録し、他の惑星から参照できるようにする useEffect(() => { - if (!planetRegistry.current) return; - if (ref.current) { // 質量計算用にuserDataに保存 ref.current.userData = { @@ -76,15 +69,13 @@ export function PlanetMesh({ id: planet.id, radius: planet.radius, }; - planetRegistry.current.set(planet.id, { + planetRegistry.register(planet.id, { mesh: ref.current, position, }); } return () => { - if (planetRegistry.current) { - planetRegistry.current.delete(planet.id); - } + planetRegistry.unregister(planet.id); }; }, [planet.id, planetRegistry, planet.mass, planet.radius, ref]); @@ -95,7 +86,7 @@ export function PlanetMesh({ // This hook runs every frame (approx 60fps) useFrame(() => { - if (!ref.current || !planetRegistry.current) return; + if (!ref.current) return; // 誤差による自転速度の異常上昇を防ぐ api.angularVelocity.set(0, planet.rotationSpeedY, 0); @@ -105,7 +96,7 @@ export function PlanetMesh({ forceAccumulator.set(0, 0, 0); // 毎フレームリセットして使い回す // 他のすべての惑星からの引力を計算して合算 - for (const [otherId, other] of planetRegistry.current) { + for (const [otherId, other] of planetRegistry) { if (otherId === planet.id) continue; const { mesh: otherMesh, position: otherPosition } = other; diff --git a/src/pages/Simulation/core/CameraFollowController.ts b/src/pages/Simulation/core/CameraFollowController.ts new file mode 100644 index 0000000..1728382 --- /dev/null +++ b/src/pages/Simulation/core/CameraFollowController.ts @@ -0,0 +1,53 @@ +import * as THREE from "three"; +import type { OrbitControls } from "three-stdlib"; +import type { PlanetRegistry } from "./PlanetRegistry"; + +type CameraFollowUpdateParams = { + followedPlanetId: string | null; + planetRegistry: PlanetRegistry; + camera: THREE.Camera; + controls: OrbitControls | null; +}; + +export class CameraFollowController { + private readonly previousPos = new THREE.Vector3(); + private readonly currentPos = new THREE.Vector3(); + private readonly delta = new THREE.Vector3(); + private hasPrev = false; + private prevFollowedPlanetId: string | null = null; + + update({ + followedPlanetId, + planetRegistry, + camera, + controls, + }: CameraFollowUpdateParams) { + if (this.prevFollowedPlanetId !== followedPlanetId) { + this.hasPrev = false; + this.prevFollowedPlanetId = followedPlanetId; + } + + if (!controls || !followedPlanetId) return; + + const target = planetRegistry.get(followedPlanetId); + if (!target) return; + + this.currentPos.fromArray(target.position.current); + + if (this.hasPrev) { + this.delta.copy(this.currentPos).sub(this.previousPos); + camera.position.add(this.delta); + } + + controls.target.copy(this.currentPos); + controls.update(); + + this.previousPos.copy(this.currentPos); + this.hasPrev = true; + } + + reset() { + this.hasPrev = false; + this.prevFollowedPlanetId = null; + } +} diff --git a/src/pages/Simulation/core/PlanetRegistry.ts b/src/pages/Simulation/core/PlanetRegistry.ts new file mode 100644 index 0000000..04b2b24 --- /dev/null +++ b/src/pages/Simulation/core/PlanetRegistry.ts @@ -0,0 +1,42 @@ +import type * as THREE from "three"; + +export type PositionRef = { + current: number[]; +}; + +export type PlanetRegistryEntry = { + mesh: THREE.Mesh; + position: PositionRef; +}; + +export class PlanetRegistry implements Iterable<[string, PlanetRegistryEntry]> { + private readonly entries = new Map(); + + register(id: string, entry: PlanetRegistryEntry) { + this.entries.set(id, entry); + } + + unregister(id: string) { + this.entries.delete(id); + } + + get(id: string) { + return this.entries.get(id); + } + + has(id: string) { + return this.entries.has(id); + } + + clear() { + this.entries.clear(); + } + + get size() { + return this.entries.size; + } + + [Symbol.iterator](): Iterator<[string, PlanetRegistryEntry]> { + return this.entries[Symbol.iterator](); + } +} diff --git a/src/pages/Simulation/index.tsx b/src/pages/Simulation/index.tsx index 6d964c2..4840fdf 100644 --- a/src/pages/Simulation/index.tsx +++ b/src/pages/Simulation/index.tsx @@ -15,6 +15,7 @@ import { PlacementSurface, PreviewPlanet, } from "./components/PlanetPlacementView"; +import { PlanetRegistry } from "./core/PlanetRegistry"; const planetTexturePaths = [ earth.texturePath, @@ -34,12 +35,7 @@ function computeMass(radius: number, mass: number, newRadius: number) { export default function Page() { const orbitControlsRef = useRef(null); - const planetRegistry = useRef< - Map< - string, - { mesh: THREE.Mesh; position: React.MutableRefObject } - > - >(new Map()); + const planetRegistry = useMemo(() => new PlanetRegistry(), []); const [planets, setPlanets] = useState([earth]); const [explosions, setExplosions] = useState([]); From 095aa9f3d2aa380615017eb52e78aaafd7be31a7 Mon Sep 17 00:00:00 2001 From: tknkaa Date: Thu, 19 Mar 2026 22:25:09 +0800 Subject: [PATCH 2/8] refactor(simulation): extract gravity system class --- .../Simulation/components/PlanetMesh.tsx | 32 +++------ src/pages/Simulation/core/GravitySystem.ts | 70 +++++++++++++++++++ 2 files changed, 80 insertions(+), 22 deletions(-) create mode 100644 src/pages/Simulation/core/GravitySystem.ts diff --git a/src/pages/Simulation/components/PlanetMesh.tsx b/src/pages/Simulation/components/PlanetMesh.tsx index 73fe9b9..53f566a 100644 --- a/src/pages/Simulation/components/PlanetMesh.tsx +++ b/src/pages/Simulation/components/PlanetMesh.tsx @@ -4,8 +4,8 @@ import { useFrame } from "@react-three/fiber"; import { useEffect, useMemo, useRef } from "react"; import * as THREE from "three"; import type { Planet } from "@/types/planet"; +import { GravitySystem } from "../core/GravitySystem"; import type { PlanetRegistry } from "../core/PlanetRegistry"; -import { calcGravityForce } from "../utils/gravityUtils"; type PlanetMeshProps = { planet: Planet; @@ -80,9 +80,9 @@ export function PlanetMesh({ }, [planet.id, planetRegistry, planet.mass, planet.radius, ref]); // 計算用ベクトルをメモリに保持しておく(毎フレームnewしないため) + const gravitySystem = useMemo(() => new GravitySystem(), []); const forceAccumulator = useMemo(() => new THREE.Vector3(), []); const myPosVec = useMemo(() => new THREE.Vector3(), []); - const otherPosVec = useMemo(() => new THREE.Vector3(), []); // This hook runs every frame (approx 60fps) useFrame(() => { @@ -93,27 +93,15 @@ export function PlanetMesh({ // ref.current.positionの代わりに、物理エンジンから取得した位置を使用 myPosVec.fromArray(position.current); - forceAccumulator.set(0, 0, 0); // 毎フレームリセットして使い回す - // 他のすべての惑星からの引力を計算して合算 - for (const [otherId, other] of planetRegistry) { - if (otherId === planet.id) continue; - - const { mesh: otherMesh, position: otherPosition } = other; - otherPosVec.fromArray(otherPosition.current); - const otherMass = otherMesh.userData.mass || 1; - const otherRadius = otherMesh.userData.radius || 0.1; - - const force = calcGravityForce( - myPosVec, - planet.mass, - planet.radius, - otherPosVec, - otherMass, - otherRadius, - ); - forceAccumulator.add(force); - } + gravitySystem.accumulateForPlanet({ + planetId: planet.id, + targetMass: planet.mass, + targetRadius: planet.radius, + targetPosition: myPosVec, + planetRegistry, + outForce: forceAccumulator, + }); // 計算した力を重心に適用 api.applyForce(forceAccumulator.toArray(), myPosVec.toArray()); diff --git a/src/pages/Simulation/core/GravitySystem.ts b/src/pages/Simulation/core/GravitySystem.ts new file mode 100644 index 0000000..2b097ff --- /dev/null +++ b/src/pages/Simulation/core/GravitySystem.ts @@ -0,0 +1,70 @@ +import * as THREE from "three"; +import type { PlanetRegistry } from "./PlanetRegistry"; + +const G = 1; +const SOFTENING_FACTOR = 0.005; + +type AccumulateForPlanetParams = { + planetId: string; + targetMass: number; + targetRadius: number; + targetPosition: THREE.Vector3; + planetRegistry: PlanetRegistry; + outForce: THREE.Vector3; +}; + +export class GravitySystem { + private readonly direction = new THREE.Vector3(); + private readonly sourcePosition = new THREE.Vector3(); + + accumulateForPlanet({ + planetId, + targetMass, + targetRadius, + targetPosition, + planetRegistry, + outForce, + }: AccumulateForPlanetParams) { + outForce.set(0, 0, 0); + + for (const [otherId, other] of planetRegistry) { + if (otherId === planetId) continue; + + const { mesh: otherMesh, position: otherPosition } = other; + this.sourcePosition.fromArray(otherPosition.current); + const sourceMass = otherMesh.userData.mass || 1; + const sourceRadius = otherMesh.userData.radius || 0.1; + + this.addPairForce( + outForce, + targetPosition, + targetMass, + targetRadius, + this.sourcePosition, + sourceMass, + sourceRadius, + ); + } + + return outForce; + } + + private addPairForce( + outForce: THREE.Vector3, + targetPos: THREE.Vector3, + targetMass: number, + targetRadius: number, + sourcePos: THREE.Vector3, + sourceMass: number, + sourceRadius: number, + ) { + const eps = ((targetRadius + sourceRadius) / 2) * SOFTENING_FACTOR; + + this.direction.subVectors(sourcePos, targetPos); + const distanceSq = this.direction.lengthSq() + eps ** 2; + const distance = Math.sqrt(distanceSq); + const forceScalar = (G * targetMass * sourceMass) / (distanceSq * distance); + + outForce.addScaledVector(this.direction, forceScalar); + } +} From 277f9834881dce32e7dca8b24a374eb870e6be5b Mon Sep 17 00:00:00 2001 From: tknkaa Date: Thu, 19 Mar 2026 22:27:18 +0800 Subject: [PATCH 3/8] refactor(simulation): introduce simulation world state class --- src/pages/Simulation/core/SimulationWorld.ts | 96 ++++++++++++++++ src/pages/Simulation/index.tsx | 112 ++++++++----------- 2 files changed, 140 insertions(+), 68 deletions(-) create mode 100644 src/pages/Simulation/core/SimulationWorld.ts diff --git a/src/pages/Simulation/core/SimulationWorld.ts b/src/pages/Simulation/core/SimulationWorld.ts new file mode 100644 index 0000000..ee07b31 --- /dev/null +++ b/src/pages/Simulation/core/SimulationWorld.ts @@ -0,0 +1,96 @@ +import * as THREE from "three"; +import type { ExplosionData } from "@/types/Explosion"; +import type { Planet } from "@/types/planet"; + +type NewPlanetSettings = { + radius: number; + position: [number, number, number]; + rotationSpeedY: number; +}; + +export type SimulationWorldSnapshot = { + planets: Planet[]; + explosions: ExplosionData[]; + followedPlanetId: string | null; +}; + +function computeMass(radius: number, mass: number, newRadius: number) { + return mass * (newRadius / radius) ** 3; +} + +function clonePlanet(planet: Planet): Planet { + return { + ...planet, + position: planet.position.clone(), + velocity: planet.velocity.clone(), + }; +} + +export class SimulationWorld { + private planets: Planet[]; + private explosions: ExplosionData[] = []; + private followedPlanetId: string | null = null; + + constructor(initialPlanets: Planet[]) { + this.planets = initialPlanets.map(clonePlanet); + } + + addPlanetFromTemplate(template: Planet, settings: NewPlanetSettings) { + const [posX, posY, posZ] = settings.position; + const mass = computeMass(template.radius, template.mass, settings.radius); + + this.planets = [ + ...this.planets, + { + id: crypto.randomUUID(), + name: template.name, + texturePath: template.texturePath, + rotationSpeedY: settings.rotationSpeedY, + radius: settings.radius, + width: 64, + height: 64, + position: new THREE.Vector3(posX, posY, posZ), + velocity: new THREE.Vector3(0, 0, 0), + mass, + }, + ]; + } + + removePlanet(planetId: string) { + this.planets = this.planets.filter((planet) => planet.id !== planetId); + if (this.followedPlanetId === planetId) { + this.followedPlanetId = null; + } + } + + setFollowedPlanetId(planetId: string | null) { + this.followedPlanetId = planetId; + } + + registerExplosion(position: THREE.Vector3, radius: number) { + if (this.explosions.some((e) => e.position.distanceTo(position) < 2)) { + return; + } + + this.explosions = [ + ...this.explosions, + { + id: crypto.randomUUID(), + radius: radius * 1.5, + position: position.clone(), + fragmentCount: 50, + }, + ]; + } + + getSnapshot(): SimulationWorldSnapshot { + return { + planets: this.planets.map(clonePlanet), + explosions: this.explosions.map((explosion) => ({ + ...explosion, + position: explosion.position.clone(), + })), + followedPlanetId: this.followedPlanetId, + }; + } +} diff --git a/src/pages/Simulation/index.tsx b/src/pages/Simulation/index.tsx index 4840fdf..513f3a2 100644 --- a/src/pages/Simulation/index.tsx +++ b/src/pages/Simulation/index.tsx @@ -3,11 +3,8 @@ import { OrbitControls, Stars, useTexture } from "@react-three/drei"; import { Canvas } from "@react-three/fiber"; import { button, useControls } from "leva"; import { Suspense, useMemo, useRef, useState } from "react"; -import * as THREE from "three"; import type { OrbitControls as Controls } from "three-stdlib"; import { earth, jupiter, mars, sun, venus } from "@/data/planets"; -import type { ExplosionData } from "@/types/Explosion"; -import type { Planet } from "@/types/planet"; import { CameraController } from "./components/CameraController"; import { Explosion } from "./components/Explosion"; import { PlanetMesh } from "./components/PlanetMesh"; @@ -16,6 +13,7 @@ import { PreviewPlanet, } from "./components/PlanetPlacementView"; import { PlanetRegistry } from "./core/PlanetRegistry"; +import { SimulationWorld } from "./core/SimulationWorld"; const planetTexturePaths = [ earth.texturePath, @@ -28,22 +26,22 @@ useTexture.preload(planetTexturePaths); const planetTemplates = { earth, sun, mars, jupiter, venus } as const; -function computeMass(radius: number, mass: number, newRadius: number) { - const newMass = mass * (newRadius / radius) ** 3; - return newMass; -} - export default function Page() { const orbitControlsRef = useRef(null); const planetRegistry = useMemo(() => new PlanetRegistry(), []); + const simulationWorld = useMemo(() => new SimulationWorld([earth]), []); - const [planets, setPlanets] = useState([earth]); - const [explosions, setExplosions] = useState([]); - const [followedPlanetId, setFollowedPlanetId] = useState(null); + const [worldState, setWorldState] = useState(() => + simulationWorld.getSnapshot(), + ); const [placementMode, setPlacementMode] = useState(false); const [placementPanelOpen, setPlacementPanelOpen] = useState(true); + const syncWorld = () => { + setWorldState(simulationWorld.getSnapshot()); + }; + const [planetControls, setPlanetControls, getPlanetControl] = useControls( "New Planet", () => ({ @@ -88,31 +86,12 @@ export default function Page() { rotationSpeedY: getPlanetControl("rotationSpeedY"), }; - const newMass = computeMass( - template.radius, - template.mass, - settings.radius, - ); - - setPlanets((prev) => [ - ...prev, - { - id: crypto.randomUUID(), - name: template.name, - texturePath: template.texturePath, - rotationSpeedY: settings.rotationSpeedY, - radius: settings.radius, - width: 64, - height: 64, - position: new THREE.Vector3( - settings.posX, - settings.posY, - settings.posZ, - ), - velocity: new THREE.Vector3(0, 0, 0), - mass: newMass, - }, - ]); + simulationWorld.addPlanetFromTemplate(template, { + radius: settings.radius, + position: [settings.posX, settings.posY, settings.posZ], + rotationSpeedY: settings.rotationSpeedY, + }); + syncWorld(); }), }); @@ -143,28 +122,16 @@ export default function Page() { }; const removePlanet = (planetId: string) => { - setPlanets((prev) => prev.filter((p) => p.id !== planetId)); - if (followedPlanetId === planetId) { - setFollowedPlanetId(null); - } + simulationWorld.removePlanet(planetId); + syncWorld(); }; - const handleExplosion = (position: THREE.Vector3, radius: number) => { - // 連続爆発を防ぐための簡易的なデバウンス処理などをここに追加しても良い - setExplosions((prev) => { - // 同じ場所での重複爆発を簡易的に防ぐ - if (prev.some((e) => e.position.distanceTo(position) < 2)) return prev; - - return [ - ...prev, - { - id: crypto.randomUUID(), - radius: radius * 1.5, - position: position.clone(), - fragmentCount: 50, - }, - ]; - }); + const handleExplosion = ( + position: import("three").Vector3, + radius: number, + ) => { + simulationWorld.registerExplosion(position, radius); + syncWorld(); }; return ( @@ -181,19 +148,22 @@ export default function Page() { - {planets.map((planet) => ( + {worldState.planets.map((planet) => ( setFollowedPlanetId(id)} + onSelect={(id) => { + simulationWorld.setFollowedPlanetId(id); + syncWorld(); + }} /> ))} @@ -214,7 +184,7 @@ export default function Page() { {showGrid && } {showAxes && } - {explosions.map((exp) => ( + {worldState.explosions.map((exp) => ( ))} @@ -257,13 +227,13 @@ export default function Page() { ONの間は水色の面をクリックすると、座標が自動入力されます。

- {followedPlanetId && ( + {worldState.followedPlanetId && (
追尾中: {(() => { - const planet = planets.find( - (p) => p.id === followedPlanetId, + const planet = worldState.planets.find( + (p) => p.id === worldState.followedPlanetId, ); return planet ? ( <> @@ -278,7 +248,10 @@ export default function Page() {
)} - 追加済み惑星 ({planets.length}) + 追加済み惑星 ({worldState.planets.length})
    - {planets.map((planet) => ( + {worldState.planets.map((planet) => (
- {followedPlanetId === planet.id ? ( + {worldState.followedPlanetId === planet.id ? ( 追尾中 ) : (