radio & sfx

This commit is contained in:
wass08
2026-02-12 08:57:37 +09:00
parent ffef71bb02
commit be7e86975e
23 changed files with 628 additions and 16 deletions
+15 -1
View File
@@ -9,6 +9,7 @@ import { ZoneSystem } from '../systems/zone/zone-system'
import { ToolManager } from '../tools/tool-manager'
import { ActionMenu } from '../ui/action-menu'
import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
import { PascalRadio } from '../pascal-radio'
import { PanelManager } from '../ui/panels/panel-manager'
import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
@@ -16,12 +17,16 @@ import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager'
import { Grid } from './grid'
import { SelectionManager } from './selection-manager'
import { initSFXBus } from '@/lib/sfx-bus'
// Load default scene initially (will be replaced when property loads)
useScene.getState().loadScene()
initSpatialGridSync()
initSpaceDetectionSync(useScene, useEditor)
// Initialize SFX bus to connect events to sound effects
initSFXBus()
export default function Editor() {
useKeyboard()
@@ -29,7 +34,16 @@ export default function Editor() {
<div className="w-full h-full">
<ActionMenu />
<PanelManager />
<CloudSaveButton />
{/* Top-right controls */}
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
<div className="pointer-events-auto">
<PascalRadio />
</div>
<div className="pointer-events-auto">
<CloudSaveButton />
</div>
</div>
<SidebarProvider className="fixed z-20">
<AppSidebar />
+245
View File
@@ -0,0 +1,245 @@
'use client'
import { Howl } from 'howler'
import { Disc3, Pause, Play, Settings2, SkipBack, SkipForward, Volume2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
import { Switch } from '@/components/ui/primitives/switch'
import { Slider } from '@/components/ui/slider'
import { cn } from '@/lib/utils'
import useAudio from '@/store/use-audio'
const PLAYLIST = [
{
title: 'Ballroom in Miniature',
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
},
{
title: 'Blueprints in Springtime',
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
},
{
title: 'Clockwork Tea Party',
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
},
{
title: 'Clockwork Tea Party (Alternate)',
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
},
{
title: 'Clockwork Teacups',
file: '/audios/radios/classic/Clockwork Teacups.mp3',
},
{
title: 'Evening in the Parlor',
file: '/audios/radios/classic/Evening in the Parlor.mp3',
},
{
title: 'Glass Atrium',
file: '/audios/radios/classic/Glass Atrium.mp3',
},
{
title: 'Moonlight On The Drafting Table',
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
},
{
title: 'Sunlit Garden Reverie',
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
},
{
title: 'Sunlit Waltz in Pastel Hues',
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
},
]
// Shuffle array helper
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
export function PascalRadio() {
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
const [isPlaying, setIsPlaying] = useState(false)
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, autoplay, setAutoplay } = useAudio()
const soundRef = useRef<Howl | null>(null)
const hasAutoplayedRef = useRef(false)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
const handlePrevious = useCallback(() => {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
// Initialize Howler when track changes
useEffect(() => {
// Clean up previous sound
if (soundRef.current) {
soundRef.current.unload()
}
const wasPlaying = isPlaying
// Create new sound
soundRef.current = new Howl({
src: [currentTrack.file],
volume: muted ? 0 : effectiveVolume,
onend: handleNext,
})
// If was playing, play new track
if (wasPlaying && !muted) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
if (soundRef.current) {
soundRef.current.volume(muted ? 0 : effectiveVolume)
// Pause if muted, resume if unmuted and was playing
if (muted && isPlaying) {
soundRef.current.pause()
} else if (!muted && isPlaying && !soundRef.current.playing()) {
soundRef.current.play()
}
}
}, [effectiveVolume, muted, isPlaying])
// Autoplay on first user click
useEffect(() => {
if (!autoplay || hasAutoplayedRef.current || muted) return
const handleFirstClick = () => {
if (!soundRef.current || hasAutoplayedRef.current) return
hasAutoplayedRef.current = true
soundRef.current.play()
setIsPlaying(true)
// Remove listener after first click
document.removeEventListener('click', handleFirstClick)
}
document.addEventListener('click', handleFirstClick)
return () => {
document.removeEventListener('click', handleFirstClick)
}
}, [autoplay, muted])
const handlePlayPause = () => {
if (!soundRef.current || muted) return
if (isPlaying) {
soundRef.current.pause()
} else {
soundRef.current.play()
}
setIsPlaying(!isPlaying)
}
const handleVolumeChange = (value: number[]) => {
useAudio.setState({ radioVolume: value[0] })
}
return (
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md">
<Disc3 className={cn('h-4 w-4', isPlaying && 'animate-spin')} />
<span className="hidden sm:inline">Pascal Radio</span>
<div
onClick={handlePlayPause}
className="rounded-sm p-1 transition-all cursor-pointer bg-accent/30 hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
role="button"
tabIndex={0}
aria-label={isPlaying ? 'Pause' : 'Play'}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handlePlayPause()
}
}}
>
{isPlaying ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
</div>
<Popover>
<PopoverTrigger asChild>
<button
className="rounded-sm p-1 transition-all cursor-pointer hover:bg-accent hover:text-accent-foreground"
aria-label="Radio Settings"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</PopoverTrigger>
<PopoverContent className="w-64" align="end">
<div className="space-y-3">
{/* Current song info with prev/next */}
<div>
<p className="text-xs text-muted-foreground mb-2">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
onClick={handlePrevious}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Previous"
>
<SkipBack className="h-4 w-4" />
</button>
<p className="text-sm font-medium text-center flex-1 truncate">{currentTrack.title}</p>
<button
onClick={handleNext}
className="rounded-full p-1.5 transition-colors hover:bg-accent shrink-0"
aria-label="Next"
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 text-muted-foreground" />
<Slider
value={[radioVolume]}
onValueChange={handleVolumeChange}
max={100}
step={1}
className="flex-1"
aria-label="Radio Volume"
/>
<span className="w-8 text-right text-xs text-muted-foreground">{radioVolume}%</span>
</div>
{/* Autoplay setting */}
<div className="flex items-center justify-between pt-2 border-t">
<label htmlFor="autoplay" className="text-sm font-medium cursor-pointer">
Autoplay
</label>
<Switch
id="autoplay"
checked={autoplay}
onCheckedChange={setAutoplay}
/>
</div>
</div>
</PopoverContent>
</Popover>
</div>
)
}
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
import useEditor from '@/store/use-editor'
import { sfxEmitter } from '@/lib/sfx-bus'
const CEILING_HEIGHT = 2.52
const GRID_OFFSET = 0.02
@@ -59,6 +60,7 @@ const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, n
})
createNode(ceiling, levelId)
sfxEmitter.emit('sfx:structure-build')
}
export const CeilingTool: React.FC = () => {
@@ -72,6 +74,7 @@ export const CeilingTool: React.FC = () => {
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
// Update cursor position and lines on grid move
useEffect(() => {
@@ -94,6 +97,13 @@ export const CeilingTool: React.FC = () => {
const lastPoint = points[points.length - 1]
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
// Play snap sound when the snapped position actually changes (only when drawing)
if (points.length > 0 && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
}
@@ -1,3 +1,4 @@
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
@@ -14,7 +15,10 @@ export const ItemTool: React.FC = () => {
draftNode.create(gridPosition, selectedItem!)
}
},
onCommitted: () => true,
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
return true
},
})
if (!selectedItem) return null
@@ -1,10 +1,14 @@
import useEditor from '@/store/use-editor'
import { Vector3 } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
import useEditor from '@/store/use-editor'
import type { PlacementState } from './placement-types'
import { useDraftNode } from './use-draft-node'
import { usePlacementCoordinator } from './use-placement-coordinator'
import type { PlacementState } from './placement-types'
function getInitialState(node: { asset: { attachTo?: string }; parentId: string | null }): PlacementState {
function getInitialState(node: {
asset: { attachTo?: string }
parentId: string | null
}): PlacementState {
const attachTo = node.asset.attachTo
if (attachTo === 'wall' || attachTo === 'wall-side') {
return { surface: 'wall', wallId: node.parentId, ceilingId: null }
@@ -33,6 +37,7 @@ export const MoveTool: React.FC = () => {
gridPosition.copy(new Vector3(...movingNode.position))
},
onCommitted: () => {
sfxEmitter.emit('sfx:item-place')
exitMoveMode()
return false
},
@@ -23,6 +23,7 @@ import {
Quaternion,
Vector3,
} from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types'
import type { DraftNodeHandle } from './use-draft-node'
@@ -83,6 +84,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const applyTransition = (result: TransitionResult) => {
Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition)
cursorRef.current.rotation.y = result.cursorRotationY
@@ -135,10 +137,20 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// ---- Floor Handlers ----
let previousGridPos: [number, number, number] | null = null
const onGridMove = (event: GridEvent) => {
const result = floorStrategy.move(getContext(), event)
if (!result) return
// Play snap sound when grid position changes
if (previousGridPos &&
(result.gridPosition[0] !== previousGridPos[0] ||
result.gridPosition[2] !== previousGridPos[2])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPos = [...result.gridPosition]
gridPosition.current.set(...result.gridPosition)
// Only update X and Z for cursor - useFrame will handle Y (slab elevation)
cursorRef.current.position.x = result.cursorPosition[0]
@@ -3,6 +3,7 @@ import { useViewer } from "@pascal-app/viewer";
import { useEffect, useMemo, useRef, useState } from "react";
import { BufferGeometry, DoubleSide, type Line, Vector3 } from "three";
import useEditor from "@/store/use-editor";
import { sfxEmitter } from '@/lib/sfx-bus';
// Default roof dimensions
const DEFAULT_HEIGHT = 1.5;
@@ -42,6 +43,7 @@ const commitRoofPlacement = (
});
createNode(roof, levelId);
sfxEmitter.emit('sfx:structure-build');
return roof.id;
};
@@ -59,6 +61,7 @@ export const RoofTool: React.FC = () => {
const setMode = useEditor((state) => state.setMode);
const corner1Ref = useRef<[number, number, number] | null>(null);
const previousGridPosRef = useRef<[number, number] | null>(null);
const [preview, setPreview] = useState<PreviewState>({
corner1: null,
cursorPosition: [0, 0, 0],
@@ -93,6 +96,14 @@ export const RoofTool: React.FC = () => {
const cursorPosition: [number, number, number] = [gridX, y, gridZ];
// Play snap sound when grid position changes (only when placing)
if (corner1Ref.current && previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
sfxEmitter.emit('sfx:grid-snap');
}
previousGridPosRef.current = [gridX, gridZ];
setPreview({
corner1: corner1Ref.current,
cursorPosition,
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, sceneRegistry } from '@pascal-app/core'
import { createPortal } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Mesh } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
const Y_OFFSET = 0.02
@@ -52,6 +53,7 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const lineRef = useRef<Mesh>(null!)
const previousPositionRef = useRef<[number, number] | null>(null)
// Track the last polygon prop to detect external changes (undo/redo)
const lastPolygonRef = useRef(polygon)
@@ -129,7 +131,16 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
setCursorPosition([gridX, gridZ])
const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag
if (dragState?.isDragging && previousPositionRef.current &&
(newPosition[0] !== previousPositionRef.current[0] || newPosition[1] !== previousPositionRef.current[1])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousPositionRef.current = newPosition
setCursorPosition(newPosition)
// Update vertex position during drag
if (dragState?.isDragging) {
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pa
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Line, type Mesh, Shape, Vector3 } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
const Y_OFFSET = 0.02
@@ -57,6 +58,7 @@ const commitSlabDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
})
createNode(slab, levelId)
sfxEmitter.emit('sfx:structure-build')
return slab.id
}
@@ -70,6 +72,7 @@ export const SlabTool: React.FC = () => {
const [points, setPoints] = useState<Array<[number, number]>>([])
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
const [levelY, setLevelY] = useState(0)
const previousSnappedPointRef = useRef<[number, number] | null>(null)
// Update cursor position and lines on grid move
useEffect(() => {
@@ -89,6 +92,13 @@ export const SlabTool: React.FC = () => {
const lastPoint = points[points.length - 1]
const displayPoint = lastPoint ? calculateSnapPoint(lastPoint, gridPosition) : gridPosition
// Play snap sound when the snapped position actually changes (only when drawing)
if (points.length > 0 && previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] || displayPoint[1] !== previousSnappedPointRef.current[1])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.position[1], displayPoint[1])
}
@@ -1,7 +1,8 @@
import { emitter, type GridEvent, useScene, WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef, useMemo } from 'react'
import { DoubleSide, type Mesh, Vector3, Shape, ShapeGeometry } from 'three'
import { useEffect, useRef } from 'react'
import { DoubleSide, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
import { sfxEmitter } from '@/lib/sfx-bus'
const WALL_HEIGHT = 2.5
const WALL_THICKNESS = 0.15
@@ -88,6 +89,7 @@ const commitWallDrawing = (start: [number, number], end: [number, number]) => {
const wall = WallNode.parse({ start, end })
createNode(wall, currentLevelId)
sfxEmitter.emit('sfx:structure-build')
}
export const WallTool: React.FC = () => {
@@ -99,6 +101,7 @@ export const WallTool: React.FC = () => {
useEffect(() => {
let gridPosition: [number, number] = [0, 0]
let previousWallEnd: [number, number] | null = null
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current || !wallPreviewRef.current) return
@@ -112,6 +115,14 @@ export const WallTool: React.FC = () => {
const snapped = snapTo45Degrees(startingPoint.current, cursorPosition)
endingPoint.current.copy(snapped)
// Play snap sound only when the actual wall end position changes
const currentWallEnd: [number, number] = [endingPoint.current.x, endingPoint.current.z]
if (previousWallEnd &&
(currentWallEnd[0] !== previousWallEnd[0] || currentWallEnd[1] !== previousWallEnd[1])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousWallEnd = currentWallEnd
// Update wall preview geometry
updateWallPreview(wallPreviewRef.current, startingPoint.current, endingPoint.current)
}
@@ -125,7 +136,7 @@ export const WallTool: React.FC = () => {
} else if (buildingState.current === 1) {
commitWallDrawing(
[startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z]
[endingPoint.current.x, endingPoint.current.z],
)
wallPreviewRef.current.visible = false
buildingState.current = 0
@@ -7,6 +7,7 @@ import Image from 'next/image'
import { useCallback } from 'react'
import useEditor from '@/store/use-editor'
import { NumberInput } from '@/components/ui/primitives/number-input'
import { sfxEmitter } from '@/lib/sfx-bus'
export function ItemPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
@@ -43,6 +44,7 @@ export function ItemPanel() {
const handleMove = useCallback(() => {
if (node) {
sfxEmitter.emit('sfx:item-pick')
setMovingNode(node)
// Deselect so the panel closes
setSelection({ selectedIds: [] })
@@ -0,0 +1,89 @@
import { Volume2, VolumeX } from 'lucide-react'
import { Button } from '@/components/ui/primitives/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/primitives/dialog'
import { Slider } from '@/components/ui/slider'
import useAudio from '@/store/use-audio'
export function AudioSettingsDialog() {
const { masterVolume, sfxVolume, radioVolume, muted, setMasterVolume, setSfxVolume, setRadioVolume, toggleMute } = useAudio()
return (
<Dialog>
<DialogTrigger asChild>
<Button
className="w-full justify-start gap-2"
variant="outline"
>
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
Audio Settings
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Audio Settings</DialogTitle>
<DialogDescription>
Adjust volume levels and mute settings
</DialogDescription>
</DialogHeader>
<div className="space-y-6 py-4">
{/* Master Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Master Volume</label>
<span className="text-sm text-muted-foreground">{masterVolume}%</span>
</div>
<Slider
value={[masterVolume]}
onValueChange={(value) => setMasterVolume(value[0])}
max={100}
step={1}
disabled={muted}
/>
</div>
{/* Radio Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Radio Volume</label>
<span className="text-sm text-muted-foreground">{radioVolume}%</span>
</div>
<Slider
value={[radioVolume]}
onValueChange={(value) => setRadioVolume(value[0])}
max={100}
step={1}
disabled={muted}
/>
</div>
{/* SFX Volume */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">Sound Effects</label>
<span className="text-sm text-muted-foreground">{sfxVolume}%</span>
</div>
<Slider
value={[sfxVolume]}
onValueChange={(value) => setSfxVolume(value[0])}
max={100}
step={1}
disabled={muted}
/>
</div>
{/* Mute Toggle */}
<div className="pt-4 border-t">
<Button
onClick={toggleMute}
variant={muted ? 'default' : 'outline'}
className="w-full justify-start gap-2"
>
{muted ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
{muted ? 'Unmute All Sounds' : 'Mute All Sounds'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -4,6 +4,7 @@ import { Download, Save, Trash2, Upload } from "lucide-react";
import { useRef } from "react";
import { Button } from "@/components/ui/primitives/button";
import useEditor from "@/store/use-editor";
import { AudioSettingsDialog } from "./audio-settings-dialog";
export function SettingsPanel() {
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -113,6 +114,14 @@ export function SettingsPanel() {
/>
</div>
{/* Audio Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Audio
</label>
<AudioSettingsDialog />
</div>
{/* Danger Zone */}
<div className="space-y-2">
<label className="font-medium text-destructive text-xs uppercase">
+28
View File
@@ -0,0 +1,28 @@
'use client'
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
'relative flex w-full touch-none select-none items-center',
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }
@@ -28,7 +28,7 @@ export function CloudSaveButton() {
if (isLoading) {
return (
<div className="pointer-events-auto fixed top-4 right-4 z-50">
<div className="pointer-events-auto">
<div className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 shadow-lg backdrop-blur-md">
<div className="h-4 w-4 animate-pulse rounded-full bg-muted" />
</div>
@@ -39,7 +39,7 @@ export function CloudSaveButton() {
if (!isAuthenticated) {
return (
<>
<div className="pointer-events-auto fixed top-4 right-4 z-50">
<div className="pointer-events-auto">
<button
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 text-sm font-medium shadow-lg backdrop-blur-md transition-colors hover:bg-accent hover:text-accent-foreground"
onClick={() => setIsSignInDialogOpen(true)}
@@ -54,7 +54,7 @@ export function CloudSaveButton() {
}
return (
<div className="pointer-events-auto fixed top-4 right-4 z-50">
<div className="pointer-events-auto">
<div className="flex items-center gap-2">
<PropertyDropdown />
<ProfileDropdown />
+43
View File
@@ -0,0 +1,43 @@
import mitt from 'mitt'
import { playSFX } from './sfx-player'
/**
* SFX-specific events that tools can trigger
*/
type SFXEvents = {
'sfx:grid-snap': undefined
'sfx:item-delete': undefined
'sfx:item-pick': undefined
'sfx:item-place': undefined
'sfx:structure-build': undefined
'sfx:structure-delete': undefined
}
/**
* Dedicated event emitter for SFX
* Tools should use this to trigger sound effects
*/
export const sfxEmitter = mitt<SFXEvents>()
/**
* Initialize SFX Bus - connects SFX events to actual sound playback
* Call once in your app initialization
*/
export function initSFXBus() {
// Map SFX events to sound playback
sfxEmitter.on('sfx:grid-snap', () => playSFX('gridSnap'))
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
}
/**
* Helper function to trigger SFX events from tools
* @example
* triggerSFX('sfx:item-place')
*/
export function triggerSFX(event: keyof SFXEvents) {
sfxEmitter.emit(event)
}
+59
View File
@@ -0,0 +1,59 @@
import { Howl } from 'howler'
import useAudio from '@/store/use-audio'
// SFX sound definitions
export const SFX = {
gridSnap: '/audios/sfx/grid_snap.mp3',
itemDelete: '/audios/sfx/item_delete.mp3',
itemPick: '/audios/sfx/item_pick.mp3',
itemPlace: '/audios/sfx/item_place.mp3',
structureBuild: '/audios/sfx/structure_build.mp3',
structureDelete: '/audios/sfx/structure_delete.mp3',
} as const
export type SFXName = keyof typeof SFX
// Preload all SFX sounds
const sfxCache = new Map<SFXName, Howl>()
// Initialize all sounds
Object.entries(SFX).forEach(([name, path]) => {
const sound = new Howl({
src: [path],
preload: true,
volume: 0.5, // Will be adjusted by the bus
})
sfxCache.set(name as SFXName, sound)
})
/**
* Play a sound effect with volume based on audio settings
*/
export function playSFX(name: SFXName) {
const sound = sfxCache.get(name)
if (!sound) {
console.warn(`SFX not found: ${name}`)
return
}
const { masterVolume, sfxVolume, muted } = useAudio.getState()
if (muted) return
// Calculate final volume (masterVolume and sfxVolume are 0-100)
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
sound.volume(finalVolume)
sound.play()
}
/**
* Update all cached SFX volumes (useful when settings change)
*/
export function updateSFXVolumes() {
const { masterVolume, sfxVolume } = useAudio.getState()
const finalVolume = (masterVolume / 100) * (sfxVolume / 100)
sfxCache.forEach((sound) => {
sound.volume(finalVolume)
})
}
+2
View File
@@ -0,0 +1,2 @@
export { playSFX, updateSFXVolumes, SFX, type SFXName } from '../sfx-player'
export { initSFXBus, sfxEmitter, triggerSFX } from '../sfx-bus'
+2
View File
@@ -36,6 +36,7 @@
"better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"howler": "^2.2.4",
"lucide-react": "^0.562.0",
"motion": "^12.26.2",
"nanoid": "^5.1.6",
@@ -48,6 +49,7 @@
},
"devDependencies": {
"@repo/typescript-config": "*",
"@types/howler": "^2.2.12",
"@types/node": "^22.15.3",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
+39
View File
@@ -0,0 +1,39 @@
'use client'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface AudioState {
masterVolume: number
sfxVolume: number
radioVolume: number
muted: boolean
autoplay: boolean
setMasterVolume: (v: number) => void
setSfxVolume: (v: number) => void
setRadioVolume: (v: number) => void
toggleMute: () => void
setAutoplay: (v: boolean) => void
}
const useAudio = create<AudioState>()(
persist(
(set) => ({
masterVolume: 70,
sfxVolume: 50,
radioVolume: 50,
muted: false,
autoplay: true,
setMasterVolume: (v) => set({ masterVolume: v }),
setSfxVolume: (v) => set({ sfxVolume: v }),
setRadioVolume: (v) => set({ radioVolume: v }),
toggleMute: () => set((state) => ({ muted: !state.muted })),
setAutoplay: (v) => set({ autoplay: v }),
}),
{
name: 'pascal-audio-settings',
}
)
)
export default useAudio