중력 적용 및 요소 쌓기 구현

This commit is contained in:
dohyeons 2025-10-27 11:40:11 +09:00
parent f0bb349c8c
commit 3b5f0b638f
2 changed files with 109 additions and 53 deletions

View File

@ -73,39 +73,50 @@ function MaterialBox({
const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { camera, gl } = useThree();
// 특정 좌표에 다른 요소가 있는지 확인 (AABB 충돌 감지)
const checkCollision = (x: number, z: number): boolean => {
// 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정
const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
const mySize = placement.size_x || gridSize; // 내 크기 (5)
const myHalfSize = mySize / 2; // 2.5
const mySizeY = placement.size_y || gridSize; // 내 높이 (5)
return allPlacements.some((p) => {
// 자기 자신은 제외 (엄격한 비교)
let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5)
for (const p of allPlacements) {
// 자기 자신은 제외
if (Number(p.id) === Number(placement.id)) {
return false;
continue;
}
const pSize = p.size_x || gridSize; // 상대방 크기 (5)
const pHalfSize = pSize / 2; // 2.5
const pSizeY = p.size_y || gridSize; // 상대방 높이 (5)
// AABB (Axis-Aligned Bounding Box) 충돌 감지
// 두 박스가 겹치는지 확인
const isOverlapping =
// XZ 평면에서 겹치는지 확인
const isXZOverlapping =
Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침
Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침
if (isOverlapping) {
console.log("🔴 충돌 감지! (AABB)", {
current: `${placement.id} at (${x}, ${z}) size=${mySize}`,
blocking: `${p.id} at (${p.position_x}, ${p.position_z}) size=${pSize}`,
distance: {
x: Math.abs(x - p.position_x),
z: Math.abs(z - p.position_z),
},
});
}
if (isXZOverlapping) {
// 같은 XZ 위치에 요소가 있음
// 그 요소의 윗면 높이 계산 (중심 + 높이/2)
const topOfOtherElement = p.position_y + pSizeY / 2;
// 내가 올라갈 Y 위치는 윗면 + 내 높이/2
const myYOnTop = topOfOtherElement + mySizeY / 2;
return isOverlapping;
});
// 가장 높은 위치 기록
if (myYOnTop > maxYBelow) {
maxYBelow = myYOnTop;
}
}
}
// 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함)
const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
return {
hasCollision: needsAdjustment,
adjustedY: maxYBelow,
};
};
// 드래그 중이 아닐 때만 위치 동기화
@ -159,8 +170,8 @@ function MaterialBox({
.multiplyScalar(deltaY * scaleFactor);
// 최종 위치 계산
let finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
let finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
const finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
// NaN 검증
if (isNaN(finalX) || isNaN(finalZ)) {
@ -171,12 +182,14 @@ function MaterialBox({
const snappedX = snapToGrid(finalX, gridSize);
const snappedZ = snapToGrid(finalZ, gridSize);
// 충돌 체크 (시각 피드백용 - 실제 차단은 마우스 업 시)
const hasCollision = checkCollision(snappedX, snappedZ);
setIsValidPosition(!hasCollision);
// 충돌 체크 및 Y 위치 조정 (시각 피드백용)
const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
// 즉시 mesh 위치 업데이트 (부드러운 드래그 - 스냅되기 전 위치)
meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
// 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문)
setIsValidPosition(true);
// 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
meshRef.current.position.set(finalX, adjustedY, finalZ);
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
// 실제 저장은 handleGlobalMouseUp에서만 수행
@ -198,31 +211,20 @@ function MaterialBox({
const snappedX = snapToGrid(currentPos.x, gridSize);
const snappedZ = snapToGrid(currentPos.z, gridSize);
// 충돌 체크: 최종 위치에서만 체크 (AABB 방식)
const hasCollision = checkCollision(snappedX, snappedZ);
// Y 위치 조정 (마인크래프트처럼 쌓기)
const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ);
if (hasCollision) {
// ⛔ 충돌 시: 원래 위치로 되돌리고 저장 안 함
console.log("⛔ 충돌! 원래 위치로 복원:", dragStartPos.current);
meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z);
setIsValidPosition(true);
// 충돌 감지 콜백 호출 (Toast 알림)
if (onCollisionDetected) {
onCollisionDetected();
}
// ⚠️ 중요: onDrag 호출하지 않음 (상태 업데이트 안 함)
} else {
// ✅ 충돌 없음: 스냅된 위치로 최종 설정하고 저장
console.log("✅ 충돌 없음! 저장:", { x: snappedX, y: dragStartPos.current.y, z: snappedZ });
meshRef.current.position.set(snappedX, dragStartPos.current.y, snappedZ);
// 최종 위치 저장 (이것만 실제 상태 업데이트)
if (onDrag) {
onDrag({
x: snappedX,
y: dragStartPos.current.y,
z: snappedZ,
});
}
// ✅ 항상 배치 가능 (위로 올라가므로)
console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ });
meshRef.current.position.set(snappedX, adjustedY, snappedZ);
// 최종 위치 저장 (조정된 Y 위치로)
if (onDrag) {
onDrag({
x: snappedX,
y: adjustedY,
z: snappedZ,
});
}
} else {
// 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함)
@ -387,10 +389,14 @@ function Scene({
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={10}
minDistance={8}
maxDistance={200}
maxPolarAngle={Math.PI / 2}
enabled={!isDraggingAny}
reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동)
screenSpacePanning={true} // 화면 공간 패닝
panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
rotateSpeed={0.5} // 회전 속도
/>
</>
);

View File

@ -180,12 +180,62 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
setDeleteConfirmDialog({ open: true, placementId });
};
// 중력 적용: 삭제된 요소 위에 있던 요소들을 아래로 내림
const applyGravity = (deletedPlacement: YardPlacement, remainingPlacements: YardPlacement[]) => {
const gridSize = 5;
const halfSize = gridSize / 2;
return remainingPlacements.map((p) => {
// 삭제된 요소와 XZ가 겹치는지 확인
const isXZOverlapping =
Math.abs(p.position_x - deletedPlacement.position_x) < gridSize &&
Math.abs(p.position_z - deletedPlacement.position_z) < gridSize;
// 삭제된 요소보다 위에 있는지 확인
const isAbove = p.position_y > deletedPlacement.position_y;
if (isXZOverlapping && isAbove) {
// 아래로 내림: 삭제된 요소의 크기만큼
const fallDistance = deletedPlacement.size_y || gridSize;
const newY = Math.max(halfSize, p.position_y - fallDistance); // 바닥(2.5) 아래로는 안 내려감
return {
...p,
position_y: newY,
};
}
return p;
});
};
// 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영)
const confirmDeletePlacement = () => {
const { placementId } = deleteConfirmDialog;
if (placementId === null) return;
setPlacements((prev) => prev.filter((p) => p.id !== placementId));
setPlacements((prev) => {
const deletedPlacement = prev.find((p) => p.id === placementId);
if (!deletedPlacement) return prev;
// 삭제 후 남은 요소들
const remaining = prev.filter((p) => p.id !== placementId);
// 중력 적용 (재귀적으로 계속 적용)
let result = remaining;
let hasChanges = true;
// 모든 요소가 안정될 때까지 반복
while (hasChanges) {
const before = JSON.stringify(result.map((p) => p.position_y));
result = applyGravity(deletedPlacement, result);
const after = JSON.stringify(result.map((p) => p.position_y));
hasChanges = before !== after;
}
return result;
});
if (selectedPlacement?.id === placementId) {
setSelectedPlacement(null);
setShowConfigPanel(false);