editor: structure build-start/end cues + menu hover/click SFX (#378)
* wip sfx * wip audios * feat(editor): structure build-start/end cues + menu hover/click SFX Sound design pass for the build/items experience: - Split structure-build into a start cue (draft begins) and end cue (commit). Wall/fence fire start on the first click; slab/ceiling/zone tick start on every non-closing vertex; roof on the first corner. - Doors and windows now use the structure-end cue on place + move; shelves use item-place instead. - New menu_hover (round-robin retired in favour of a single sample) and menu_click cues wired to the Build/Items main categories, item-catalog tiles, the icon rail / tab bar, and the bottom action menu (via the shared ActionButton). - Refresh item_pick / item_place / item_rotate samples; drop the now-unused numbered menu_hover variations. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(editor): silence forEach return lint in updateSFXVolumes Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(editor): convert SFX wavs to mp3 to shrink assets Re-encode all SFX samples as 128kbps mp3 (~94% smaller, transparent for short UI cues) and point the player at the .mp3 sources. Drops the .wav originals; the freshly converted item_pick/place/rotate.mp3 override the older committed mp3s. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8f2e5678e8
commit
1419709131
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { MaterialPaintPanel, useEditor } from '@pascal-app/editor'
|
||||
import { MaterialPaintPanel, triggerSFX, useEditor } from '@pascal-app/editor'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
@@ -131,7 +131,11 @@ export function BuildTab() {
|
||||
? 'bg-primary/10 ring-1 ring-primary/50'
|
||||
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
onClick={() => handleTypeClick(type)}
|
||||
onClick={() => {
|
||||
triggerSFX('sfx:menu-click')
|
||||
handleTypeClick(type)
|
||||
}}
|
||||
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||
type="button"
|
||||
>
|
||||
<Image
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7839,6 +7839,9 @@ export function FloorplanPanel() {
|
||||
return
|
||||
}
|
||||
|
||||
// Every non-closing vertex is a "start" tick; closing the polygon above
|
||||
// creates the zone and fires the structure-build (end) cue.
|
||||
sfxEmitter.emit('sfx:structure-build-start')
|
||||
setZoneDraftPoints((currentPoints) => [...currentPoints, point])
|
||||
setCursorPoint(point)
|
||||
},
|
||||
|
||||
@@ -295,6 +295,7 @@ export const RoofTool: React.FC = () => {
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
corner1Ref.current = [gridX, y, gridZ]
|
||||
sfxEmitter.emit('sfx:structure-build-start')
|
||||
setPreview((prev) => ({
|
||||
...prev,
|
||||
corner1: corner1Ref.current,
|
||||
|
||||
@@ -236,7 +236,9 @@ export const ZoneTool: React.FC = () => {
|
||||
mainLineRef.current.visible = false
|
||||
closingLineRef.current.visible = false
|
||||
} else {
|
||||
// Add point to polygon
|
||||
// Add point to polygon. Every non-closing vertex is a "start" tick;
|
||||
// closing the polygon above fires the structure-build (end) cue.
|
||||
sfxEmitter.emit('sfx:structure-build-start')
|
||||
pointsRef.current = [...pointsRef.current, clickPoint]
|
||||
updatePreview()
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from './../../../components/ui/primitives/tooltip'
|
||||
import { triggerSFX } from './../../../lib/sfx-bus'
|
||||
import { cn } from './../../../lib/utils'
|
||||
|
||||
interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
||||
@@ -17,7 +18,18 @@ interface ActionButtonProps extends React.ComponentProps<typeof Button> {
|
||||
|
||||
export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
(
|
||||
{ className, children, label, shortcut, isActive, tooltipContent, tooltipSide, ...props },
|
||||
{
|
||||
className,
|
||||
children,
|
||||
label,
|
||||
shortcut,
|
||||
isActive,
|
||||
tooltipContent,
|
||||
tooltipSide,
|
||||
onClick,
|
||||
onMouseEnter,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
@@ -25,6 +37,14 @@ export const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProp
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn('relative h-11 w-11 transition-all', className)}
|
||||
onClick={(event) => {
|
||||
triggerSFX('sfx:menu-click')
|
||||
onClick?.(event)
|
||||
}}
|
||||
onMouseEnter={(event) => {
|
||||
triggerSFX('sfx:menu-hover')
|
||||
onMouseEnter?.(event)
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from './../../../components/ui/primitives/tooltip'
|
||||
import { triggerSFX } from './../../../lib/sfx-bus'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
|
||||
import { CATALOG_ITEMS } from './catalog-items'
|
||||
@@ -94,10 +95,12 @@ export function ItemCatalog({
|
||||
)}
|
||||
key={index}
|
||||
onClick={() => {
|
||||
triggerSFX('sfx:menu-click')
|
||||
setSelectedItem(item)
|
||||
setTool('item')
|
||||
setMode('build')
|
||||
}}
|
||||
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||
type="button"
|
||||
>
|
||||
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
|
||||
|
||||
+6
-1
@@ -4,6 +4,7 @@ import type { AssetInput } from '@pascal-app/core'
|
||||
import { Root as TooltipRoot } from '@radix-ui/react-tooltip'
|
||||
import NextImage from 'next/image'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { triggerSFX } from '../../../../../lib/sfx-bus'
|
||||
import { cn } from '../../../../../lib/utils'
|
||||
import { ItemCatalog } from '../../../item-catalog/item-catalog'
|
||||
import {
|
||||
@@ -132,7 +133,11 @@ export function FunctionTreePanel({
|
||||
? 'bg-primary/10 ring-1 ring-primary/50'
|
||||
: 'bg-muted/40 opacity-70 grayscale hover:bg-muted hover:opacity-100 hover:grayscale-0',
|
||||
)}
|
||||
onClick={() => selectRoot(root.slug)}
|
||||
onClick={() => {
|
||||
triggerSFX('sfx:menu-click')
|
||||
selectRoot(root.slug)
|
||||
}}
|
||||
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||
type="button"
|
||||
>
|
||||
{root.iconUrl ? (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { AssetInput } from '@pascal-app/core'
|
||||
import NextImage from 'next/image'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { triggerSFX } from '../../../../../lib/sfx-bus'
|
||||
import { cn } from '../../../../../lib/utils'
|
||||
import type { CatalogCategory } from '../../../../../store/use-editor'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
@@ -206,7 +207,11 @@ function LegacyItemsPanel({
|
||||
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
|
||||
)}
|
||||
key={cat.catalogCategory}
|
||||
onClick={() => selectCategory(cat.catalogCategory)}
|
||||
onClick={() => {
|
||||
triggerSFX('sfx:menu-click')
|
||||
selectCategory(cat.catalogCategory)
|
||||
}}
|
||||
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||
type="button"
|
||||
>
|
||||
<NextImage
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { triggerSFX } from './../../../lib/sfx-bus'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
|
||||
|
||||
@@ -33,7 +34,11 @@ export function TabBar({ tabs, activeTab, onTabChange }: TabBarProps) {
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
||||
)}
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
onClick={() => {
|
||||
triggerSFX('sfx:menu-click')
|
||||
onTabChange(tab.id)
|
||||
}}
|
||||
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||
type="button"
|
||||
>
|
||||
{tab.label}
|
||||
@@ -77,7 +82,11 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
|
||||
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
|
||||
)}
|
||||
onClick={() => onIconClick(tab.id)}
|
||||
onClick={() => {
|
||||
triggerSFX('sfx:menu-click')
|
||||
onIconClick(tab.id)
|
||||
}}
|
||||
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
|
||||
type="button"
|
||||
>
|
||||
{tab.icon ?? tab.label.charAt(0)}
|
||||
|
||||
@@ -10,9 +10,12 @@ type SFXEvents = {
|
||||
'sfx:item-pick': undefined
|
||||
'sfx:item-place': undefined
|
||||
'sfx:item-rotate': undefined
|
||||
'sfx:structure-build-start': undefined
|
||||
'sfx:structure-build': undefined
|
||||
'sfx:structure-delete': undefined
|
||||
'sfx:snapshot-capture': undefined
|
||||
'sfx:menu-hover': undefined
|
||||
'sfx:menu-click': undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,9 +39,12 @@ export function initSFXBus() {
|
||||
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
||||
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-start', () => playSFX('structureBuildStart'))
|
||||
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
|
||||
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
||||
sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
|
||||
sfxEmitter.on('sfx:menu-hover', () => playSFX('menuHover'))
|
||||
sfxEmitter.on('sfx:menu-click', () => playSFX('menuClick'))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,8 @@ import useAudio from '../store/use-audio'
|
||||
// so a rate range of ~0.88–1.12 reads as a subtle ±2 semitones — enough to kill the
|
||||
// machine-gun feeling when the same SFX fires in rapid succession.
|
||||
type SFXConfig = {
|
||||
src: string
|
||||
// One file, or several pre-rendered variations cycled round-robin per play.
|
||||
src: string | string[]
|
||||
// Random playback-rate range applied per play (1 = unchanged).
|
||||
rateRange?: [number, number]
|
||||
// Random volume multiplier range applied per play (1 = unchanged).
|
||||
@@ -24,10 +25,15 @@ const DEFAULT_MIN_INTERVAL_MS = 30
|
||||
// SFX sound definitions
|
||||
export const SFX: Record<string, SFXConfig> = {
|
||||
gridSnap: {
|
||||
src: '/audios/sfx/grid_snap.mp3',
|
||||
rateRange: [0.94, 1.06],
|
||||
volumeRange: [0.92, 1.0],
|
||||
src: [
|
||||
'/audios/sfx/grid_snap_0.mp3',
|
||||
'/audios/sfx/grid_snap_1.mp3',
|
||||
'/audios/sfx/grid_snap_2.mp3',
|
||||
],
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.5, 0.6],
|
||||
panJitter: 0.15,
|
||||
minIntervalMs: 50,
|
||||
},
|
||||
itemDelete: {
|
||||
src: '/audios/sfx/item_delete.mp3',
|
||||
@@ -37,13 +43,13 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
},
|
||||
itemPick: {
|
||||
src: '/audios/sfx/item_pick.mp3',
|
||||
rateRange: [0.92, 1.08],
|
||||
rateRange: [0.95, 1.05],
|
||||
volumeRange: [0.92, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
itemPlace: {
|
||||
src: '/audios/sfx/item_place.mp3',
|
||||
rateRange: [0.98, 1.06],
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.9, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
@@ -53,8 +59,16 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
volumeRange: [0.92, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
structureBuild: {
|
||||
src: '/audios/sfx/structure_build.mp3',
|
||||
// Fired when a structure draft begins (first click of a wall/slab/etc).
|
||||
structureBuildStart: {
|
||||
src: '/audios/sfx/structure_build_start.mp3',
|
||||
rateRange: [0.95, 1.05],
|
||||
volumeRange: [0.88, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
// Fired when a structure is committed (segment placed / polygon closed).
|
||||
structureBuildEnd: {
|
||||
src: '/audios/sfx/structure_build_end.mp3',
|
||||
rateRange: [0.95, 1.05],
|
||||
volumeRange: [0.88, 1.0],
|
||||
panJitter: 0.15,
|
||||
@@ -69,6 +83,23 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
// Shutter should sound consistent — no variation.
|
||||
src: '/audios/sfx/snapshot_capture.mp3',
|
||||
},
|
||||
// Soft tick when hovering a main category in the Build / Items panels.
|
||||
// Kept quiet and rate-locked so sweeping across the grid reads as texture,
|
||||
// not a melody.
|
||||
menuHover: {
|
||||
src: '/audios/sfx/menu_hover.mp3',
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.2, 0.3],
|
||||
panJitter: 0.1,
|
||||
minIntervalMs: 0,
|
||||
},
|
||||
// Fired when a main category in the Build / Items panels is clicked.
|
||||
menuClick: {
|
||||
src: '/audios/sfx/menu_click.mp3',
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.5, 0.6],
|
||||
panJitter: 0.1,
|
||||
},
|
||||
} as const
|
||||
|
||||
export type SFXName = keyof typeof SFX
|
||||
@@ -77,26 +108,32 @@ function randomInRange([min, max]: [number, number]): number {
|
||||
return min + Math.random() * (max - min)
|
||||
}
|
||||
|
||||
// Preload all SFX sounds
|
||||
const sfxCache = new Map<SFXName, Howl>()
|
||||
// Preload all SFX sounds. Each variation gets its own Howl so they can overlap
|
||||
// and be cycled round-robin.
|
||||
const sfxCache = new Map<SFXName, Howl[]>()
|
||||
const lastPlayedAt = new Map<SFXName, number>()
|
||||
const lastVariation = new Map<SFXName, number>()
|
||||
|
||||
// Initialize all sounds
|
||||
Object.entries(SFX).forEach(([name, config]) => {
|
||||
const sound = new Howl({
|
||||
src: [config.src],
|
||||
preload: true,
|
||||
volume: 0.5, // Will be adjusted by the bus
|
||||
})
|
||||
sfxCache.set(name as SFXName, sound)
|
||||
const sources = Array.isArray(config.src) ? config.src : [config.src]
|
||||
const sounds = sources.map(
|
||||
(src) =>
|
||||
new Howl({
|
||||
src: [src],
|
||||
preload: true,
|
||||
volume: 0.5, // Will be adjusted by the bus
|
||||
}),
|
||||
)
|
||||
sfxCache.set(name as SFXName, sounds)
|
||||
})
|
||||
|
||||
/**
|
||||
* Play a sound effect with volume based on audio settings
|
||||
*/
|
||||
export function playSFX(name: SFXName) {
|
||||
const sound = sfxCache.get(name)
|
||||
if (!sound) {
|
||||
const sounds = sfxCache.get(name)
|
||||
if (!sounds || sounds.length === 0) {
|
||||
console.warn(`SFX not found: ${name}`)
|
||||
return
|
||||
}
|
||||
@@ -110,6 +147,15 @@ export function playSFX(name: SFXName) {
|
||||
if (last !== undefined && now - last < minInterval) return
|
||||
lastPlayedAt.set(name, now)
|
||||
|
||||
// Pick a random variation, avoiding an immediate repeat of the last one so
|
||||
// consecutive plays don't land on the same file.
|
||||
let index = Math.floor(Math.random() * sounds.length)
|
||||
if (sounds.length > 1 && index === lastVariation.get(name)) {
|
||||
index = (index + 1) % sounds.length
|
||||
}
|
||||
lastVariation.set(name, index)
|
||||
const sound = sounds[index]!
|
||||
|
||||
const { masterVolume, sfxVolume, muted } = useAudio.getState()
|
||||
|
||||
if (muted) return
|
||||
@@ -137,7 +183,9 @@ export function updateSFXVolumes() {
|
||||
const { masterVolume, sfxVolume } = useAudio.getState()
|
||||
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
|
||||
|
||||
sfxCache.forEach((sound) => {
|
||||
sound.volume(finalVolume)
|
||||
sfxCache.forEach((sounds) => {
|
||||
sounds.forEach((sound) => {
|
||||
sound.volume(finalVolume)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,6 +201,9 @@ export const CeilingTool: React.FC = () => {
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
// Every non-closing vertex is a "start" tick; the closing click above
|
||||
// fires the structure-build (end) cue.
|
||||
triggerSFX('sfx:structure-build-start')
|
||||
setPoints([...points, clickPoint])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ const MoveDoorTool: React.FC<{ node: DoorNode }> = ({ node: movingDoorNode }) =>
|
||||
useLiveTransforms.getState().clear(movingDoorNode.id)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
triggerSFX('sfx:structure-build')
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
exitMoveMode()
|
||||
|
||||
@@ -323,7 +323,7 @@ const DoorTool: React.FC = () => {
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:item-place')
|
||||
triggerSFX('sfx:structure-build')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
|
||||
@@ -569,6 +569,7 @@ export const FenceTool: React.FC = () => {
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
triggerSFX('sfx:structure-build-start')
|
||||
previewRef.current.visible = true
|
||||
setDraftMeasurement(null)
|
||||
} else {
|
||||
|
||||
@@ -181,7 +181,7 @@ const ShelfTool = () => {
|
||||
})
|
||||
useScene.getState().createNode(shelf, activeLevelId)
|
||||
useViewer.getState().setSelection({ selectedIds: [shelf.id] })
|
||||
triggerSFX('sfx:structure-build')
|
||||
triggerSFX('sfx:item-place')
|
||||
// The placed shelf is now a valid alignment target for the next one;
|
||||
// refresh the candidate pool and drop the guide from this drop.
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, previewNode.id)
|
||||
|
||||
@@ -178,6 +178,9 @@ export const SlabTool: React.FC = () => {
|
||||
setPoints([])
|
||||
useAlignmentGuides.getState().clear()
|
||||
} else {
|
||||
// Every non-closing vertex is a "start" tick; the closing click above
|
||||
// fires the structure-build (end) cue.
|
||||
triggerSFX('sfx:structure-build-start')
|
||||
setPoints([...points, clickPoint])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +538,7 @@ export const WallTool: React.FC = () => {
|
||||
startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1])
|
||||
endingPoint.current.copy(startingPoint.current)
|
||||
buildingState.current = 1
|
||||
triggerSFX('sfx:structure-build-start')
|
||||
// Visibility is owned by `updateWallPreview` — it flips
|
||||
// `mesh.visible` based on segment length. Setting it here
|
||||
// (before any geometry data has been written) draws the
|
||||
|
||||
@@ -381,7 +381,7 @@ const MoveWindowTool: React.FC<{ node: WindowNode }> = ({ node: movingWindowNode
|
||||
useLiveTransforms.getState().clear(movingWindowNode.id)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
triggerSFX('sfx:item-place')
|
||||
triggerSFX('sfx:structure-build')
|
||||
hideCursor()
|
||||
useViewer.getState().setSelection({ selectedIds: [placedId] })
|
||||
exitMoveMode()
|
||||
|
||||
@@ -331,7 +331,7 @@ const WindowTool: React.FC = () => {
|
||||
useScene.getState().createNode(node, event.node.id as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [node.id] })
|
||||
useScene.temporal.getState().pause()
|
||||
triggerSFX('sfx:item-place')
|
||||
triggerSFX('sfx:structure-build')
|
||||
alignmentCandidates = collectAlignmentAnchors(useScene.getState().nodes, '')
|
||||
useAlignmentGuides.getState().clear()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user