init
This commit is contained in:
commit
6c13ce3373
22 changed files with 3850 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
22
README.md
Normal file
22
README.md
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
# React Three.js study
|
||||||
|
|
||||||
|
This is a simple React Three.js project that demonstrates how to use Three.js with React.
|
||||||
|
It includes a simple scene with a city and a globe.
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
To get started, clone the repository and install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, start the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open your browser and navigate to `http://localhost:5173` to see the app in action.
|
||||||
|
You should see a simple scene with a city and a globe.
|
||||||
|
|
37
eslint.config.js
Normal file
37
eslint.config.js
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
'unused-vars/no-unused-vars': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
argsIgnorePattern: '^_',
|
||||||
|
varsIgnorePattern: '^_',
|
||||||
|
ignoreRestSiblings: true,
|
||||||
|
args: 'none',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
13
index.html
Normal file
13
index.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Vite + React + TS</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
40
package.json
Normal file
40
package.json
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
{
|
||||||
|
"name": "vite-project",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@react-three/drei": "^10.0.6",
|
||||||
|
"@react-three/fiber": "^9.1.2",
|
||||||
|
"@tailwindcss/vite": "^4.1.3",
|
||||||
|
"@types/three": "^0.175.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"simplex-noise": "^4.0.3",
|
||||||
|
"tailwind-merge": "^3.2.0",
|
||||||
|
"tailwindcss": "^4.1.3",
|
||||||
|
"three": "^0.175.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.21.0",
|
||||||
|
"@types/react": "^19.0.10",
|
||||||
|
"@types/react-dom": "^19.0.4",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.21.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^15.15.0",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"typescript-eslint": "^8.24.1",
|
||||||
|
"vite": "^6.2.0"
|
||||||
|
},
|
||||||
|
"packageManager": "pnpm@10.8.0+sha512.0e82714d1b5b43c74610193cb20734897c1d00de89d0e18420aebc5977fa13d780a9cb05734624e81ebd81cc876cd464794850641c48b9544326b5622ca29971"
|
||||||
|
}
|
2883
pnpm-lock.yaml
generated
Normal file
2883
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- esbuild
|
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
After Width: | Height: | Size: 1.5 KiB |
0
src/App.css
Normal file
0
src/App.css
Normal file
336
src/App1.tsx
Normal file
336
src/App1.tsx
Normal file
|
@ -0,0 +1,336 @@
|
||||||
|
import React, { useState, useRef, useMemo, FC } from 'react';
|
||||||
|
import { Canvas } from '@react-three/fiber';
|
||||||
|
// Environment, Cylinder, Sphere 임포트 추가
|
||||||
|
import { OrbitControls, Box, Plane, Environment, MeshReflectorMaterial, Cylinder, Sphere } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
|
||||||
|
// --- 타입 정의 ---
|
||||||
|
|
||||||
|
// 건물 데이터 구조 정의
|
||||||
|
interface BuildingData {
|
||||||
|
id: number;
|
||||||
|
position: number[];
|
||||||
|
size: number[];
|
||||||
|
color: string;
|
||||||
|
hasRooftop: boolean; // 옥탑 구조물 유무 추가
|
||||||
|
}
|
||||||
|
|
||||||
|
// 나무 데이터 구조 정의
|
||||||
|
interface TreeData {
|
||||||
|
id: number;
|
||||||
|
position: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Building 컴포넌트 Props 타입 정의
|
||||||
|
interface BuildingProps extends BuildingData {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tree 컴포넌트 Props 타입 정의
|
||||||
|
interface TreeProps extends TreeData {}
|
||||||
|
|
||||||
|
// Road 컴포넌트 Props 타입 정의
|
||||||
|
interface RoadProps {
|
||||||
|
position: number[];
|
||||||
|
size: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// InfoBox 컴포넌트 Props 타입 정의
|
||||||
|
interface InfoBoxProps {
|
||||||
|
buildingInfo: BuildingData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 컴포넌트 정의 ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 건물 컴포넌트 (옥탑 구조물 추가)
|
||||||
|
*/
|
||||||
|
const Building: FC<BuildingProps> = ({ position, size, color, onClick, id, hasRooftop }) => {
|
||||||
|
const [hovered, setHovered] = useState<boolean>(false);
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null!);
|
||||||
|
|
||||||
|
const effectiveColor = hovered ? '#007bff' : color;
|
||||||
|
const [width, height, depth] = size;
|
||||||
|
|
||||||
|
// 옥탑 구조물 크기 및 위치 계산
|
||||||
|
const rooftopHeight = height * 0.075;
|
||||||
|
const rooftopWidth = width * 0.7;
|
||||||
|
const rooftopDepth = depth * 0.7;
|
||||||
|
const rooftopYPosition = height / 2 + rooftopHeight / 2 - 0.01; // 본체 위에 살짝 겹치게
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={position as [number, number, number]}>
|
||||||
|
{/* 본체 건물 */}
|
||||||
|
<Box
|
||||||
|
ref={meshRef}
|
||||||
|
args={[width, height, depth]}
|
||||||
|
// position 은 group 에서 설정하므로 여기선 [0, 0, 0] 기준
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
onPointerOver={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
setHovered(true);
|
||||||
|
}}
|
||||||
|
onPointerOut={(event) => setHovered(false)}
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={effectiveColor}
|
||||||
|
metalness={0.1}
|
||||||
|
roughness={0.6}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 옥탑 구조물 (선택적 렌더링) */}
|
||||||
|
{hasRooftop && (
|
||||||
|
<Box
|
||||||
|
args={[rooftopWidth, rooftopHeight, rooftopDepth]}
|
||||||
|
position={[0, rooftopYPosition, 0]} // 본체 상단 중앙
|
||||||
|
castShadow
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={effectiveColor} // 본체와 같은 색 또는 약간 다른 색
|
||||||
|
metalness={0.1}
|
||||||
|
roughness={0.6}
|
||||||
|
emissive={hovered ? '#555555' : '#000000'} // 호버 시 약간 밝게
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 나무 컴포넌트
|
||||||
|
*/
|
||||||
|
const Tree: FC<TreeProps> = ({ position }) => {
|
||||||
|
return (
|
||||||
|
<group position={position as [number, number, number]}>
|
||||||
|
{/* 바닥 */}
|
||||||
|
<Plane
|
||||||
|
args={[10, 10]} // 바닥 크기
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]} // 바닥에 눕히기 위해 x축으로 -90도 회전
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#138b31" roughness={0.8} metalness={0.1} />
|
||||||
|
</Plane>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도로 컴포넌트
|
||||||
|
*/
|
||||||
|
const Road: FC<RoadProps> = ({ position, size }) => {
|
||||||
|
return (
|
||||||
|
<Plane
|
||||||
|
args={size as [number, number]} // Plane args 타입에 맞게 캐스팅
|
||||||
|
position={position as [number, number, number]} // position 타입 캐스팅
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]} // 바닥에 눕히기 위해 x축으로 -90도 회전
|
||||||
|
>
|
||||||
|
{/* 도로 색상을 중간 회색으로 변경 */}
|
||||||
|
<meshStandardMaterial color="#666666" side={THREE.DoubleSide} roughness={0.8} metalness={0.1} />
|
||||||
|
</Plane>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정보 표시 박스 컴포넌트 (Tailwind CSS 적용)
|
||||||
|
*/
|
||||||
|
const InfoBox: FC<InfoBoxProps> = ({ buildingInfo }) => {
|
||||||
|
if (!buildingInfo) return null;
|
||||||
|
|
||||||
|
// 위치와 크기 정보를 소수점 아래 두 자리까지만 표시
|
||||||
|
const formatArray = (arr: number[]) => arr.map(n => n.toFixed(2)).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Tailwind CSS 클래스 적용
|
||||||
|
<div className="absolute top-5 left-5 p-3 px-4 bg-black/70 rounded-lg text-white font-sans text-xs shadow-lg pointer-events-none z-50">
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Selected Building</h3>
|
||||||
|
<p className="my-1">ID: {buildingInfo.id}</p>
|
||||||
|
{/* position[1]은 그룹 기준이므로 실제 바닥부터의 높이 표시 위해 size[1]/2 추가 */}
|
||||||
|
<p className="my-1">Position: [{formatArray([buildingInfo.position[0], buildingInfo.position[1] - buildingInfo.size[1]/2, buildingInfo.position[2]])}]</p>
|
||||||
|
<p className="my-1">Size: [{formatArray(buildingInfo.size)}]</p>
|
||||||
|
{/* 색상 정보는 hex 코드로 표시 */}
|
||||||
|
<p className="my-1">Color: {buildingInfo.color}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 메인 앱 컴포넌트 ---
|
||||||
|
const App: FC = () => {
|
||||||
|
// useState 타입 명시: BuildingData 또는 null
|
||||||
|
const [selectedBuilding, setSelectedBuilding] = useState<BuildingData | null>(null);
|
||||||
|
|
||||||
|
// 건물 클릭 시 호출될 함수
|
||||||
|
const handleBuildingClick = (buildingData: BuildingData) => {
|
||||||
|
setSelectedBuilding(buildingData);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 도시 데이터 정의 (건물, 도로, 나무) - 모던 스타일 적용
|
||||||
|
const cityData = useMemo<{ buildings: BuildingData[]; roads: { position: number[]; size: number[] }[]; trees: TreeData[] }>(() => {
|
||||||
|
const buildings: BuildingData[] = [];
|
||||||
|
const roads: { position: number[]; size: number[] }[] = [];
|
||||||
|
const trees: TreeData[] = [];
|
||||||
|
const gridSize = 7;
|
||||||
|
const spacing = 5;
|
||||||
|
const roadWidth = 1.5;
|
||||||
|
const buildingBaseSize = 4;
|
||||||
|
const parkProbability = 0.15; // 공원(나무)이 될 확률
|
||||||
|
|
||||||
|
// 건물 또는 나무 생성
|
||||||
|
for (let i = -gridSize; i <= gridSize; i++) {
|
||||||
|
for (let j = -gridSize; j <= gridSize; j++) {
|
||||||
|
// 격자 교차점이 아닌 곳
|
||||||
|
if (Math.abs(i % 2) === 1 && Math.abs(j % 2) === 1) {
|
||||||
|
// 공원 확률 체크
|
||||||
|
if (Math.random() < parkProbability) {
|
||||||
|
// 나무 생성
|
||||||
|
trees.push({
|
||||||
|
id: trees.length + 1,
|
||||||
|
position: [i * spacing, 0, j * spacing] // 나무는 바닥(y=0)에서 시작
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 건물 생성
|
||||||
|
const height = Math.random() * 20 + 6;
|
||||||
|
const width = buildingBaseSize;
|
||||||
|
const depth = buildingBaseSize;
|
||||||
|
// 건물의 position.y는 건물의 중앙 높이
|
||||||
|
const position = [i * spacing, height / 2, j * spacing];
|
||||||
|
const size = [width, height, depth];
|
||||||
|
const hasRooftop = Math.random() > 0.6; // 40% 확률로 옥탑 구조물 생성
|
||||||
|
|
||||||
|
let color: string;
|
||||||
|
if (Math.random() < 0.15) {
|
||||||
|
const darkShade = Math.random() * 0.2 + 0.1;
|
||||||
|
color = new THREE.Color(darkShade, darkShade, darkShade).getHexString();
|
||||||
|
color = `#${color}`;
|
||||||
|
} else {
|
||||||
|
const lightShade = Math.random() * 0.3 + 0.7;
|
||||||
|
color = new THREE.Color(lightShade, lightShade, lightShade).getHexString();
|
||||||
|
color = `#${color}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
buildings.push({
|
||||||
|
id: buildings.length + 1,
|
||||||
|
position: position,
|
||||||
|
size: size,
|
||||||
|
color: color,
|
||||||
|
hasRooftop: hasRooftop
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 도로 생성
|
||||||
|
const roadYPosition = 0.01;
|
||||||
|
const totalGridWidth = (gridSize * 2 + 1) * spacing;
|
||||||
|
for (let j = -gridSize; j <= gridSize; j++) {
|
||||||
|
if (Math.abs(j % 2) === 0) {
|
||||||
|
roads.push({ position: [0, roadYPosition, j * spacing], size: [totalGridWidth, roadWidth] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = -gridSize; i <= gridSize; i++) {
|
||||||
|
if (Math.abs(i % 2) === 0) {
|
||||||
|
roads.push({ position: [i * spacing, roadYPosition, 0], size: [roadWidth, totalGridWidth] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { buildings, roads, trees };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
const groundColor = "#e0e0e0"; // 밝은 회색 바닥
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen relative">
|
||||||
|
<InfoBox buildingInfo={selectedBuilding} />
|
||||||
|
<Canvas
|
||||||
|
camera={{ position: [0, 50, 50], fov: 50 }}
|
||||||
|
onPointerMissed={() => setSelectedBuilding(null)}
|
||||||
|
shadows
|
||||||
|
>
|
||||||
|
<color attach="background" args={[groundColor]} />
|
||||||
|
<fog attach="fog" args={[groundColor, 60, 150]} />
|
||||||
|
|
||||||
|
{/* 조명: Environment 사용 시 강도 조절 */}
|
||||||
|
<ambientLight intensity={0.1} />
|
||||||
|
{/* Environment preset과 어울리도록 DirectionalLight 강도/색상 조절 가능 */}
|
||||||
|
<directionalLight
|
||||||
|
position={[20, 30, 15]}
|
||||||
|
intensity={2} // 강도 약간 줄임
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize-width={1024}
|
||||||
|
shadow-mapSize-height={1024}
|
||||||
|
shadow-camera-far={70}
|
||||||
|
shadow-camera-left={-40}
|
||||||
|
shadow-camera-right={40}
|
||||||
|
shadow-camera-top={40}
|
||||||
|
shadow-camera-bottom={-40}
|
||||||
|
shadow-bias={-0.0005}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<OrbitControls makeDefault maxPolarAngle={Math.PI / 2.1} />
|
||||||
|
|
||||||
|
{/* --- 환경 맵 추가 --- */}
|
||||||
|
{/* preset="city" 또는 "sunset", "dawn", "apartment" 등 시도 */}
|
||||||
|
<Environment preset="city" />
|
||||||
|
|
||||||
|
{/* 건물 렌더링 */}
|
||||||
|
{cityData.buildings.map((building) => (
|
||||||
|
<Building
|
||||||
|
key={building.id}
|
||||||
|
{...building} // 모든 속성 전달
|
||||||
|
onClick={() => handleBuildingClick(building)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 도로 렌더링 */}
|
||||||
|
{cityData.roads.map((road, index) => (
|
||||||
|
<Road key={`road-${index}`} position={road.position} size={road.size} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 나무 렌더링 */}
|
||||||
|
{cityData.trees.map((tree) => (
|
||||||
|
<Tree key={tree.id} {...tree} />
|
||||||
|
))}
|
||||||
|
|
||||||
|
|
||||||
|
{/* --- 바닥 평면에 MeshReflectorMaterial 적용 --- */}
|
||||||
|
<Plane
|
||||||
|
args={[300, 300]}
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
position={[0, -0.01, 0]}
|
||||||
|
receiveShadow
|
||||||
|
>
|
||||||
|
<MeshReflectorMaterial
|
||||||
|
resolution={1024}
|
||||||
|
mirror={0.8} // 반사 선명도 약간 낮춤
|
||||||
|
mixBlur={0.2} // 블러 약간 추가
|
||||||
|
mixStrength={0.8}
|
||||||
|
roughness={0.4}
|
||||||
|
metalness={0.6}
|
||||||
|
color={groundColor}
|
||||||
|
blur={[100, 100]} // 블러 값 조정 (성능 영향 주의)
|
||||||
|
/>
|
||||||
|
</Plane>
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
|
// --- TypeScript 및 Tailwind CSS 설정 참고 ---
|
||||||
|
// 이전과 동일
|
||||||
|
|
||||||
|
// --- 필요 라이브러리 설치 ---
|
||||||
|
// 이전과 동일
|
||||||
|
|
||||||
|
// --- 실행 방법 ---
|
||||||
|
// 이전과 동일
|
255
src/App2.tsx
Normal file
255
src/App2.tsx
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
import React, { useState, useRef, useMemo, FC, useEffect } from 'react';
|
||||||
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
|
import { OrbitControls, Sphere, Cone, Box, Text } from '@react-three/drei';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { createNoise3D } from 'simplex-noise';
|
||||||
|
import { generateGlobeTextureData } from './texture-generator';
|
||||||
|
|
||||||
|
// --- 타입 정의 ---
|
||||||
|
|
||||||
|
interface CityData {
|
||||||
|
name: string;
|
||||||
|
lat: number; // 위도 (degrees)
|
||||||
|
lon: number; // 경도 (degrees)
|
||||||
|
population?: number; // 예시 데이터
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SymbolicBuildingProps {
|
||||||
|
position: THREE.Vector3;
|
||||||
|
cityName: string;
|
||||||
|
scale?: number;
|
||||||
|
onPointerOver: () => void;
|
||||||
|
onPointerOut: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobeProps {
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 유틸리티 함수 ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위도, 경도를 3D 벡터 좌표로 변환
|
||||||
|
* @param lat 위도 (degrees)
|
||||||
|
* @param lon 경도 (degrees)
|
||||||
|
* @param radius 구의 반지름
|
||||||
|
* @returns THREE.Vector3 좌표
|
||||||
|
*/
|
||||||
|
const latLonToVector3 = (lat: number, lon: number, radius: number): THREE.Vector3 => {
|
||||||
|
const phi = (90 - lat) * (Math.PI / 180); // 위도를 극 각도(phi)로 변환 (라디안)
|
||||||
|
const theta = (lon + 180) * (Math.PI / 180); // 경도를 방위각(theta)으로 변환 (라디안)
|
||||||
|
|
||||||
|
const x = -(radius * Math.sin(phi) * Math.cos(theta));
|
||||||
|
const z = radius * Math.sin(phi) * Math.sin(theta);
|
||||||
|
const y = radius * Math.cos(phi);
|
||||||
|
|
||||||
|
return new THREE.Vector3(x, y, z);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 간단한 절차적 지구본 텍스처 생성 (Simplex Noise 사용)
|
||||||
|
* @param width 텍스처 너비
|
||||||
|
* @param height 텍스처 높이
|
||||||
|
* @returns THREE.DataTexture
|
||||||
|
*/
|
||||||
|
const generateGlobeTexture = (width: number, height: number, seed?: number): THREE.DataTexture => {
|
||||||
|
const { data } = generateGlobeTextureData(width, height, seed); // 텍스처 데이터 생성
|
||||||
|
const texture = new THREE.DataTexture(
|
||||||
|
data,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
THREE.RGBAFormat // RGBA 포맷 사용
|
||||||
|
);
|
||||||
|
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
// 텍스처 필터링 및 래핑 설정 (선택 사항)
|
||||||
|
texture.magFilter = THREE.LinearFilter;
|
||||||
|
texture.minFilter = THREE.LinearMipmapLinearFilter; // 밉맵 사용 시
|
||||||
|
texture.wrapS = THREE.ClampToEdgeWrapping; // 구 텍스처는 ClampToEdge가 더 적합할 수 있음
|
||||||
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
|
texture.generateMipmaps = true; // 밉맵 생성
|
||||||
|
|
||||||
|
return texture;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- 컴포넌트 정의 ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 도시를 상징하는 건물 컴포넌트 (Cone 사용)
|
||||||
|
*/
|
||||||
|
const SymbolicBuilding: FC<SymbolicBuildingProps> = ({ position, cityName, scale = 1, onPointerOver, onPointerOut }) => {
|
||||||
|
const coneRef = useRef<THREE.Mesh>(null!);
|
||||||
|
const textRef = useRef<any>(null); // drei Text 타입 추론 어려움
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
|
||||||
|
// 건물이 구 표면에서 바깥쪽을 향하도록 방향 설정
|
||||||
|
useEffect(() => {
|
||||||
|
if (coneRef.current) {
|
||||||
|
coneRef.current.lookAt(0, 0, 0); // 구의 중심을 바라보게 하여 위쪽이 바깥을 향하도록
|
||||||
|
coneRef.current.rotateX(Math.PI / 2); // Cone은 기본적으로 Y축 방향이므로 X축 회전 추가
|
||||||
|
}
|
||||||
|
if (textRef.current) {
|
||||||
|
// 텍스트도 같은 방향을 보도록 설정
|
||||||
|
textRef.current.lookAt(1000 * position.x, 1000 * position.y, 1000 * position.z); // 카메라를 향하도록
|
||||||
|
}
|
||||||
|
}, [position]);
|
||||||
|
|
||||||
|
const coneHeight = 0.5 * scale;
|
||||||
|
const coneRadius = 0.1 * scale;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<group position={position}>
|
||||||
|
<Cone
|
||||||
|
ref={coneRef}
|
||||||
|
args={[coneRadius, coneHeight, 8]} // 반지름, 높이, 분할 수
|
||||||
|
position={[0, coneHeight / 2, 0]} // 위치 조정 (Cone의 기준점은 바닥 중앙)
|
||||||
|
castShadow
|
||||||
|
onPointerOver={(e) => { e.stopPropagation(); setHovered(true); onPointerOver(); }}
|
||||||
|
onPointerOut={(e) => { setHovered(false); onPointerOut(); }}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color={hovered ? 'red' : 'yellow'} emissive={hovered ? 'darkred' : 'black'} />
|
||||||
|
</Cone>
|
||||||
|
{/* 호버 시 도시 이름 표시 */}
|
||||||
|
<Text
|
||||||
|
ref={textRef}
|
||||||
|
position={[0, coneHeight + 0.2, 0]} // 콘 위쪽에 위치
|
||||||
|
fontSize={0.15}
|
||||||
|
color="white"
|
||||||
|
anchorX="center"
|
||||||
|
anchorY="middle"
|
||||||
|
visible={hovered}
|
||||||
|
depthOffset={-1000} // 다른 객체 위에 렌더링되도록
|
||||||
|
>
|
||||||
|
{cityName}
|
||||||
|
</Text>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const mapSeed = Math.random() * 1000; // 랜덤 시드 (선택 사항)
|
||||||
|
/**
|
||||||
|
* 지구본 컴포넌트
|
||||||
|
*/
|
||||||
|
const Globe: FC<GlobeProps> = ({ radius }) => {
|
||||||
|
const meshRef = useRef<THREE.Mesh>(null!);
|
||||||
|
const [globeTexture, setGlobeTexture] = useState<THREE.DataTexture | null>(
|
||||||
|
() => generateGlobeTexture(256, 128, mapSeed) // 초기 텍스처 생성 (메인 스레드에서)
|
||||||
|
);
|
||||||
|
const workerRef = useRef<Worker | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 웹 워커를 사용하여 텍스처 생성
|
||||||
|
workerRef.current = new Worker(new URL('./textureWorker.ts', import.meta.url), { type: 'module' });
|
||||||
|
workerRef.current.onmessage = (event) => {
|
||||||
|
const { data, width, height } = event.data;
|
||||||
|
const texture = new THREE.DataTexture(data, width, height, THREE.RGBAFormat);
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
texture.magFilter = THREE.LinearFilter;
|
||||||
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||||
|
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||||||
|
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||||||
|
texture.generateMipmaps = true;
|
||||||
|
console.log('Texture generated in worker:', texture);
|
||||||
|
setGlobeTexture(texture);
|
||||||
|
};
|
||||||
|
workerRef.current.postMessage({ width: 4096, height: 2048, seed: mapSeed });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (workerRef.current) {
|
||||||
|
workerRef.current.terminate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sphere ref={meshRef} args={[radius, 64, 64]} receiveShadow castShadow>
|
||||||
|
{/* 절차적 텍스처 적용 */}
|
||||||
|
<meshStandardMaterial
|
||||||
|
map={globeTexture}
|
||||||
|
roughness={1}
|
||||||
|
metalness={0.5}
|
||||||
|
color={"#ffffff"} // 기본 색상 (흰색)
|
||||||
|
// bumpMap={globeTexture} // 같은 텍스처를 범프맵으로 사용 (선택 사항)
|
||||||
|
// bumpScale={0.01}
|
||||||
|
/>
|
||||||
|
</Sphere>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// --- 메인 앱 컴포넌트 ---
|
||||||
|
const App: FC = () => {
|
||||||
|
const globeRadius = 5; // 지구본 반지름
|
||||||
|
const [hoveredCity, setHoveredCity] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 주요 도시 데이터
|
||||||
|
const cities: CityData[] = useMemo(() => [
|
||||||
|
{ name: 'Seoul', lat: 37.5665, lon: 126.9780 },
|
||||||
|
{ name: 'New York', lat: 40.7128, lon: -74.0060 },
|
||||||
|
{ name: 'London', lat: 51.5074, lon: -0.1278 },
|
||||||
|
{ name: 'Tokyo', lat: 35.6895, lon: 139.6917 },
|
||||||
|
{ name: 'Sydney', lat: -33.8688, lon: 151.2093 },
|
||||||
|
{ name: 'Cairo', lat: 30.0444, lon: 31.2357 },
|
||||||
|
{ name: 'Rio de Janeiro', lat: -22.9068, lon: -43.1729 },
|
||||||
|
{ name: 'Moscow', lat: 55.7558, lon: 37.6173 },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Tailwind CSS 적용
|
||||||
|
<div className="w-screen h-screen relative bg-black">
|
||||||
|
{/* 호버된 도시 이름 표시 (선택 사항) */}
|
||||||
|
{/*
|
||||||
|
<div className="absolute top-5 left-5 text-white font-sans z-50">
|
||||||
|
Hovered: {hoveredCity || 'None'}
|
||||||
|
</div>
|
||||||
|
*/}
|
||||||
|
<Canvas shadows camera={{ position: [0, 0, 15], fov: 45 }}>
|
||||||
|
{/* 배경색 제거 (우주처럼 검게) */}
|
||||||
|
{/* <color attach="background" args={['#111111']} /> */}
|
||||||
|
<ambientLight intensity={0.3} />
|
||||||
|
<directionalLight
|
||||||
|
position={[5, 5, 5]} // 태양광처럼 멀리서 비추는 빛
|
||||||
|
intensity={5}
|
||||||
|
castShadow
|
||||||
|
shadow-mapSize-width={1024}
|
||||||
|
shadow-mapSize-height={1024}
|
||||||
|
/>
|
||||||
|
<OrbitControls enablePan={false} minDistance={8} maxDistance={30} />
|
||||||
|
|
||||||
|
{/* 지구본 렌더링 */}
|
||||||
|
<Globe radius={globeRadius} />
|
||||||
|
|
||||||
|
{/* 도시 건물 렌더링 */}
|
||||||
|
{cities.map((city) => {
|
||||||
|
const position = latLonToVector3(city.lat, city.lon, globeRadius);
|
||||||
|
return (
|
||||||
|
<SymbolicBuilding
|
||||||
|
key={city.name}
|
||||||
|
position={position}
|
||||||
|
cityName={city.name}
|
||||||
|
scale={0.5} // 건물 크기 조절
|
||||||
|
onPointerOver={() => setHoveredCity(city.name)}
|
||||||
|
onPointerOut={() => setHoveredCity(null)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
</Canvas>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
||||||
|
// --- TypeScript 및 Tailwind CSS 설정 참고 ---
|
||||||
|
// 이전과 동일
|
||||||
|
|
||||||
|
// --- 필요 라이브러리 설치 ---
|
||||||
|
// npm install react react-dom three @react-three/fiber @react-three/drei
|
||||||
|
// yarn add react react-dom three @react-three/fiber @react-three/drei
|
||||||
|
// npm install --save-dev @types/three
|
||||||
|
|
||||||
|
// --- 실행 방법 ---
|
||||||
|
// 이전과 동일 (App.tsx 사용)
|
24
src/AppSwitch.tsx
Normal file
24
src/AppSwitch.tsx
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
import App1 from './App1.tsx';
|
||||||
|
import App2 from './App2.tsx';
|
||||||
|
|
||||||
|
export function AppSwitch() {
|
||||||
|
const [app, setApp] = useState('app2');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-screen relative">
|
||||||
|
<div className='absolute top-0 right-0 z-50'>
|
||||||
|
<select className='bg-gray-200 p-2 rounded-md' value={app} onChange={(e) => setApp(e.target.value)}>
|
||||||
|
<option value="app1">App 1</option>
|
||||||
|
<option value="app2">App 2</option>
|
||||||
|
<option value="app3">App 3</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
{app === 'app1' && <App1 />}
|
||||||
|
{app === 'app2' && <App2 />}
|
||||||
|
{app === 'app3' && <div>App 3</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
After Width: | Height: | Size: 4 KiB |
12
src/index.css
Normal file
12
src/index.css
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
import inter font
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
}
|
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import { AppSwitch } from './AppSwitch.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<AppSwitch />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
107
src/texture-generator.ts
Normal file
107
src/texture-generator.ts
Normal file
|
@ -0,0 +1,107 @@
|
||||||
|
import { createNoise3D } from "simplex-noise";
|
||||||
|
|
||||||
|
export const generateGlobeTextureData = (width: number, height: number, seed?: number): {
|
||||||
|
data: Uint8Array;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} => {
|
||||||
|
const size = width * height;
|
||||||
|
const data = new Uint8Array(4 * size); // RGBA
|
||||||
|
seed = seed ?? Math.floor(Math.random() * 10000); // 시드 초기화 (랜덤 시드 생성)
|
||||||
|
const getRandom = (seed: number) => () => {
|
||||||
|
// 시드가 주어지면 고정된 랜덤 생성기 사용
|
||||||
|
let x = Math.sin(seed) * 10000;
|
||||||
|
x = (x - Math.floor(x)) * 10000;
|
||||||
|
seed += 1; // 시드 증가
|
||||||
|
return x - Math.floor(x); // 0 ~ 1 사이 값 반환
|
||||||
|
} // 시드가 주어지면 고정된 랜덤 생성기 사용 (선택 사항)
|
||||||
|
const noise3D = createNoise3D(getRandom(seed)); // 3D 노이즈 생성 함수
|
||||||
|
const noise3D2 = createNoise3D(getRandom(seed ^ 12)); // 3D 노이즈 생성 함수 (다른 스케일을 위해)
|
||||||
|
const noise3D3 = createNoise3D(getRandom(seed ^ 224)); // 3D 노이즈 생성 함수 (다른 스케일을 위해)
|
||||||
|
|
||||||
|
const noiseScale = 2; // 노이즈 스케일 (값이 클수록 지형이 작아짐)
|
||||||
|
const waterLevel = 0.05; // 해수면 높이 (-1 ~ 1 사이 값)
|
||||||
|
const mountainLevel = 0.4; // 산악 지형 시작 높이
|
||||||
|
const snowLine = 0.8; // 만년설 시작 높이 (위도와 조합)
|
||||||
|
|
||||||
|
for (let i = 0; i < size; i++) {
|
||||||
|
const stride = i * 4;
|
||||||
|
const u = (i % width) / width; // 0 to 1 (경도 방향)
|
||||||
|
const v = Math.floor(i / width) / height; // 0 to 1 (위도 방향)
|
||||||
|
|
||||||
|
// 위도, 경도를 3D 좌표로 변환 (단위 구 기준)
|
||||||
|
const lat = (v - 0.5) * 180;
|
||||||
|
const lon = (u - 0.5) * 360;
|
||||||
|
const phi = (90 - lat) * (Math.PI / 180);
|
||||||
|
const theta = (lon + 180) * (Math.PI / 180);
|
||||||
|
|
||||||
|
const x = -Math.sin(phi) * Math.cos(theta);
|
||||||
|
const z = Math.sin(phi) * Math.sin(theta);
|
||||||
|
const y = Math.cos(phi);
|
||||||
|
|
||||||
|
// 3D Simplex Noise 값 계산 (-1 ~ 1)
|
||||||
|
const noiseValue = noise3D(x * noiseScale, y * noiseScale, z * noiseScale)
|
||||||
|
+ noise3D2(x * noiseScale * 0.5, y * noiseScale * 0.5, z * noiseScale * 0.5) * 0.5
|
||||||
|
+ noise3D3(x * noiseScale * 0.25, y * noiseScale * 0.25, z * noiseScale * 0.25) * 0.25; // 노이즈 조합
|
||||||
|
|
||||||
|
let r = 50; // 바다 기본 R
|
||||||
|
let g = 100; // 바다 기본 G
|
||||||
|
let b = 200; // 바다 기본 B
|
||||||
|
let a = 255; // Alpha
|
||||||
|
|
||||||
|
const absLat = Math.abs(lat);
|
||||||
|
|
||||||
|
if (noiseValue > waterLevel) {
|
||||||
|
// 육지
|
||||||
|
const elevation = (noiseValue - waterLevel) / (1 - waterLevel); // 0 ~ 1 정규화
|
||||||
|
|
||||||
|
if (elevation < mountainLevel) {
|
||||||
|
// 평지/언덕 (녹색)
|
||||||
|
r = 80 + 70 * elevation + Math.random() * 10;
|
||||||
|
g = 140 + 60 * (1 - elevation) + Math.random() * 10;
|
||||||
|
b = 40 + 30 * elevation + Math.random() * 5;
|
||||||
|
} else {
|
||||||
|
// 산악 (갈색/회색)
|
||||||
|
const mountainFactor = (elevation - mountainLevel) / (1 - mountainLevel); // 0 ~ 1
|
||||||
|
r = 140 + 40 * mountainFactor;
|
||||||
|
g = 120 + 20 * mountainFactor;
|
||||||
|
b = 100 + 10 * mountainFactor;
|
||||||
|
|
||||||
|
// 높은 산 + 높은 위도 = 눈 (흰색)
|
||||||
|
const snowChance = Math.max(0, (absLat - 60) / 30) + mountainFactor * 0.5; // 위도 60도 이상부터 눈 확률 증가
|
||||||
|
if (elevation > snowLine || snowChance > 0.5) {
|
||||||
|
r = 240;
|
||||||
|
g = 240;
|
||||||
|
b = 245;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 바다 (깊이에 따라 색상 변화)
|
||||||
|
const depthFactor = Math.abs((noiseValue - waterLevel) / ( 1 + waterLevel)); // 0 ~ 1 정규화 (깊이 비율)
|
||||||
|
r = 50 - 30 * depthFactor;
|
||||||
|
g = 100 - 50 * depthFactor;
|
||||||
|
b = 200 - 40 * depthFactor;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 극지방 얼음 (기존 로직과 유사하게, 노이즈 값과 무관하게 덮어쓰기)
|
||||||
|
if (absLat > 75) {
|
||||||
|
let polarFactor = (absLat - 75) / 15; // 0 to 1
|
||||||
|
polarFactor = 1 - Math.pow(1 - polarFactor, 5);
|
||||||
|
|
||||||
|
// 얼음/눈 (흰색)
|
||||||
|
r = 245 * polarFactor + (1 - polarFactor) * r;
|
||||||
|
g = 245 * polarFactor + (1 - polarFactor) * g;
|
||||||
|
b = 250 * polarFactor + (1 - polarFactor) * b;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
data[stride] = Math.max(0, Math.min(255, r));
|
||||||
|
data[stride + 1] = Math.max(0, Math.min(255, g));
|
||||||
|
data[stride + 2] = Math.max(0, Math.min(255, b));
|
||||||
|
data[stride + 3] = a; // Alpha 값 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 배열과 크기를 반환
|
||||||
|
return { data, width, height };
|
||||||
|
};
|
17
src/textureWorker.ts
Normal file
17
src/textureWorker.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import { generateGlobeTextureData } from "./texture-generator";
|
||||||
|
|
||||||
|
// Worker 메시지 리스너
|
||||||
|
self.onmessage = (event: MessageEvent<{ width: number, height: number, seed?: number }>) => {
|
||||||
|
const { width, height, seed } = event.data;
|
||||||
|
console.log("Worker: ", width, height, seed);
|
||||||
|
const textureData = generateGlobeTextureData(width, height, seed);
|
||||||
|
// 생성된 데이터를 메인 스레드로 전송 (Transferable Object로 성능 향상)
|
||||||
|
self.postMessage(textureData,
|
||||||
|
// transferable objects
|
||||||
|
[textureData.data.buffer]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export {}; // 모듈로 인식시키기 위함
|
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
26
tsconfig.app.json
Normal file
26
tsconfig.app.json
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
24
tsconfig.node.json
Normal file
24
tsconfig.node.json
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), react()],
|
||||||
|
})
|
Loading…
Add table
Reference in a new issue