diff --git a/package-lock.json b/package-lock.json index 3b87578..592b8d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,6 +63,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1777,6 +1778,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.5.0.tgz", "integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2557,6 +2559,7 @@ "integrity": "sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2572,6 +2575,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2582,6 +2586,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2606,6 +2611,7 @@ "resolved": "https://registry.npmjs.org/@types/three/-/three-0.182.0.tgz", "integrity": "sha512-WByN9V3Sbwbe2OkWuSGyoqQO8Du6yhYaXtXLoA5FkKTUJorZ+yOHBZ35zUUPQXlAKABZmbYp5oAqpA4RBjtJ/Q==", "license": "MIT", + "peer": true, "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", "@tweenjs/tween.js": "~23.1.3", @@ -2744,6 +2750,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2820,7 +2827,8 @@ "version": "0.20.0", "resolved": "https://registry.npmjs.org/cannon-es/-/cannon-es-0.20.0.tgz", "integrity": "sha512-eZhWTZIkFOnMAJOgfXJa9+b3kVlvG+FX4mdkpePev/w/rP5V8NRquGyEozcjPfEoXUlb+p7d9SUcmDSn14prOA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cannon-es-debugger": { "version": "1.0.0", @@ -3889,6 +3897,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3957,6 +3966,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3976,6 +3986,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -4285,7 +4296,8 @@ "version": "0.182.0", "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/three-mesh-bvh": { "version": "0.8.3", @@ -4415,6 +4427,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4491,6 +4504,7 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", 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/Explosion.tsx b/src/pages/Simulation/components/Explosion.tsx index 65d2218..d309edc 100644 --- a/src/pages/Simulation/components/Explosion.tsx +++ b/src/pages/Simulation/components/Explosion.tsx @@ -1,5 +1,5 @@ import { useFrame } from "@react-three/fiber"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useState } from "react"; import * as THREE from "three"; import type { ExplosionData } from "@/types/Explosion"; @@ -17,7 +17,6 @@ type ExplosionProps = { }; export function Explosion({ explosion, onComplete }: ExplosionProps) { - const groupRef = useRef(null); const [fragments, setFragments] = useState([]); // 爆発初期化 @@ -118,7 +117,7 @@ export function Explosion({ explosion, onComplete }: ExplosionProps) { }); return ( - + {fragments.map((f) => ( ))} diff --git a/src/pages/Simulation/components/PlanetMesh.tsx b/src/pages/Simulation/components/PlanetMesh.tsx index 7c310d4..e0c814d 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 { useEffect, useMemo } from "react"; import * as THREE from "three"; import type { Planet } from "@/types/planet"; -import { calcGravityForce } from "../utils/gravityUtils"; +import { GravitySystem } from "../core/GravitySystem"; +import type { PlanetRegistry, PositionRef } from "../core/PlanetRegistry"; 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; }; @@ -25,50 +20,45 @@ export function PlanetMesh({ onExplosion, onSelect, }: PlanetMeshProps) { - const [ref, api] = useSphere( - () => ({ - mass: planet.mass, - args: [planet.radius], - position: [planet.position.x, planet.position.y, planet.position.z], - velocity: [planet.velocity.x, planet.velocity.y, planet.velocity.z], - angularVelocity: [0, planet.rotationSpeedY, 0], // 物理エンジンでY軸周りの角速度を設定 - linearDamping: 0, // 宇宙空間なので抵抗なし - angularDamping: 0, // 宇宙空間なので回転の減衰もない - onCollide: (e) => { - // 衝突時の衝撃が一定以上なら爆発とみなす - if (e.contact.impactVelocity > 0.5) { - const contactPoint = new THREE.Vector3( - e.contact.contactPoint[0], - e.contact.contactPoint[1], - e.contact.contactPoint[2], - ); - onExplosion(contactPoint, planet.radius); - } - }, - }), - useRef(null), - ); + const [ref, api] = useSphere(() => ({ + mass: planet.mass, + args: [planet.radius], + position: [planet.position.x, planet.position.y, planet.position.z], + velocity: [planet.velocity.x, planet.velocity.y, planet.velocity.z], + angularVelocity: [0, planet.rotationSpeedY, 0], // 物理エンジンでY軸周りの角速度を設定 + linearDamping: 0, // 宇宙空間なので抵抗なし + angularDamping: 0, // 宇宙空間なので回転の減衰もない + onCollide: (e) => { + // 衝突時の衝撃が一定以上なら爆発とみなす + if (e.contact.impactVelocity > 0.5) { + const contactPoint = new THREE.Vector3( + e.contact.contactPoint[0], + e.contact.contactPoint[1], + e.contact.contactPoint[2], + ); + onExplosion(contactPoint, planet.radius); + } + }, + })); // Load the texture (you can use any public Earth texture URL) const [colorMap] = useTexture([planet.texturePath]); - // 物理エンジンの位置を追跡するためのref - const position = useRef([ - planet.position.x, - planet.position.y, - planet.position.z, - ]); + const position = useMemo( + () => ({ + current: [planet.position.x, planet.position.y, planet.position.z], + }), + [planet.position.x, planet.position.y, planet.position.z], + ); useEffect(() => { const unsubscribe = api.position.subscribe((v) => { position.current = v; }); return () => unsubscribe(); // アンマウント時に購読解除 - }, [api.position]); + }, [api.position, position]); // マウント時に自分のMeshをレジストリに登録し、他の惑星から参照できるようにする useEffect(() => { - if (!planetRegistry.current) return; - if (ref.current) { // 質量計算用にuserDataに保存 ref.current.userData = { @@ -76,53 +66,39 @@ 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]); + }, [planet.id, planetRegistry, planet.mass, planet.radius, ref, position]); // 計算用ベクトルをメモリに保持しておく(毎フレーム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(() => { - if (!ref.current || !planetRegistry.current) return; + if (!ref.current) return; // 誤差による自転速度の異常上昇を防ぐ api.angularVelocity.set(0, planet.rotationSpeedY, 0); // ref.current.positionの代わりに、物理エンジンから取得した位置を使用 myPosVec.fromArray(position.current); - forceAccumulator.set(0, 0, 0); // 毎フレームリセットして使い回す - - // 他のすべての惑星からの引力を計算して合算 - for (const [otherId, other] of planetRegistry.current) { - 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/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/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); + } +} 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/core/SimulationWorld.ts b/src/pages/Simulation/core/SimulationWorld.ts new file mode 100644 index 0000000..a8aca14 --- /dev/null +++ b/src/pages/Simulation/core/SimulationWorld.ts @@ -0,0 +1,112 @@ +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; + private snapshot: SimulationWorldSnapshot; + + constructor(initialPlanets: Planet[]) { + this.planets = initialPlanets.map(clonePlanet); + this.snapshot = this.buildSnapshot(); + } + + private buildSnapshot(): SimulationWorldSnapshot { + return { + planets: this.planets, + explosions: this.explosions, + followedPlanetId: this.followedPlanetId, + }; + } + + private updateSnapshot() { + this.snapshot = this.buildSnapshot(); + } + + 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, + }, + ]; + this.updateSnapshot(); + } + + removePlanet(planetId: string) { + this.planets = this.planets.filter((planet) => planet.id !== planetId); + if (this.followedPlanetId === planetId) { + this.followedPlanetId = null; + } + this.updateSnapshot(); + } + + setFollowedPlanetId(planetId: string | null) { + this.followedPlanetId = planetId; + this.updateSnapshot(); + } + + 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, + }, + ]; + this.updateSnapshot(); + } + + completeExplosion(explosionId: string) { + this.explosions = this.explosions.filter( + (explosion) => explosion.id !== explosionId, + ); + this.updateSnapshot(); + } + + getSnapshot(): SimulationWorldSnapshot { + return this.snapshot; + } +} diff --git a/src/pages/Simulation/index.tsx b/src/pages/Simulation/index.tsx index 6d964c2..59c672c 100644 --- a/src/pages/Simulation/index.tsx +++ b/src/pages/Simulation/index.tsx @@ -3,11 +3,9 @@ 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 { Vector3 } 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"; @@ -15,6 +13,8 @@ import { PlacementSurface, PreviewPlanet, } from "./components/PlanetPlacementView"; +import { PlanetRegistry } from "./core/PlanetRegistry"; +import { SimulationWorld } from "./core/SimulationWorld"; const planetTexturePaths = [ earth.texturePath, @@ -27,27 +27,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 = useRef< - Map< - string, - { mesh: THREE.Mesh; position: React.MutableRefObject } - > - >(new Map()); + 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", () => ({ @@ -92,31 +87,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(); }), }); @@ -147,28 +123,13 @@ 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: Vector3, radius: number) => { + simulationWorld.registerExplosion(position, radius); + syncWorld(); }; return ( @@ -185,19 +146,22 @@ export default function Page() { - {planets.map((planet) => ( + {worldState.planets.map((planet) => ( setFollowedPlanetId(id)} + onSelect={(id) => { + simulationWorld.setFollowedPlanetId(id); + syncWorld(); + }} /> ))} @@ -218,8 +182,15 @@ export default function Page() { {showGrid && } {showAxes && } - {explosions.map((exp) => ( - + {worldState.explosions.map((exp) => ( + { + simulationWorld.completeExplosion(exp.id); + syncWorld(); + }} + /> ))} {/* Optional background and controls */} @@ -261,13 +232,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 ? ( <> @@ -282,7 +253,10 @@ export default function Page() {
)} - 追加済み惑星 ({planets.length}) + 追加済み惑星 ({worldState.planets.length})
    - {planets.map((planet) => ( + {worldState.planets.map((planet) => (
- {followedPlanetId === planet.id ? ( + {worldState.followedPlanetId === planet.id ? ( 追尾中 ) : (