better placing tools
This commit is contained in:
@@ -11,6 +11,7 @@ import { ActionMenu } from '../ui/action-menu'
|
|||||||
import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
|
import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
|
||||||
import { PascalRadio } from '../pascal-radio'
|
import { PascalRadio } from '../pascal-radio'
|
||||||
import { PanelManager } from '../ui/panels/panel-manager'
|
import { PanelManager } from '../ui/panels/panel-manager'
|
||||||
|
import { HelperManager } from '../ui/helpers/helper-manager'
|
||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
@@ -34,6 +35,7 @@ export default function Editor() {
|
|||||||
<div className="w-full h-full">
|
<div className="w-full h-full">
|
||||||
<ActionMenu />
|
<ActionMenu />
|
||||||
<PanelManager />
|
<PanelManager />
|
||||||
|
<HelperManager />
|
||||||
|
|
||||||
{/* Top-right controls */}
|
{/* Top-right controls */}
|
||||||
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
|
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
|
||||||
|
|||||||
@@ -17,12 +17,17 @@ import { useFrame } from '@react-three/fiber'
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
BoxGeometry,
|
BoxGeometry,
|
||||||
|
EdgesGeometry,
|
||||||
Euler,
|
Euler,
|
||||||
|
type Group,
|
||||||
|
type LineSegments,
|
||||||
type Mesh,
|
type Mesh,
|
||||||
type MeshStandardMaterial,
|
PlaneGeometry,
|
||||||
Quaternion,
|
Quaternion,
|
||||||
Vector3,
|
Vector3,
|
||||||
} from 'three'
|
} from 'three'
|
||||||
|
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
|
||||||
|
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
import { sfxEmitter } from '@/lib/sfx-bus'
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
|
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
|
||||||
import type { PlacementState, TransitionResult } from './placement-types'
|
import type { PlacementState, TransitionResult } from './placement-types'
|
||||||
@@ -30,6 +35,28 @@ import type { DraftNodeHandle } from './use-draft-node'
|
|||||||
|
|
||||||
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
|
||||||
|
|
||||||
|
// Shared materials for placement cursor - we just change colors, not swap materials
|
||||||
|
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
|
||||||
|
const edgeMaterial = new LineBasicNodeMaterial({
|
||||||
|
color: 0xef4444, // red-500 (invalid)
|
||||||
|
linewidth: 3,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const basePlaneMaterial = new MeshBasicNodeMaterial({
|
||||||
|
color: 0xef4444, // red-500 (invalid)
|
||||||
|
transparent: true,
|
||||||
|
depthTest: false,
|
||||||
|
depthWrite: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create radial opacity: transparent in center, opaque at edges
|
||||||
|
const center = vec2(0.5, 0.5)
|
||||||
|
const dist = distance(uv(), center)
|
||||||
|
const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
|
||||||
|
basePlaneMaterial.opacityNode = radialOpacity
|
||||||
|
|
||||||
export interface PlacementCoordinatorConfig {
|
export interface PlacementCoordinatorConfig {
|
||||||
asset: AssetInput
|
asset: AssetInput
|
||||||
draftNode: DraftNodeHandle
|
draftNode: DraftNodeHandle
|
||||||
@@ -40,7 +67,9 @@ export interface PlacementCoordinatorConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
|
||||||
const cursorRef = useRef<Mesh>(null!)
|
const cursorGroupRef = useRef<Group>(null!)
|
||||||
|
const edgesRef = useRef<LineSegments>(null!)
|
||||||
|
const basePlaneRef = useRef<Mesh>(null!)
|
||||||
const gridPosition = useRef(new Vector3(0, 0, 0))
|
const gridPosition = useRef(new Vector3(0, 0, 0))
|
||||||
const placementState = useRef<PlacementState>(
|
const placementState = useRef<PlacementState>(
|
||||||
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
|
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
|
||||||
@@ -77,7 +106,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const revalidate = (): boolean => {
|
const revalidate = (): boolean => {
|
||||||
const placeable = checkCanPlace(getContext(), validators)
|
const placeable = checkCanPlace(getContext(), validators)
|
||||||
;(cursorRef.current.material as MeshStandardMaterial).color.set(placeable ? 'green' : 'red')
|
const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500
|
||||||
|
edgeMaterial.color.setHex(color)
|
||||||
|
basePlaneMaterial.color.setHex(color)
|
||||||
return placeable
|
return placeable
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +116,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
Object.assign(placementState.current, result.stateUpdate)
|
Object.assign(placementState.current, result.stateUpdate)
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
|
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
cursorRef.current.rotation.y = result.cursorRotationY
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) {
|
if (draft) {
|
||||||
@@ -98,8 +129,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
const ensureDraft = (result: TransitionResult) => {
|
const ensureDraft = (result: TransitionResult) => {
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
cursorRef.current.rotation.y = result.cursorRotationY
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0])
|
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0])
|
||||||
|
|
||||||
@@ -122,14 +153,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (draftNode.current) {
|
if (draftNode.current) {
|
||||||
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
mesh.getWorldPosition(cursorRef.current.position)
|
mesh.getWorldPosition(cursorGroupRef.current.position)
|
||||||
// Extract world Y rotation (handles wall-parented items correctly)
|
// Extract world Y rotation (handles wall-parented items correctly)
|
||||||
const q = new Quaternion()
|
const q = new Quaternion()
|
||||||
mesh.getWorldQuaternion(q)
|
mesh.getWorldQuaternion(q)
|
||||||
cursorRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
|
||||||
} else {
|
} else {
|
||||||
cursorRef.current.position.copy(gridPosition.current)
|
cursorGroupRef.current.position.copy(gridPosition.current)
|
||||||
cursorRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,17 +175,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
// Play snap sound when grid position changes
|
// Play snap sound when grid position changes
|
||||||
if (previousGridPos &&
|
if (
|
||||||
(result.gridPosition[0] !== previousGridPos[0] ||
|
previousGridPos &&
|
||||||
result.gridPosition[2] !== previousGridPos[2])) {
|
(result.gridPosition[0] !== previousGridPos[0] ||
|
||||||
|
result.gridPosition[2] !== previousGridPos[2])
|
||||||
|
) {
|
||||||
sfxEmitter.emit('sfx:grid-snap')
|
sfxEmitter.emit('sfx:grid-snap')
|
||||||
}
|
}
|
||||||
|
|
||||||
previousGridPos = [...result.gridPosition]
|
previousGridPos = [...result.gridPosition]
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
|
||||||
cursorRef.current.position.x = result.cursorPosition[0]
|
cursorGroupRef.current.position.x = result.cursorPosition[0]
|
||||||
cursorRef.current.position.z = result.cursorPosition[2]
|
cursorGroupRef.current.position.z = result.cursorPosition[2]
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft) draft.position = result.gridPosition
|
if (draft) draft.position = result.gridPosition
|
||||||
@@ -167,7 +200,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
if (!result) return
|
if (!result) return
|
||||||
|
|
||||||
// Preserve cursor rotation for the next draft
|
// Preserve cursor rotation for the next draft
|
||||||
const currentRotation: [number, number, number] = [0, cursorRef.current.rotation.y, 0]
|
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
|
||||||
|
|
||||||
draftNode.commit(result.nodeUpdate)
|
draftNode.commit(result.nodeUpdate)
|
||||||
if (configRef.current.onCommitted()) {
|
if (configRef.current.onCommitted()) {
|
||||||
@@ -237,8 +270,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
gridPosition.current.z !== result.gridPosition[2]
|
gridPosition.current.z !== result.gridPosition[2]
|
||||||
|
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
cursorRef.current.rotation.y = result.cursorRotationY
|
cursorGroupRef.current.rotation.y = result.cursorRotationY
|
||||||
|
|
||||||
const draft = draftNode.current
|
const draft = draftNode.current
|
||||||
if (draft && result.nodeUpdate) {
|
if (draft && result.nodeUpdate) {
|
||||||
@@ -361,7 +394,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
gridPosition.current.set(...result.gridPosition)
|
gridPosition.current.set(...result.gridPosition)
|
||||||
cursorRef.current.position.set(...result.cursorPosition)
|
cursorGroupRef.current.position.set(...result.cursorPosition)
|
||||||
|
|
||||||
revalidate()
|
revalidate()
|
||||||
|
|
||||||
@@ -441,12 +474,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
|
|
||||||
if (rotationDelta !== 0) {
|
if (rotationDelta !== 0) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
const currentRotation = draft.rotation
|
const currentRotation = draft.rotation
|
||||||
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
|
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
|
||||||
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
|
||||||
|
|
||||||
// Ref + cursor mesh + item mesh — no store update during drag
|
// Ref + cursor mesh + item mesh — no store update during drag
|
||||||
cursorRef.current.rotation.y = newRotationY
|
cursorGroupRef.current.rotation.y = newRotationY
|
||||||
const mesh = sceneRegistry.nodes.get(draft.id)
|
const mesh = sceneRegistry.nodes.get(draft.id)
|
||||||
if (mesh) mesh.rotation.y = newRotationY
|
if (mesh) mesh.rotation.y = newRotationY
|
||||||
revalidate()
|
revalidate()
|
||||||
@@ -468,7 +502,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
const dims = asset.dimensions ?? DEFAULT_DIMENSIONS
|
const dims = asset.dimensions ?? DEFAULT_DIMENSIONS
|
||||||
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||||
boxGeometry.translate(0, dims[1] / 2, 0)
|
boxGeometry.translate(0, dims[1] / 2, 0)
|
||||||
cursorRef.current.geometry = boxGeometry
|
const edgesGeometry = new EdgesGeometry(boxGeometry)
|
||||||
|
edgesRef.current.geometry = edgesGeometry
|
||||||
|
|
||||||
// ---- Subscribe ----
|
// ---- Subscribe ----
|
||||||
|
|
||||||
@@ -532,17 +567,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
|
|||||||
draftNode.current.rotation,
|
draftNode.current.rotation,
|
||||||
)
|
)
|
||||||
mesh.position.y = slabElevation
|
mesh.position.y = slabElevation
|
||||||
cursorRef.current.position.y = slabElevation
|
cursorGroupRef.current.position.y = slabElevation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const dims = config.asset.dimensions ?? DEFAULT_DIMENSIONS
|
||||||
|
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
|
||||||
|
initialBoxGeometry.translate(0, dims[1] / 2, 0)
|
||||||
|
|
||||||
|
// Base plane geometry (colored rectangle on the ground)
|
||||||
|
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
|
||||||
|
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
|
||||||
|
basePlaneGeometry.translate(0, 0.01, 0) // Slightly above ground to avoid z-fighting
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group>
|
<group ref={cursorGroupRef}>
|
||||||
<mesh ref={cursorRef}>
|
<lineSegments ref={edgesRef} material={edgeMaterial}>
|
||||||
<boxGeometry args={[0.1, 0.1, 0.1]} />
|
<edgesGeometry args={[initialBoxGeometry]} />
|
||||||
<meshStandardMaterial color="red" wireframe />
|
</lineSegments>
|
||||||
</mesh>
|
<mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} />
|
||||||
</group>
|
</group>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export const WallTool: React.FC = () => {
|
|||||||
const startingPoint = useRef(new Vector3(0, 0, 0))
|
const startingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const endingPoint = useRef(new Vector3(0, 0, 0))
|
const endingPoint = useRef(new Vector3(0, 0, 0))
|
||||||
const buildingState = useRef(0)
|
const buildingState = useRef(0)
|
||||||
|
const shiftPressed = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let gridPosition: [number, number] = [0, 0]
|
let gridPosition: [number, number] = [0, 0]
|
||||||
@@ -111,8 +112,10 @@ export const WallTool: React.FC = () => {
|
|||||||
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
|
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
|
||||||
|
|
||||||
if (buildingState.current === 1) {
|
if (buildingState.current === 1) {
|
||||||
// Snap to 45° angles
|
// Snap to 45° angles only if shift is not pressed
|
||||||
const snapped = snapTo45Degrees(startingPoint.current, cursorPosition)
|
const snapped = shiftPressed.current
|
||||||
|
? cursorPosition
|
||||||
|
: snapTo45Degrees(startingPoint.current, cursorPosition)
|
||||||
endingPoint.current.copy(snapped)
|
endingPoint.current.copy(snapped)
|
||||||
|
|
||||||
// Play snap sound only when the actual wall end position changes
|
// Play snap sound only when the actual wall end position changes
|
||||||
@@ -143,12 +146,28 @@ export const WallTool: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
shiftPressed.current = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
shiftPressed.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emitter.on('grid:move', onGridMove)
|
emitter.on('grid:move', onGridMove)
|
||||||
emitter.on('grid:click', onGridClick)
|
emitter.on('grid:click', onGridClick)
|
||||||
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
window.addEventListener('keyup', onKeyUp)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
emitter.off('grid:move', onGridMove)
|
emitter.off('grid:move', onGridMove)
|
||||||
emitter.off('grid:click', onGridClick)
|
emitter.off('grid:click', onGridClick)
|
||||||
|
window.removeEventListener('keydown', onKeyDown)
|
||||||
|
window.removeEventListener('keyup', onKeyUp)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -156,8 +175,8 @@ export const WallTool: React.FC = () => {
|
|||||||
<group>
|
<group>
|
||||||
{/* Cursor indicator */}
|
{/* Cursor indicator */}
|
||||||
<mesh ref={cursorRef}>
|
<mesh ref={cursorRef}>
|
||||||
<boxGeometry args={[0.2, 0.2, 0.2]} />
|
<sphereGeometry args={[0.1, 16, 16]} />
|
||||||
<meshStandardMaterial color="red" />
|
<meshBasicMaterial color="#a3a3a3" depthTest={false} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
|
|
||||||
{/* Wall preview */}
|
{/* Wall preview */}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { ItemHelper } from './item-helper'
|
||||||
|
import { WallHelper } from './wall-helper'
|
||||||
|
|
||||||
|
export function HelperManager() {
|
||||||
|
const tool = useEditor((s) => s.tool)
|
||||||
|
const movingNode = useEditor((state) => state.movingNode)
|
||||||
|
|
||||||
|
if (movingNode) {
|
||||||
|
return <ItemHelper />
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show appropriate helper based on current tool
|
||||||
|
switch (tool) {
|
||||||
|
case 'wall':
|
||||||
|
return <WallHelper />
|
||||||
|
case 'item':
|
||||||
|
return <ItemHelper />
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export function ItemHelper() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">R</kbd>
|
||||||
|
<span className="text-muted-foreground">Rotate counterclockwise</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">T</kbd>
|
||||||
|
<span className="text-muted-foreground">Rotate clockwise</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
|
||||||
|
<span className="text-muted-foreground">Cancel</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function WallHelper() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-2 shadow-lg backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
|
||||||
|
<span className="text-muted-foreground">Allow non-45° angles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ export function ItemPanel() {
|
|||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
if (!selectedId) return
|
if (!selectedId) return
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
deleteNode(selectedId as AnyNode['id'])
|
deleteNode(selectedId as AnyNode['id'])
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [selectedId, deleteNode, setSelection])
|
}, [selectedId, deleteNode, setSelection])
|
||||||
@@ -144,6 +145,7 @@ export function ItemPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||||
const newDegrees = currentDegrees - 90
|
const newDegrees = currentDegrees - 90
|
||||||
const radians = (newDegrees * Math.PI) / 180
|
const radians = (newDegrees * Math.PI) / 180
|
||||||
@@ -156,6 +158,7 @@ export function ItemPanel() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
sfxEmitter.emit('sfx:item-rotate')
|
||||||
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
const currentDegrees = (node.rotation[1] * 180) / Math.PI
|
||||||
const newDegrees = currentDegrees + 90
|
const newDegrees = currentDegrees + 90
|
||||||
const radians = (newDegrees * Math.PI) / 180
|
const radians = (newDegrees * Math.PI) / 180
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { type AnyNodeId, useScene } from '@pascal-app/core'
|
|||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import useEditor from '@/store/use-editor'
|
import useEditor from '@/store/use-editor'
|
||||||
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
||||||
|
|
||||||
export const useKeyboard = () => {
|
export const useKeyboard = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -47,6 +48,18 @@ export const useKeyboard = () => {
|
|||||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||||
|
|
||||||
if (selectedNodeIds.length > 0) {
|
if (selectedNodeIds.length > 0) {
|
||||||
|
// Play appropriate SFX based on what's being deleted
|
||||||
|
if (selectedNodeIds.length === 1) {
|
||||||
|
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||||
|
if (node?.type === 'item') {
|
||||||
|
sfxEmitter.emit('sfx:item-delete')
|
||||||
|
} else {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sfxEmitter.emit('sfx:structure-delete')
|
||||||
|
}
|
||||||
|
|
||||||
useScene.getState().deleteNodes(selectedNodeIds)
|
useScene.getState().deleteNodes(selectedNodeIds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type SFXEvents = {
|
|||||||
'sfx:item-delete': undefined
|
'sfx:item-delete': undefined
|
||||||
'sfx:item-pick': undefined
|
'sfx:item-pick': undefined
|
||||||
'sfx:item-place': undefined
|
'sfx:item-place': undefined
|
||||||
|
'sfx:item-rotate': undefined
|
||||||
'sfx:structure-build': undefined
|
'sfx:structure-build': undefined
|
||||||
'sfx:structure-delete': undefined
|
'sfx:structure-delete': undefined
|
||||||
}
|
}
|
||||||
@@ -29,6 +30,7 @@ export function initSFXBus() {
|
|||||||
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
|
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
|
||||||
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
||||||
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
||||||
|
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
||||||
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
|
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
|
||||||
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const SFX = {
|
|||||||
itemDelete: '/audios/sfx/item_delete.mp3',
|
itemDelete: '/audios/sfx/item_delete.mp3',
|
||||||
itemPick: '/audios/sfx/item_pick.mp3',
|
itemPick: '/audios/sfx/item_pick.mp3',
|
||||||
itemPlace: '/audios/sfx/item_place.mp3',
|
itemPlace: '/audios/sfx/item_place.mp3',
|
||||||
|
itemRotate: '/audios/sfx/item_rotate.mp3',
|
||||||
structureBuild: '/audios/sfx/structure_build.mp3',
|
structureBuild: '/audios/sfx/structure_build.mp3',
|
||||||
structureDelete: '/audios/sfx/structure_delete.mp3',
|
structureDelete: '/audios/sfx/structure_delete.mp3',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
Binary file not shown.
@@ -21,7 +21,7 @@ const useAudio = create<AudioState>()(
|
|||||||
(set) => ({
|
(set) => ({
|
||||||
masterVolume: 70,
|
masterVolume: 70,
|
||||||
sfxVolume: 50,
|
sfxVolume: 50,
|
||||||
radioVolume: 50,
|
radioVolume: 25,
|
||||||
muted: false,
|
muted: false,
|
||||||
autoplay: true,
|
autoplay: true,
|
||||||
setMasterVolume: (v) => set({ masterVolume: v }),
|
setMasterVolume: (v) => set({ masterVolume: v }),
|
||||||
|
|||||||
Reference in New Issue
Block a user