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
@@ -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