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:
Wassim SAMAD
2026-06-06 20:15:24 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 8f2e5678e8
commit 1419709131
31 changed files with 148 additions and 34 deletions
@@ -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">
@@ -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)}
+7 -1
View File
@@ -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'))
}
/**
+68 -20
View File
@@ -5,7 +5,8 @@ import useAudio from '../store/use-audio'
// so a rate range of ~0.881.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)
})
})
}
+3
View File
@@ -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])
}
}
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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()
+1
View File
@@ -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 {
+1 -1
View File
@@ -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)
+3
View File
@@ -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])
}
}
+1
View File
@@ -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
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -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()