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 = ({ position, size, color, onClick, id, hasRooftop }) => { const [hovered, setHovered] = useState(false); const meshRef = useRef(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 ( {/* 본체 건물 */} { event.stopPropagation(); onClick(); }} onPointerOver={(event) => { event.stopPropagation(); setHovered(true); }} onPointerOut={(event) => setHovered(false)} castShadow receiveShadow > {/* 옥탑 구조물 (선택적 렌더링) */} {hasRooftop && ( )} ); } /** * 나무 컴포넌트 */ const Tree: FC = ({ position }) => { return ( {/* 바닥 */} ); } /** * 도로 컴포넌트 */ const Road: FC = ({ position, size }) => { return ( {/* 도로 색상을 중간 회색으로 변경 */} ); } /** * 정보 표시 박스 컴포넌트 (Tailwind CSS 적용) */ const InfoBox: FC = ({ buildingInfo }) => { if (!buildingInfo) return null; // 위치와 크기 정보를 소수점 아래 두 자리까지만 표시 const formatArray = (arr: number[]) => arr.map(n => n.toFixed(2)).join(', '); return ( // Tailwind CSS 클래스 적용

Selected Building

ID: {buildingInfo.id}

{/* position[1]은 그룹 기준이므로 실제 바닥부터의 높이 표시 위해 size[1]/2 추가 */}

Position: [{formatArray([buildingInfo.position[0], buildingInfo.position[1] - buildingInfo.size[1]/2, buildingInfo.position[2]])}]

Size: [{formatArray(buildingInfo.size)}]

{/* 색상 정보는 hex 코드로 표시 */}

Color: {buildingInfo.color}

); } // --- 메인 앱 컴포넌트 --- const App: FC = () => { // useState 타입 명시: BuildingData 또는 null const [selectedBuilding, setSelectedBuilding] = useState(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 (
setSelectedBuilding(null)} shadows > {/* 조명: Environment 사용 시 강도 조절 */} {/* Environment preset과 어울리도록 DirectionalLight 강도/색상 조절 가능 */} {/* --- 환경 맵 추가 --- */} {/* preset="city" 또는 "sunset", "dawn", "apartment" 등 시도 */} {/* 건물 렌더링 */} {cityData.buildings.map((building) => ( handleBuildingClick(building)} /> ))} {/* 도로 렌더링 */} {cityData.roads.map((road, index) => ( ))} {/* 나무 렌더링 */} {cityData.trees.map((tree) => ( ))} {/* --- 바닥 평면에 MeshReflectorMaterial 적용 --- */}
); } export default App; // --- TypeScript 및 Tailwind CSS 설정 참고 --- // 이전과 동일 // --- 필요 라이브러리 설치 --- // 이전과 동일 // --- 실행 방법 --- // 이전과 동일