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