From e9ca009747afe0f1cf20e8df6ec8f8c057a71537 Mon Sep 17 00:00:00 2001 From: haru Date: Sun, 21 Dec 2025 03:29:03 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(frontend):=20snowdome=E3=81=AE?= =?UTF-8?q?=E5=87=A6=E7=90=86=E4=BD=9C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/features/room/calendar.tsx | 1 + .../src/features/room/draggableSnowdome.tsx | 251 ++++++++++++++++ .../features/room/hooks/useCalendarFocus.ts | 2 +- .../features/room/hooks/useItemAcquisition.ts | 277 ++++++++++++++++-- frontend/src/features/room/placedItems.tsx | 125 +++++++- frontend/src/routes/$roomId/index.lazy.tsx | 86 +++++- 6 files changed, 709 insertions(+), 33 deletions(-) create mode 100644 frontend/src/features/room/draggableSnowdome.tsx diff --git a/frontend/src/features/room/calendar.tsx b/frontend/src/features/room/calendar.tsx index c3b0925..6acda76 100644 --- a/frontend/src/features/room/calendar.tsx +++ b/frontend/src/features/room/calendar.tsx @@ -89,6 +89,7 @@ export default function Calendar({ castShadow receiveShadow onClick={(e) => { + e.stopPropagation(); // フォーカスモードでない場合は、カレンダー全体クリックでフォーカスモードへ if (!isFocusMode) { e.stopPropagation(); diff --git a/frontend/src/features/room/draggableSnowdome.tsx b/frontend/src/features/room/draggableSnowdome.tsx new file mode 100644 index 0000000..f0fb71d --- /dev/null +++ b/frontend/src/features/room/draggableSnowdome.tsx @@ -0,0 +1,251 @@ +import { Gltf, useGLTF } from "@react-three/drei"; +import { useFrame, useThree } from "@react-three/fiber"; +import type { RapierRigidBody } from "@react-three/rapier"; +import { RigidBody } from "@react-three/rapier"; +import type { CalendarItemWithItem } from "common/generate/adventSphereAPI.schemas"; +import { useCallback, useEffect, useRef, useState } from "react"; +import * as THREE from "three"; +import { R2_BASE_URL } from "@/constants/r2-url"; + +interface DraggableSnowdomeProps { + snowdomeParts: CalendarItemWithItem[]; + onPositionChange?: ( + position: [number, number, number], + rotation: [number, number, number], + ) => void; + isPlacementValid: boolean; + setIsPlacementValid: (valid: boolean) => void; + initialRotation?: [number, number, number]; + onLockChange?: (isLocked: boolean) => void; + roomRef: React.RefObject; + placedItemsRef?: React.RefObject; +} + +// 回転のステップ(ラジアン) +const ROTATION_STEP = Math.PI / 4; // 45度 + +export default function DraggableSnowdome({ + snowdomeParts, + onPositionChange, + setIsPlacementValid, + initialRotation = [0, 0, 0], + onLockChange, + roomRef, + placedItemsRef, +}: DraggableSnowdomeProps) { + const { camera, pointer, gl } = useThree(); + const rigidBodyRef = useRef(null); + const [rotation, setRotation] = + useState<[number, number, number]>(initialRotation); + // ロック状態:falseの時はドラッグ中(上空に固定)、trueの時は重力有効で落下 + const [isLocked, setIsLocked] = useState(false); + + const raycaster = useRef(new THREE.Raycaster()); + const lastNotifiedPosition = useRef<[number, number, number] | null>(null); + const lastNotifiedRotation = useRef<[number, number, number] | null>(null); + + // 各パーツのGLBをプリロード + for (const part of snowdomeParts) { + const modelUrl = `${R2_BASE_URL}/item/object/${part.item.id}.glb`; + useGLTF.preload(modelUrl); + } + + // レイキャストでマウス位置のXZ座標を取得(ドラッグ中のみ) + useFrame(() => { + // ロック中は位置を更新しない + if (isLocked) { + // ロック中は物理演算後の位置を取得して親に通知 + if (rigidBodyRef.current) { + const pos = rigidBodyRef.current.translation(); + const rot = rigidBodyRef.current.rotation(); + + // クォータニオンからオイラー角に変換 + const euler = new THREE.Euler().setFromQuaternion( + new THREE.Quaternion(rot.x, rot.y, rot.z, rot.w), + ); + + const currentPos: [number, number, number] = [pos.x, pos.y, pos.z]; + const currentRot: [number, number, number] = [ + euler.x, + euler.y, + euler.z, + ]; + + // 位置が安定したら(速度が小さくなったら)親に通知 + const velocity = rigidBodyRef.current.linvel(); + const speed = Math.sqrt( + velocity.x ** 2 + velocity.y ** 2 + velocity.z ** 2, + ); + + if (speed < 0.1) { + const shouldNotify = + !lastNotifiedPosition.current || + Math.abs(currentPos[0] - lastNotifiedPosition.current[0]) > 0.01 || + Math.abs(currentPos[1] - lastNotifiedPosition.current[1]) > 0.01 || + Math.abs(currentPos[2] - lastNotifiedPosition.current[2]) > 0.01; + + if (shouldNotify && onPositionChange) { + lastNotifiedPosition.current = currentPos; + lastNotifiedRotation.current = currentRot; + onPositionChange(currentPos, currentRot); + } + } + } + return; + } + + raycaster.current.setFromCamera(pointer, camera); + + // room と配置済みオブジェクトの両方に Raycast + const targets: THREE.Object3D[] = []; + if (roomRef.current) targets.push(roomRef.current); + if (placedItemsRef?.current) targets.push(placedItemsRef.current); + + const intersects = raycaster.current.intersectObjects(targets, true); + + if (!intersects.length || !rigidBodyRef.current) return; + + // 一番近いヒット + const hit = intersects[0]; + + // 床の法線(傾き対応) + const normal = + hit.face?.normal.clone().transformDirection(hit.object.matrixWorld) ?? + new THREE.Vector3(0, 1, 0); + + // アイテムの半分の高さ(適宜調整) + const halfHeight = 0; + + // 法線方向に少し浮かせる(めり込み防止) + const target = hit.point.clone().add(normal.multiplyScalar(halfHeight)); + + // 現在位置 + const current = rigidBodyRef.current.translation(); + + // なめらかに追従(滑る感じ) + const next = { + x: THREE.MathUtils.lerp(current.x, target.x, 0.25), + y: THREE.MathUtils.lerp(current.y, target.y, 0.25), + z: THREE.MathUtils.lerp(current.z, target.z, 0.25), + }; + + // ★ ここが肝 + rigidBodyRef.current.setNextKinematicTranslation(next); + + // 回転(そのままでOK) + const euler = new THREE.Euler(rotation[0], rotation[1], rotation[2]); + const quaternion = new THREE.Quaternion().setFromEuler(euler); + rigidBodyRef.current.setRotation( + { x: quaternion.x, y: quaternion.y, z: quaternion.z, w: quaternion.w }, + true, + ); + + // レイキャストがヒットしない場合は配置不可 + if (!intersects.length) { + setIsPlacementValid(false); + return; + } + + // 仮:床に当たっていれば配置可能 + setIsPlacementValid(true); + }); + + // 右クリックまたはRキーで回転 + const handleRotate = useCallback(() => { + if (isLocked) return; // ロック中は回転不可 + + setRotation((prev) => { + const newRotation: [number, number, number] = [ + prev[0], + prev[1] + ROTATION_STEP, + prev[2], + ]; + return newRotation; + }); + }, [isLocked]); + + // キーボードイベント(Rキーで回転) + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "r" || e.key === "R") { + e.preventDefault(); + handleRotate(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleRotate]); + + // 右クリックイベント(回転) + useEffect(() => { + const canvas = gl.domElement; + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); + handleRotate(); + }; + + canvas.addEventListener("contextmenu", handleContextMenu); + return () => { + canvas.removeEventListener("contextmenu", handleContextMenu); + }; + }, [gl.domElement, handleRotate]); + + // 左クリックでロック(落下開始)/アンロック(再配置) + useEffect(() => { + const canvas = gl.domElement; + const handleClick = (e: MouseEvent) => { + // 左クリックのみ + if (e.button !== 0) return; + + setIsLocked((prev) => { + const newLocked = !prev; + onLockChange?.(newLocked); + + // アンロック(再配置)時は速度をリセット + if (!newLocked && rigidBodyRef.current) { + rigidBodyRef.current.setLinvel({ x: 0, y: 0, z: 0 }, true); + rigidBodyRef.current.setAngvel({ x: 0, y: 0, z: 0 }, true); + } + + return newLocked; + }); + }; + + canvas.addEventListener("click", handleClick); + return () => { + canvas.removeEventListener("click", handleClick); + }; + }, [gl.domElement, onLockChange]); + + return ( + + {/* 全snowdomeパーツを同じ位置に重ねてレンダリング */} + + {snowdomeParts.map((part) => ( + + ))} + + + {/* 配置位置のビジュアルフィードバック(ドラッグ中のみ表示) */} + {!isLocked && ( + + + + + )} + + ); +} diff --git a/frontend/src/features/room/hooks/useCalendarFocus.ts b/frontend/src/features/room/hooks/useCalendarFocus.ts index 433c303..c1f9f11 100644 --- a/frontend/src/features/room/hooks/useCalendarFocus.ts +++ b/frontend/src/features/room/hooks/useCalendarFocus.ts @@ -38,7 +38,7 @@ export function useCalendarFocus() { // 縦横それぞれで必要な距離を計算し、大きい方を採用 const distanceForHeight = calendarHeight / (2 * Math.tan(fov / 2)); const distanceForWidth = calendarWidth / (2 * Math.tan(fov / 2) * aspect); - const distance = Math.max(distanceForHeight, distanceForWidth) * 0.6; + const distance = Math.max(distanceForHeight, distanceForWidth) * 0.9; // 先にフォーカスモードをONにして暗くする setIsFocusMode(true); diff --git a/frontend/src/features/room/hooks/useItemAcquisition.ts b/frontend/src/features/room/hooks/useItemAcquisition.ts index 73505a6..92ec2b2 100644 --- a/frontend/src/features/room/hooks/useItemAcquisition.ts +++ b/frontend/src/features/room/hooks/useItemAcquisition.ts @@ -11,7 +11,32 @@ import { } from "common/generate/calendar-items/calendar-items"; import { useCallback, useMemo, useState } from "react"; -export type AcquisitionPhase = "idle" | "get_modal" | "placement" | "completed"; +export type AcquisitionPhase = + | "idle" + | "get_modal" + | "placement" + | "snowdome_placement" + | "completed"; + +/** + * snowdomeアイテムかどうか判定 + */ +function isSnowdomeItem(item: CalendarItemWithItem): boolean { + return item.item.type === "snowdome"; +} + +/** + * 4日目(最終日)かどうか判定 + */ +function isSnowdomeFinalDay(room: Room, openDate: Date): boolean { + if (!room.snowDomePartsLastDate) return false; + const lastDate = new Date(room.snowDomePartsLastDate); + return ( + openDate.getFullYear() === lastDate.getFullYear() && + openDate.getMonth() === lastDate.getMonth() && + openDate.getDate() === lastDate.getDate() + ); +} interface UseItemAcquisitionProps { roomId: string; @@ -105,10 +130,9 @@ export function useItemAcquisition({ } // openDateの時刻を過ぎているか - // const now = new Date(); - // const openDate = new Date(item.openDate); - // TODO: テスト用に時刻チェックを一時的に無効化 - return true; // now >= openDate; + const now = new Date(); + const openDate = new Date(item.openDate); + return now >= openDate; }, [room, getItemForDay], ); @@ -152,13 +176,6 @@ export function useItemAcquisition({ [canOpenDay, getItemForDay], ); - /** - * ゲットモーダルから「次へ」を押した時 - */ - const handleNextFromGetModal = useCallback(() => { - setPhase("placement"); - }, []); - /** * クエリの無効化 */ @@ -186,6 +203,87 @@ export function useItemAcquisition({ setTargetDay(null); }, []); + /** + * インベントリにあるsnowdomeパーツを取得(開封済み・未配置) + */ + const getInventorySnowdomeParts = useCallback((): CalendarItemWithItem[] => { + if (!calendarItems) return []; + return calendarItems.filter( + (item) => + item.item.type === "snowdome" && item.isOpened && !item.position, + ); + }, [calendarItems]); + + /** + * 指定位置に配置されているsnowdomeパーツを取得 + */ + const getPlacedSnowdomePartsAtPosition = useCallback( + (position: [number, number, number]): CalendarItemWithItem[] => { + if (!calendarItems) return []; + return calendarItems.filter( + (item) => + item.item.type === "snowdome" && + item.isOpened && + item.position && + item.position.length === 3 && + Math.abs((item.position[0] as number) - position[0]) < 0.001 && + Math.abs((item.position[1] as number) - position[1]) < 0.001 && + Math.abs((item.position[2] as number) - position[2]) < 0.001, + ); + }, + [calendarItems], + ); + + /** + * ゲットモーダルから「次へ」を押した時 + */ + const handleNextFromGetModal = useCallback(async () => { + if (!targetCalendarItem || !room) { + setPhase("placement"); + return; + } + + // snowdomeアイテムの場合は特殊処理 + if (isSnowdomeItem(targetCalendarItem)) { + const openDate = new Date(targetCalendarItem.openDate); + const isFinalDay = isSnowdomeFinalDay(room, openDate); + + if (isFinalDay) { + // 4日目: snowdome配置モードへ + setPhase("snowdome_placement"); + } else { + // 1-3日目: 自動で持ち物へ(配置をスキップ) + await patchCalendarItem({ + roomId, + id: targetCalendarItem.id, + data: { + calendarItem: { + isOpened: true, + position: null, + rotation: null, + }, + }, + }); + await invalidateQueries(); + setPhase("completed"); + setTimeout(() => { + resetFlow(); + }, 100); + } + return; + } + + // 通常アイテム: 配置モードへ + setPhase("placement"); + }, [ + targetCalendarItem, + room, + roomId, + patchCalendarItem, + invalidateQueries, + resetFlow, + ]); + /** * アイテム配置確定時 */ @@ -263,11 +361,30 @@ export function useItemAcquisition({ */ const startPlacementFromInventory = useCallback( (calendarItem: CalendarItemWithItem) => { - setTargetCalendarItem(calendarItem); - setTargetDay(null); // 持ち物からの配置なので日付は不要 - setPhase("placement"); + // snowdomeアイテムの場合は、配置済みの場合は同じ位置の全パーツを取得 + if (isSnowdomeItem(calendarItem)) { + if (calendarItem.position && calendarItem.position.length === 3) { + // 配置済みのsnowdomeを再配置する場合 + const position = calendarItem.position as [number, number, number]; + const allParts = getPlacedSnowdomePartsAtPosition(position); + // 最初のパーツをtargetとして設定(全パーツはsnowdome_placementで処理) + setTargetCalendarItem(allParts[0] || calendarItem); + setTargetDay(null); + setPhase("snowdome_placement"); + } else { + // インベントリから配置する場合 + setTargetCalendarItem(calendarItem); + setTargetDay(null); + setPhase("snowdome_placement"); + } + } else { + // 通常アイテム + setTargetCalendarItem(calendarItem); + setTargetDay(null); // 持ち物からの配置なので日付は不要 + setPhase("placement"); + } }, - [], + [getPlacedSnowdomePartsAtPosition], ); /** @@ -275,20 +392,125 @@ export function useItemAcquisition({ */ const returnPlacedItemToInventory = useCallback( async (calendarItem: CalendarItemWithItem) => { - await patchCalendarItem({ - roomId, - id: calendarItem.id, - data: { - calendarItem: { - position: null, - rotation: null, + // snowdomeアイテムの場合は、同じ位置の全パーツをまとめて戻す + if ( + isSnowdomeItem(calendarItem) && + calendarItem.position && + calendarItem.position.length === 3 + ) { + const position = calendarItem.position as [number, number, number]; + const allParts = getPlacedSnowdomePartsAtPosition(position); + + // 全パーツをまとめてposition/rotationをnullにする + const returnPromises = allParts.map((part) => + patchCalendarItem({ + roomId, + id: part.id, + data: { + calendarItem: { + position: null, + rotation: null, + }, + }, + }), + ); + + await Promise.all(returnPromises); + } else { + // 通常アイテム + await patchCalendarItem({ + roomId, + id: calendarItem.id, + data: { + calendarItem: { + position: null, + rotation: null, + }, }, - }, - }); + }); + } await invalidateQueries(); }, - [roomId, patchCalendarItem, invalidateQueries], + [ + roomId, + patchCalendarItem, + invalidateQueries, + getPlacedSnowdomePartsAtPosition, + ], + ); + + /** + * snowdome配置確定時(全パーツを同じ位置に配置) + */ + const handleSnowdomePlacement = useCallback( + async ( + position: [number, number, number], + rotation: [number, number, number], + ) => { + let allParts: CalendarItemWithItem[] = []; + + if (targetCalendarItem) { + // 再配置の場合(既に配置済みのsnowdomeを移動) + if ( + targetCalendarItem.position && + targetCalendarItem.position.length === 3 + ) { + const oldPosition = targetCalendarItem.position as [ + number, + number, + number, + ]; + // 旧位置の全パーツを取得 + allParts = getPlacedSnowdomePartsAtPosition(oldPosition); + } else { + // 新規配置の場合(インベントリから配置) + const inventoryParts = getInventorySnowdomeParts(); + allParts = [...inventoryParts, targetCalendarItem]; + } + } else { + // targetCalendarItemがない場合はインベントリのパーツのみ + allParts = getInventorySnowdomeParts(); + } + + // 重複を除去 + const uniqueParts = allParts.filter( + (part, index, self) => + self.findIndex((p) => p.id === part.id) === index, + ); + + // 各パーツを同じ位置に配置 + const placementPromises = uniqueParts.map((part) => + patchCalendarItem({ + roomId, + id: part.id, + data: { + calendarItem: { + isOpened: true, + position, + rotation, + }, + }, + }), + ); + + await Promise.all(placementPromises); + await invalidateQueries(); + setPhase("completed"); + + setTimeout(() => { + resetFlow(); + }, 100); + }, + [ + targetCalendarItem, + getInventorySnowdomeParts, + getPlacedSnowdomePartsAtPosition, + roomId, + patchCalendarItem, + invalidateQueries, + resetFlow, + ], ); return { @@ -302,9 +524,12 @@ export function useItemAcquisition({ handleNextFromGetModal, handlePlacement, handleSkipPlacement, + handleSnowdomePlacement, resetFlow, startPlacementFromInventory, returnPlacedItemToInventory, + getInventorySnowdomeParts, + getPlacedSnowdomePartsAtPosition, isPending, }; } diff --git a/frontend/src/features/room/placedItems.tsx b/frontend/src/features/room/placedItems.tsx index 1a5548d..a3671c8 100644 --- a/frontend/src/features/room/placedItems.tsx +++ b/frontend/src/features/room/placedItems.tsx @@ -44,9 +44,30 @@ const PlacedItems = forwardRef( item.id !== excludeItemId, ); + // snowdomeパーツと通常アイテムを分離 + const snowdomeItems = placedItems.filter( + (item) => item.item.type === "snowdome", + ); + const normalItems = placedItems.filter( + (item) => item.item.type !== "snowdome", + ); + + // snowdomeパーツを位置ごとにグループ化 + const snowdomeGroups = new Map(); + for (const item of snowdomeItems) { + const positionKey = JSON.stringify(item.position); + const existingGroup = snowdomeGroups.get(positionKey); + if (existingGroup) { + existingGroup.push(item); + } else { + snowdomeGroups.set(positionKey, [item]); + } + } + return ( - {placedItems.map((item) => ( + {/* 通常アイテム */} + {normalItems.map((item) => ( ( isPending={isPending} /> ))} + {/* snowdomeパーツグループ */} + {Array.from(snowdomeGroups.entries()).map(([positionKey, parts]) => { + // グループ内のいずれかのパーツが選択されているかチェック + const isSelected = parts.some((part) => part.id === selectedItemId); + const firstPart = parts[0]; + const position = firstPart.position as [number, number, number]; + const rotation = (firstPart.rotation as [number, number, number]) ?? [ + 0, 0, 0, + ]; + + return ( + + ); + })} ); }, @@ -66,6 +111,84 @@ PlacedItems.displayName = "PlacedItems"; export default PlacedItems; +interface PlacedSnowdomeGroupProps { + snowdomeParts: CalendarItemWithItem[]; + position: [number, number, number]; + rotation: [number, number, number]; + isSelected: boolean; + onItemClick: (calendarItem: CalendarItemWithItem) => void; + onReposition: () => void; + onReturnToInventory: () => void; + isPending: boolean; +} + +/** + * 配置済みsnowdomeパーツをグループとしてレンダリング + */ +function PlacedSnowdomeGroup({ + snowdomeParts, + position, + rotation, + isSelected, + onItemClick, + onReposition, + onReturnToInventory, + isPending, +}: PlacedSnowdomeGroupProps) { + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + // 最初のパーツをクリックとして扱う + onItemClick(snowdomeParts[0]); + }; + + return ( + + {/** biome-ignore lint/a11y/noStaticElementInteractions: 静的要素にはインタラクティブな操作を追加しない */} + + {/* 全snowdomeパーツを同じ位置に重ねてレンダリング */} + {snowdomeParts.map((part) => ( + + ))} + + {isSelected && ( + +
+ + +
+ + )} +
+ ); +} + interface PlacedItemProps { calendarItem: CalendarItemWithItem; isSelected: boolean; diff --git a/frontend/src/routes/$roomId/index.lazy.tsx b/frontend/src/routes/$roomId/index.lazy.tsx index 9a57bb5..cf6c26f 100644 --- a/frontend/src/routes/$roomId/index.lazy.tsx +++ b/frontend/src/routes/$roomId/index.lazy.tsx @@ -13,6 +13,7 @@ import Loading from "@/components/Loading"; import { Button } from "@/components/ui/button"; import { R2_BASE_URL } from "@/constants/r2-url"; import Calendar from "@/features/room/calendar"; +import DraggableSnowdome from "@/features/room/draggableSnowdome"; import { useCalendarFocus } from "@/features/room/hooks/useCalendarFocus"; import { useItemAcquisition } from "@/features/room/hooks/useItemAcquisition"; import InventoryDialog from "@/features/room/inventoryDialog"; @@ -48,6 +49,7 @@ const CALENDAR_POSITION: [number, number, number] = [0, 1, 0]; function RouteComponent() { const { roomId } = Route.useParams(); const { data: room } = useGetRoomsId(roomId); + console.log("room", room); const { data: calendarItems } = useGetCalendarItemsRoomIdCalendarItems(roomId); const [isInventoryDialogOpen, setIsInventoryDialogOpen] = useState(false); @@ -73,9 +75,12 @@ function RouteComponent() { handleNextFromGetModal, handlePlacement, handleSkipPlacement, + handleSnowdomePlacement, resetFlow, startPlacementFromInventory, returnPlacedItemToInventory, + getInventorySnowdomeParts, + getPlacedSnowdomePartsAtPosition, isPending, } = useItemAcquisition({ roomId, @@ -216,11 +221,53 @@ function RouteComponent() { // 配置モード中かどうか const isPlacementMode = phase === "placement"; + const isSnowdomePlacementMode = phase === "snowdome_placement"; + const isAnyPlacementMode = isPlacementMode || isSnowdomePlacementMode; + + // snowdome配置用のパーツを計算 + const snowdomePartsForPlacement = useMemo(() => { + if (!isSnowdomePlacementMode || !calendarItems || !targetCalendarItem) { + return []; + } + + // 再配置の場合(既に配置済みのsnowdomeを移動) + if ( + targetCalendarItem.position && + targetCalendarItem.position.length === 3 + ) { + const position = targetCalendarItem.position as [number, number, number]; + return getPlacedSnowdomePartsAtPosition(position); + } + + // 新規配置の場合(インベントリから配置) + const inventoryParts = getInventorySnowdomeParts(); + const allParts = [...inventoryParts, targetCalendarItem]; + + // 重複を除去 + return allParts.filter( + (part, index, self) => self.findIndex((p) => p.id === part.id) === index, + ); + }, [ + isSnowdomePlacementMode, + calendarItems, + targetCalendarItem, + getInventorySnowdomeParts, + getPlacedSnowdomePartsAtPosition, + ]); + + // snowdome配置確定時 + const handleConfirmSnowdomePlacement = async () => { + if (tempPosition && tempRotation && isPlacementValid) { + await handleSnowdomePlacement(tempPosition, tempRotation); + setOpenedDrawers([]); + resetTempPlacement(); + } + }; return (
- {!isFocusMode && !isPlacementMode && ( + {!isFocusMode && !isAnyPlacementMode && (