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
+6 -2
View File
@@ -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.
@@ -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()