終於來到 Day 30。
從第一天認識 Modern Web,到今天我們已經完成一個可以實際操作的:
Interactive Solar System
這 30 天不是單純把八顆行星畫在畫面上,而是一步一步把
各種學習到的東西整合成一個完整的網頁作品。
今天不再新增新的大型功能,而是把前面所有內容整理成 Final Version
並提供完整
index.html
style.css
main.js
textures/
讓整個系列正式完成。
目前的互動式太陽系包含太陽和八大行星
並加入:
Planet Self Rotation
Planet Revolution
Orbit Lines
Planet Textures
PointLight
AmbientLight
Shadow
Earth Night Lights
Moon
Moon Orbit
Venus Atmosphere
Saturn Ring
Starfield
OrbitControls
Play / Pause
Animation Speed
Raycaster
Planet Selection
Orbit Highlight
Planet Focus
Planet Information
Camera Presets
Responsive Design
Desktop / Mobile Support
整理後的資料夾:
solar-system/
│
├── index.html
│
├── css/
│ └── style.css
│
├── js/
│ └── main.js
│
└── textures/
├── sun.jpg
├── mercury.jpg
├── venus.jpg
├── venus_atmosphere.jpg
├── earth.jpg
├── earth_night.jpg
├── moon.jpg
├── mars.jpg
├── jupiter.jpg
├── saturn.jpg
├── saturn_ring.png
├── uranus.jpg
└── neptune.jpg
最重要的是路徑一定要正確。
這個專案使用 type="module"
以及 Three.js import * as THREE from "three";
建議使用 VS Code:Live Server 開啟專案。
在 VS Code 對 index.html 按右鍵:
Open with Live Server
接著瀏覽器通常會開啟:
http://127.0.0.1:5500/
這次我們也真的測試了手機版。
方法是讓手機 + 筆電位於同一個區域網路。
例如手機開啟熱點:
手機 Hotspot
↓
筆電連上手機 Hotspot
接著 Windows CMD:
ipconfig
找到筆電目前的:
IPv4 Address
假設 192.168.43.100
Live Server Port 是 5500
手機就輸入:
http://192.168.43.100:5500/
即可從手機直接觀看作品。
如果遇到 ERR_CONNECTION_TIMED_OUT
除了檢查:
IP
Port
Windows Firewall
之外,也可以更換較穩定的區域網路環境。
這次實際測試時,改用:
手機 Hotspot
↓
筆電連接
之後就能正常開啟。
這也是整個系列很實際的一次
Network Debug 經驗。
這裡也需要再次說明:
本作品以太陽系真實特徵為參考,但為兼顧網頁視覺效果與互動操作,行星尺寸、距離與運行速度皆採視覺化比例呈現,並非真實天文尺度。
例如目前:
Mercury → 4
Venus → 6
Earth → 8.5
Mars → 12
Jupiter → 18
Saturn → 24
Uranus → 30
Neptune → 36
這些 distance 是 Three.js Scene 裡的視覺化距離。
不是真實天文距離
同樣地:
rotationSpeed
orbitSpeed
也是為了讓網頁動畫容易觀察所設定的速度。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>
從 Web 到宇宙
</title>
<link
rel="stylesheet"
href="css/style.css"
>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.180.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.180.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="scene"></div>
<div class="title">
<h1>
從 Web 到宇宙
</h1>
<p>
Interactive Solar System
</p>
</div>
<div class="control-panel">
<h2>
Solar System Controls
</h2>
<label>
<input
type="checkbox"
id="orbitToggle"
checked
>
顯示軌道
</label>
<label>
<input
type="checkbox"
id="venusToggle"
checked
>
金星大氣層
</label>
<label>
<input
type="checkbox"
id="starToggle"
checked
>
星空背景
</label>
<hr>
<button id="playPauseButton">
⏸ Pause
</button>
<div class="speed-control">
<label for="speedSlider">
Animation Speed
</label>
<input
type="range"
id="speedSlider"
min="0.1"
max="5"
step="0.1"
value="1"
>
<span id="speedValue">
1.0x
</span>
</div>
</div>
<div
class="planet-info"
id="planetInfo"
>
<h2 id="planetName">
Planet
</h2>
<p id="planetChineseName"></p>
<p id="planetOrder"></p>
<p id="planetDescription"></p>
<button id="returnButton">
返回太陽系
</button>
</div>
<script
type="module"
src="js/main.js"
></script>
</body>
</html>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
body {
background: #000000;
color: white;
font-family:
Arial,
sans-serif;
}
/* ========================
Three.js Scene
======================== */
#scene {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
#scene canvas {
display: block;
width: 100%;
height: 100%;
}
/* ========================
Title
======================== */
.title {
position: fixed;
top: 30px;
left: 40px;
z-index: 10;
pointer-events: none;
}
.title h1 {
margin: 0;
font-size: 36px;
letter-spacing: 2px;
}
.title p {
margin-top: 8px;
color: #aaaaaa;
font-size: 16px;
}
/* ========================
Control Panel
======================== */
.control-panel {
position: fixed;
top: 30px;
right: 30px;
z-index: 20;
width: 230px;
padding: 18px;
background:
rgba(
15,
15,
25,
0.8
);
border:
1px solid
rgba(
255,
255,
255,
0.15
);
border-radius: 12px;
backdrop-filter:
blur(10px);
}
.control-panel h2 {
margin:
0
0
16px;
font-size: 16px;
}
.control-panel label {
display: block;
margin-top: 12px;
font-size: 14px;
cursor: pointer;
}
.control-panel input {
margin-right: 8px;
cursor: pointer;
}
.control-panel hr {
margin: 18px 0;
border: none;
border-top:
1px solid
rgba(
255,
255,
255,
0.15
);
}
.control-panel button {
width: 100%;
padding: 10px;
border: none;
border-radius: 8px;
background:
rgba(
255,
255,
255,
0.12
);
color: white;
font-size: 14px;
cursor: pointer;
}
.control-panel button:hover {
background:
rgba(
255,
255,
255,
0.2
);
}
/* ========================
Speed Control
======================== */
.speed-control {
margin-top: 18px;
}
.speed-control label {
margin-bottom: 8px;
}
.speed-control input {
width: 100%;
margin: 8px 0;
}
#speedValue {
display: block;
text-align: right;
color: #aaaaaa;
font-size: 13px;
}
/* ========================
Planet Info
======================== */
.planet-info {
position: fixed;
left: 40px;
bottom: 40px;
z-index: 20;
width: 280px;
padding: 20px;
background:
rgba(
15,
15,
25,
0.82
);
border:
1px solid
rgba(
255,
255,
255,
0.15
);
border-radius: 12px;
backdrop-filter:
blur(10px);
display: none;
}
.planet-info.active {
display: block;
}
.planet-info h2 {
margin:
0
0
12px;
}
.planet-info p {
margin: 8px 0;
color: #cccccc;
font-size: 14px;
line-height: 1.5;
}
.planet-info button {
width: 100%;
margin-top: 14px;
padding: 10px;
border: none;
border-radius: 8px;
background:
rgba(
255,
255,
255,
0.12
);
color: white;
cursor: pointer;
}
.planet-info button:hover {
background:
rgba(
255,
255,
255,
0.2
);
}
/* ========================
Responsive
======================== */
@media (
max-width: 768px
) {
.title {
top: 18px;
left: 18px;
}
.title h1 {
font-size: 24px;
}
.title p {
font-size: 13px;
}
.control-panel {
top: 90px;
right: 12px;
width: 190px;
padding: 14px;
max-height:
calc(
100vh -
110px
);
overflow-y: auto;
}
.control-panel h2 {
font-size: 14px;
}
.control-panel label,
.control-panel button {
font-size: 13px;
}
.planet-info {
left: 12px;
right: 12px;
bottom: 12px;
width: auto;
padding: 16px;
}
}
import * as THREE from
"three";
import { OrbitControls } from
"three/addons/controls/OrbitControls.js";
// ========================
// Scene
// ========================
const scene =
new THREE.Scene();
scene.background =
new THREE.Color(
0x000000
);
// ========================
// Camera
// ========================
const camera =
new THREE.PerspectiveCamera(
60,
window.innerWidth /
window.innerHeight,
0.1,
1000
);
if (
window.innerWidth <=
768
) {
camera.position.set(
45,
40,
55
);
} else {
camera.position.set(
30,
25,
30
);
}
camera.lookAt(
0,
0,
0
);
// ========================
// Renderer
// ========================
const renderer =
new THREE.WebGLRenderer({
antialias: true
});
renderer.setSize(
window.innerWidth,
window.innerHeight
);
renderer.setPixelRatio(
Math.min(
window.devicePixelRatio,
2
)
);
renderer.shadowMap.enabled =
true;
renderer.shadowMap.type =
THREE.PCFSoftShadowMap;
document
.getElementById(
"scene"
)
.appendChild(
renderer.domElement
);
// ========================
// OrbitControls
// ========================
const controls =
new OrbitControls(
camera,
renderer.domElement
);
controls.enableDamping =
true;
controls.dampingFactor =
0.05;
controls.minDistance =
2;
controls.maxDistance =
100;
controls.target.set(
0,
0,
0
);
controls.update();
// ========================
// Clock / State
// ========================
const clock =
new THREE.Clock();
let isPlaying =
true;
let timeScale =
1;
let selectedPlanet =
null;
let focusMode =
false;
// ========================
// Focus Tracking
// ========================
const previousFocusPosition =
new THREE.Vector3();
const focusMovement =
new THREE.Vector3();
// ========================
// Raycaster
// ========================
const raycaster =
new THREE.Raycaster();
const mouse =
new THREE.Vector2();
const pointerStart =
new THREE.Vector2();
const defaultOrbitColor =
0x444444;
const selectedOrbitColor =
0xffcc66;
// ========================
// Texture Loader
// ========================
const textureLoader =
new THREE.TextureLoader();
// ========================
// Sun
// ========================
const sunTexture =
textureLoader.load(
"textures/sun.jpg"
);
sunTexture.colorSpace =
THREE.SRGBColorSpace;
const sunGeometry =
new THREE.SphereGeometry(
2,
64,
64
);
const sunMaterial =
new THREE.MeshBasicMaterial({
map:
sunTexture
});
const sun =
new THREE.Mesh(
sunGeometry,
sunMaterial
);
scene.add(
sun
);
// ========================
// Lights
// ========================
const sunLight =
new THREE.PointLight(
0xffffff,
500,
100
);
sunLight.position.set(
0,
0,
0
);
sunLight.castShadow =
true;
sunLight.shadow.mapSize.width =
512;
sunLight.shadow.mapSize.height =
512;
sunLight.shadow.camera.near =
0.1;
sunLight.shadow.camera.far =
100;
scene.add(
sunLight
);
const ambientLight =
new THREE.AmbientLight(
0xffffff,
0.08
);
scene.add(
ambientLight
);
// ========================
// Planet Data
// ========================
const planetData = [
{
name: "Mercury",
chineseName: "水星",
description:
"距離太陽最近的行星。",
order:
"第一顆行星",
size: 0.45,
distance: 4,
rotationSpeed: 0.8,
orbitSpeed: 1.2,
texture:
"textures/mercury.jpg"
},
{
name: "Venus",
chineseName: "金星",
description:
"擁有非常濃厚的大氣層。",
order:
"第二顆行星",
size: 0.65,
distance: 6,
rotationSpeed: -0.3,
orbitSpeed: 0.95,
texture:
"textures/venus.jpg"
},
{
name: "Earth",
chineseName: "地球",
description:
"目前已知唯一已知存在生命的行星。",
order:
"第三顆行星",
size: 0.7,
distance: 8.5,
rotationSpeed: 1.0,
orbitSpeed: 0.8,
texture:
"textures/earth.jpg"
},
{
name: "Mars",
chineseName: "火星",
description:
"表面富含氧化鐵,因此呈現紅色。",
order:
"第四顆行星",
size: 0.55,
distance: 12,
rotationSpeed: 0.9,
orbitSpeed: 0.65,
texture:
"textures/mars.jpg"
},
{
name: "Jupiter",
chineseName: "木星",
description:
"太陽系中體積最大的行星。",
order:
"第五顆行星",
size: 1.3,
distance: 18,
rotationSpeed: 2.0,
orbitSpeed: 0.4,
texture:
"textures/jupiter.jpg"
},
{
name: "Saturn",
chineseName: "土星",
description:
"以明顯的行星環系統聞名。",
order:
"第六顆行星",
size: 1.1,
distance: 24,
rotationSpeed: 1.8,
orbitSpeed: 0.3,
texture:
"textures/saturn.jpg"
},
{
name: "Uranus",
chineseName: "天王星",
description:
"自轉軸傾斜程度非常明顯。",
order:
"第七顆行星",
size: 0.9,
distance: 30,
rotationSpeed: 1.2,
orbitSpeed: 0.22,
texture:
"textures/uranus.jpg"
},
{
name: "Neptune",
chineseName: "海王星",
description:
"八大行星中距離太陽最遠。",
order:
"第八顆行星",
size: 0.85,
distance: 36,
rotationSpeed: 1.1,
orbitSpeed: 0.16,
texture:
"textures/neptune.jpg"
}
];
// ========================
// Special Textures
// ========================
const earthNightTexture =
textureLoader.load(
"textures/earth_night.jpg"
);
earthNightTexture.colorSpace =
THREE.SRGBColorSpace;
const venusAtmosphereTexture =
textureLoader.load(
"textures/venus_atmosphere.jpg"
);
venusAtmosphereTexture.colorSpace =
THREE.SRGBColorSpace;
const saturnRingTexture =
textureLoader.load(
"textures/saturn_ring.png"
);
saturnRingTexture.colorSpace =
THREE.SRGBColorSpace;
// ========================
// Starfield
// ========================
const starGeometry =
new THREE.BufferGeometry();
const starPositions =
[];
const starCount =
2000;
for (
let i = 0;
i < starCount;
i++
) {
const radius =
100 +
Math.random() *
40;
const theta =
Math.random() *
Math.PI *
2;
const phi =
Math.acos(
2 *
Math.random() -
1
);
const x =
radius *
Math.sin(phi) *
Math.cos(theta);
const y =
radius *
Math.cos(phi);
const z =
radius *
Math.sin(phi) *
Math.sin(theta);
starPositions.push(
x,
y,
z
);
}
starGeometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(
starPositions,
3
)
);
const starMaterial =
new THREE.PointsMaterial({
color:
0xffffff,
size:
0.25,
sizeAttenuation:
true
});
const stars =
new THREE.Points(
starGeometry,
starMaterial
);
scene.add(
stars
);
// ========================
// Planet Orbits
// ========================
const planetOrbits =
[];
planetData.forEach(
(data) => {
const points =
[];
const segments =
128;
for (
let i = 0;
i < segments;
i++
) {
const angle =
(
i /
segments
) *
Math.PI *
2;
points.push(
new THREE.Vector3(
Math.cos(
angle
) *
data.distance,
0,
Math.sin(
angle
) *
data.distance
)
);
}
const orbitGeometry =
new THREE.BufferGeometry()
.setFromPoints(
points
);
const orbitMaterial =
new THREE.LineBasicMaterial({
color:
defaultOrbitColor
});
const orbit =
new THREE.LineLoop(
orbitGeometry,
orbitMaterial
);
scene.add(
orbit
);
planetOrbits.push({
name:
data.name,
line:
orbit
});
}
);
// ========================
// Create Planets
// ========================
const planets =
[];
let earth =
null;
let venusAtmosphere =
null;
planetData.forEach(
(data, index) => {
const planetTexture =
textureLoader.load(
data.texture
);
planetTexture.colorSpace =
THREE.SRGBColorSpace;
const geometry =
new THREE.SphereGeometry(
data.size,
48,
48
);
let material;
// ========================
// Earth Night Lights
// ========================
if (
data.name ===
"Earth"
) {
material =
new THREE.MeshStandardMaterial({
map:
planetTexture,
emissiveMap:
earthNightTexture,
emissive:
0xffffff,
emissiveIntensity:
0.6,
roughness:
1,
metalness:
0
});
} else {
material =
new THREE.MeshStandardMaterial({
map:
planetTexture,
roughness:
1,
metalness:
0
});
}
const planet =
new THREE.Mesh(
geometry,
material
);
// ========================
// Shadow
// ========================
planet.castShadow =
data.name ===
"Earth" ||
data.name ===
"Saturn";
planet.receiveShadow =
data.name ===
"Earth";
// ========================
// Venus Atmosphere
// ========================
if (
data.name ===
"Venus"
) {
const atmosphereGeometry =
new THREE.SphereGeometry(
data.size *
1.03,
48,
48
);
const atmosphereMaterial =
new THREE.MeshBasicMaterial({
map:
venusAtmosphereTexture,
transparent:
true,
opacity:
0.55,
side:
THREE.DoubleSide,
depthWrite:
false
});
venusAtmosphere =
new THREE.Mesh(
atmosphereGeometry,
atmosphereMaterial
);
planet.add(
venusAtmosphere
);
}
// ========================
// Saturn Ring
// ========================
if (
data.name ===
"Saturn"
) {
const innerRadius =
data.size *
1.3;
const outerRadius =
data.size *
2.2;
const ringGeometry =
new THREE.RingGeometry(
innerRadius,
outerRadius,
128
);
// ========================
// Saturn Ring UV
// ========================
const position =
ringGeometry
.attributes
.position;
const uv =
ringGeometry
.attributes
.uv;
for (
let i = 0;
i < position.count;
i++
) {
const x =
position.getX(
i
);
const y =
position.getY(
i
);
const distance =
Math.sqrt(
x * x +
y * y
);
const normalizedRadius =
(
distance -
innerRadius
) /
(
outerRadius -
innerRadius
);
uv.setXY(
i,
normalizedRadius,
0.5
);
}
uv.needsUpdate =
true;
const ringMaterial =
new THREE.MeshStandardMaterial({
map:
saturnRingTexture,
transparent:
true,
side:
THREE.DoubleSide,
depthWrite:
false,
roughness:
1,
metalness:
0,
emissive:
0xffffff,
emissiveMap:
saturnRingTexture,
emissiveIntensity:
0.35
});
const saturnRing =
new THREE.Mesh(
ringGeometry,
ringMaterial
);
saturnRing.receiveShadow =
false;
saturnRing.rotation.x =
Math.PI /
2;
saturnRing.rotation.z =
THREE.MathUtils.degToRad(
27
);
planet.add(
saturnRing
);
}
// ========================
// Initial Position
// ========================
const startAngle =
index *
0.8;
planet.position.x =
Math.cos(
startAngle
) *
data.distance;
planet.position.z =
Math.sin(
startAngle
) *
data.distance;
scene.add(
planet
);
if (
data.name ===
"Earth"
) {
earth =
planet;
}
planets.push({
name:
data.name,
chineseName:
data.chineseName,
description:
data.description,
order:
data.order,
mesh:
planet,
size:
data.size,
distance:
data.distance,
rotationSpeed:
data.rotationSpeed,
orbitSpeed:
data.orbitSpeed,
angle:
startAngle
});
}
);
// ========================
// Moon
// ========================
const moonTexture =
textureLoader.load(
"textures/moon.jpg"
);
moonTexture.colorSpace =
THREE.SRGBColorSpace;
const moonGeometry =
new THREE.SphereGeometry(
0.2,
32,
32
);
const moonMaterial =
new THREE.MeshStandardMaterial({
map:
moonTexture,
roughness:
1,
metalness:
0
});
const moon =
new THREE.Mesh(
moonGeometry,
moonMaterial
);
moon.castShadow =
true;
moon.receiveShadow =
true;
scene.add(
moon
);
const moonDistance =
1.3;
const moonOrbitSpeed =
2;
let moonAngle =
0;
// ========================
// Moon Orbit
// ========================
const moonOrbitPoints =
[];
const moonOrbitSegments =
64;
for (
let i = 0;
i < moonOrbitSegments;
i++
) {
const angle =
(
i /
moonOrbitSegments
) *
Math.PI *
2;
moonOrbitPoints.push(
new THREE.Vector3(
Math.cos(
angle
) *
moonDistance,
0,
Math.sin(
angle
) *
moonDistance
)
);
}
const moonOrbitGeometry =
new THREE.BufferGeometry()
.setFromPoints(
moonOrbitPoints
);
const moonOrbitMaterial =
new THREE.LineBasicMaterial({
color:
0x666666
});
const moonOrbit =
new THREE.LineLoop(
moonOrbitGeometry,
moonOrbitMaterial
);
scene.add(
moonOrbit
);
// ========================
// DOM
// ========================
const planetInfo =
document.getElementById(
"planetInfo"
);
const planetName =
document.getElementById(
"planetName"
);
const planetChineseName =
document.getElementById(
"planetChineseName"
);
const planetOrder =
document.getElementById(
"planetOrder"
);
const planetDescription =
document.getElementById(
"planetDescription"
);
const returnButton =
document.getElementById(
"returnButton"
);
const orbitToggle =
document.getElementById(
"orbitToggle"
);
const venusToggle =
document.getElementById(
"venusToggle"
);
const starToggle =
document.getElementById(
"starToggle"
);
const playPauseButton =
document.getElementById(
"playPauseButton"
);
const speedSlider =
document.getElementById(
"speedSlider"
);
const speedValue =
document.getElementById(
"speedValue"
);
// ========================
// Orbit Functions
// ========================
function resetOrbitColors() {
planetOrbits.forEach(
(orbit) => {
orbit.line
.material
.color
.setHex(
defaultOrbitColor
);
}
);
}
function highlightOrbit(
planetNameValue
) {
const orbit =
planetOrbits.find(
(orbit) =>
orbit.name ===
planetNameValue
);
if (
orbit
) {
orbit.line
.material
.color
.setHex(
selectedOrbitColor
);
}
}
// ========================
// Planet Information
// ========================
function showPlanetInfo(
planet
) {
planetName.textContent =
planet.name;
planetChineseName.textContent =
"中文名稱:" +
planet.chineseName;
planetOrder.textContent =
"位置:" +
planet.order;
planetDescription.textContent =
planet.description;
planetInfo.classList.add(
"active"
);
}
// ========================
// Focus Functions
// ========================
function focusPlanet(
planet
) {
selectedPlanet =
planet;
focusMode =
true;
const focusDistance =
planet.size *
6 +
3;
camera.position.set(
planet.mesh.position.x +
focusDistance,
planet.mesh.position.y +
focusDistance *
0.5,
planet.mesh.position.z +
focusDistance
);
controls.target.copy(
planet.mesh.position
);
previousFocusPosition.copy(
planet.mesh.position
);
controls.update();
showPlanetInfo(
planet
);
}
function returnToSolarSystem() {
focusMode =
false;
selectedPlanet =
null;
if (
window.innerWidth <=
768
) {
camera.position.set(
45,
40,
55
);
} else {
camera.position.set(
30,
25,
30
);
}
controls.target.set(
0,
0,
0
);
controls.update();
planetInfo.classList.remove(
"active"
);
resetOrbitColors();
}
// ========================
// Planet Selection
// ========================
function selectPlanet(
event
) {
const rect =
renderer.domElement
.getBoundingClientRect();
mouse.x =
(
(
event.clientX -
rect.left
) /
rect.width
) *
2 -
1;
mouse.y =
-(
(
event.clientY -
rect.top
) /
rect.height
) *
2 +
1;
raycaster.setFromCamera(
mouse,
camera
);
const planetMeshes =
planets.map(
(planet) =>
planet.mesh
);
const intersects =
raycaster.intersectObjects(
planetMeshes,
false
);
if (
intersects.length ===
0
) {
return;
}
const clickedMesh =
intersects[0].object;
const clickedPlanet =
planets.find(
(planet) =>
planet.mesh ===
clickedMesh
);
if (
!clickedPlanet
) {
return;
}
resetOrbitColors();
highlightOrbit(
clickedPlanet.name
);
focusPlanet(
clickedPlanet
);
}
// ========================
// Pointer Events
// ========================
renderer.domElement.addEventListener(
"pointerdown",
(event) => {
pointerStart.set(
event.clientX,
event.clientY
);
}
);
renderer.domElement.addEventListener(
"pointerup",
(event) => {
const pointerEnd =
new THREE.Vector2(
event.clientX,
event.clientY
);
const pointerDistance =
pointerStart.distanceTo(
pointerEnd
);
if (
pointerDistance >
5
) {
return;
}
selectPlanet(
event
);
}
);
// ========================
// UI Events
// ========================
orbitToggle.addEventListener(
"change",
() => {
planetOrbits.forEach(
(orbit) => {
orbit.line.visible =
orbitToggle.checked;
}
);
moonOrbit.visible =
orbitToggle.checked;
}
);
venusToggle.addEventListener(
"change",
() => {
if (
venusAtmosphere
) {
venusAtmosphere.visible =
venusToggle.checked;
}
}
);
starToggle.addEventListener(
"change",
() => {
stars.visible =
starToggle.checked;
}
);
playPauseButton.addEventListener(
"click",
() => {
isPlaying =
!isPlaying;
playPauseButton.textContent =
isPlaying
? "⏸ Pause"
: "▶ Play";
}
);
speedSlider.addEventListener(
"input",
() => {
timeScale =
Number(
speedSlider.value
);
speedValue.textContent =
timeScale.toFixed(
1
) +
"x";
}
);
returnButton.addEventListener(
"click",
returnToSolarSystem
);
// ========================
// Keyboard Camera
// ========================
window.addEventListener(
"keydown",
(event) => {
if (
![
"1",
"2",
"3",
"4"
].includes(
event.key
)
) {
return;
}
focusMode =
false;
selectedPlanet =
null;
planetInfo.classList.remove(
"active"
);
resetOrbitColors();
if (
event.key ===
"1"
) {
camera.position.set(
0,
0,
50
);
}
if (
event.key ===
"2"
) {
camera.position.set(
30,
50,
40
);
}
if (
event.key ===
"3"
) {
camera.position.set(
0,
65,
0.1
);
}
if (
event.key ===
"4"
) {
camera.position.set(
0,
12,
20
);
}
controls.target.set(
0,
0,
0
);
controls.update();
}
);
// ========================
// Resize
// ========================
window.addEventListener(
"resize",
() => {
camera.aspect =
window.innerWidth /
window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(
window.innerWidth,
window.innerHeight
);
renderer.setPixelRatio(
Math.min(
window.devicePixelRatio,
2
)
);
}
);
// ========================
// Animation
// ========================
function animate() {
requestAnimationFrame(
animate
);
const delta =
clock.getDelta();
const scaledDelta =
delta *
timeScale;
if (
isPlaying
) {
// Sun
sun.rotation.y +=
0.5 *
scaledDelta;
// Planets
planets.forEach(
(planet) => {
planet.mesh.rotation.y +=
planet.rotationSpeed *
scaledDelta;
planet.angle +=
planet.orbitSpeed *
scaledDelta;
planet.mesh.position.x =
Math.cos(
planet.angle
) *
planet.distance;
planet.mesh.position.z =
Math.sin(
planet.angle
) *
planet.distance;
}
);
// Venus Atmosphere
if (
venusAtmosphere
) {
venusAtmosphere.rotation.y +=
0.15 *
scaledDelta;
}
// Moon
moonAngle +=
moonOrbitSpeed *
scaledDelta;
moon.position.x =
earth.position.x +
Math.cos(
moonAngle
) *
moonDistance;
moon.position.y =
earth.position.y;
moon.position.z =
earth.position.z +
Math.sin(
moonAngle
) *
moonDistance;
moon.rotation.y +=
0.4 *
scaledDelta;
moonOrbit.position.copy(
earth.position
);
}
// ========================
// Focus Tracking
// ========================
if (
focusMode &&
selectedPlanet
) {
focusMovement
.copy(
selectedPlanet
.mesh
.position
)
.sub(
previousFocusPosition
);
camera.position.add(
focusMovement
);
controls.target.copy(
selectedPlanet
.mesh
.position
);
previousFocusPosition.copy(
selectedPlanet
.mesh
.position
);
}
controls.update();
renderer.render(
scene,
camera
);
}
animate();
我們這次主要使用:
Solar System Scope
Solar Textures
提供的太陽系貼圖。
Solar System Scope:https://www.solarsystemscope.com/textures/
Day 30 並不代表:
這個太陽系不能再修改
反而可以把它當成下一階段的起點。
未來可以繼續加入
Planet Label
真實 Planet Data
更多 Moon
...
甚至可以把 Planet Data 從 JavaScript 寫死的資料改成:
JSON
API
Database
讓它真正變成資料驅動的 3D Web Application
從最初Hello Modern Web
一路做到:
可以旋轉
可以公轉
可以縮放
可以點擊
可以追蹤
可以控制時間
可以顯示資訊
可以在手機上操作
這也是這次 30 天最大的成果。
我們不是單純學 Three.js API
而是實際走過:
想法
↓
HTML / CSS / JavaScript
↓
3D Scene
↓
功能
↓
互動
↓
Debug
↓
Responsive
↓
實機測試
↓
完整作品
這就是 Modern Web 開發真正有趣的地方。
《從 Web 到宇宙:30 天打造互動式太陽系|Modern Web × Three.js 三維視覺化與光照實作》
30 天前,我們從一個普通的 Web Page 開始。
30 天後,我們已經可以在瀏覽器裡打造一個自己的宇宙!!