react-threejs-example/src/App1.tsx
2025-04-12 04:03:35 +09:00

336 lines
11 KiB
TypeScript

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 설정 참고 ---
// 이전과 동일
// --- 필요 라이브러리 설치 ---
// 이전과 동일
// --- 실행 방법 ---
// 이전과 동일