Enhance UI components and improve user notifications
- Updated App.vue to include a toast notification system for new messages, enhancing user engagement. - Modified SplashScreen.vue to streamline the intro text display with improved typing effects. - Added Montserrat font styles in style.css for better typography across the application. - Improved controller navigation in useControllerNav.ts to support enhanced focus management and sound feedback. - Updated routing logic in index.ts to redirect authenticated users from the login page to the home page. - Enhanced the Login.vue view with transition effects for a smoother user experience during login and setup processes.
This commit is contained in:
@@ -1,15 +1,16 @@
|
||||
/**
|
||||
* Controller / gamepad-style navigation for Archipelago.
|
||||
* Supports Rii X8 (keyboard/d-pad) and standard gamepads.
|
||||
* - Arrow keys / d-pad: navigate between focusable elements
|
||||
* - Enter / A button: activate
|
||||
* - Escape / B button: back
|
||||
* - Game-like navigation sounds and visual feedback
|
||||
* Xbox-style controller / gamepad navigation for Archipelago.
|
||||
* - Left: Go to side menu only when on leftmost main content
|
||||
* - Right: Go to main content (from side menu)
|
||||
* - Main: spatial/grid navigation (up/down/left/right like a game)
|
||||
* - Enter enters container's inner actions; actions get celebratory sound
|
||||
*/
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useControllerStore } from '@/stores/controller'
|
||||
import { useSpotlightStore } from '@/stores/spotlight'
|
||||
import { playNavSound } from '@/composables/useNavSounds'
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
@@ -19,6 +20,7 @@ const FOCUSABLE_SELECTOR = [
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
'[data-controller-focus]',
|
||||
'[data-controller-container]',
|
||||
].join(', ')
|
||||
|
||||
function getFocusableElements(container: Document | HTMLElement = document): HTMLElement[] {
|
||||
@@ -27,24 +29,94 @@ function getFocusableElements(container: Document | HTMLElement = document): HTM
|
||||
)
|
||||
}
|
||||
|
||||
function playNavSound(type: 'move' | 'select' | 'back' = 'move') {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
gain.gain.value = 0.08
|
||||
osc.frequency.value = type === 'select' ? 880 : type === 'back' ? 220 : 440
|
||||
osc.type = 'sine'
|
||||
osc.start()
|
||||
osc.stop(ctx.currentTime + 0.05)
|
||||
} catch {
|
||||
// Audio not supported or blocked
|
||||
}
|
||||
function getElementsInZone(zone: 'sidebar' | 'main'): HTMLElement[] {
|
||||
const container = document.querySelector(`[data-controller-zone="${zone}"]`) as HTMLElement | null
|
||||
if (!container) return []
|
||||
return getFocusableElements(container)
|
||||
}
|
||||
|
||||
function isInZone(el: HTMLElement | null, zone: 'sidebar' | 'main'): boolean {
|
||||
if (!el) return false
|
||||
return !!el.closest(`[data-controller-zone="${zone}"]`)
|
||||
}
|
||||
|
||||
function getInnerFocusables(container: HTMLElement): HTMLElement[] {
|
||||
return getFocusableElements(container).filter((el) => el !== container && !el.hasAttribute('data-controller-container'))
|
||||
}
|
||||
|
||||
function isInsideContainer(el: HTMLElement | null): boolean {
|
||||
if (!el) return false
|
||||
const container = el.closest('[data-controller-container]')
|
||||
return !!container && container !== el
|
||||
}
|
||||
|
||||
/** Spatial navigation: find nearest focusable in direction (game-style grid) */
|
||||
function findNearestInDirection(
|
||||
from: HTMLElement,
|
||||
candidates: HTMLElement[],
|
||||
direction: 'up' | 'down' | 'left' | 'right'
|
||||
): HTMLElement | null {
|
||||
const fromRect = from.getBoundingClientRect()
|
||||
const fromCenterX = fromRect.left + fromRect.width / 2
|
||||
const fromCenterY = fromRect.top + fromRect.height / 2
|
||||
const threshold = 50 // px overlap allowed
|
||||
|
||||
const filtered = candidates.filter((el) => {
|
||||
if (el === from) return false
|
||||
const r = el.getBoundingClientRect()
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
return r.right <= fromRect.left + threshold
|
||||
case 'right':
|
||||
return r.left >= fromRect.right - threshold
|
||||
case 'up':
|
||||
return r.bottom <= fromRect.top + threshold
|
||||
case 'down':
|
||||
return r.top >= fromRect.bottom - threshold
|
||||
default:
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (filtered.length === 0) return null
|
||||
|
||||
// Pick best: most overlap on perpendicular axis, then closest
|
||||
const scored = filtered.map((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const centerX = r.left + r.width / 2
|
||||
const centerY = r.top + r.height / 2
|
||||
|
||||
let overlap: number
|
||||
let dist: number
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
overlap = Math.max(0, Math.min(fromRect.bottom, r.bottom) - Math.max(fromRect.top, r.top))
|
||||
dist = Math.abs(centerX - fromCenterX)
|
||||
break
|
||||
case 'up':
|
||||
case 'down':
|
||||
overlap = Math.max(0, Math.min(fromRect.right, r.right) - Math.max(fromRect.left, r.left))
|
||||
dist = Math.abs(centerY - fromCenterY)
|
||||
break
|
||||
default:
|
||||
overlap = 0
|
||||
dist = Infinity
|
||||
}
|
||||
return { el, overlap, dist }
|
||||
})
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (b.overlap !== a.overlap) return b.overlap - a.overlap
|
||||
return a.dist - b.dist
|
||||
})
|
||||
return scored[0]?.el ?? null
|
||||
}
|
||||
|
||||
export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useControllerStore()
|
||||
const isControllerActive = ref(false)
|
||||
const gamepadCount = ref(0)
|
||||
@@ -69,7 +141,6 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape']
|
||||
if (!navKeys.includes(e.key)) return
|
||||
|
||||
// Ignore when typing in inputs
|
||||
const target = e.target as HTMLElement
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
if (e.key !== 'Escape') return
|
||||
@@ -78,7 +149,9 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
const root = containerRef?.value ?? document
|
||||
const focusable = getFocusableElements(root)
|
||||
const currentIndex = focusable.indexOf(document.activeElement as HTMLElement)
|
||||
const activeEl = document.activeElement as HTMLElement
|
||||
|
||||
// --- ESCAPE ---
|
||||
if (e.key === 'Escape') {
|
||||
if (useSpotlightStore().isOpen) {
|
||||
useSpotlightStore().close()
|
||||
@@ -86,21 +159,67 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
e.stopPropagation()
|
||||
return
|
||||
}
|
||||
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement | null
|
||||
if (container && container.tabIndex >= 0) {
|
||||
playNavSound('back')
|
||||
container.focus()
|
||||
container.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const isDetailPage = /\/apps\/[^/]+$|\/marketplace\/[^/]+$|\/cloud\/[^/]+$/.test(route.path)
|
||||
if (isDetailPage) {
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
const sidebarEls = getElementsInZone('sidebar')
|
||||
const firstSidebar = sidebarEls[0]
|
||||
if (firstSidebar && isInZone(activeEl, 'main')) {
|
||||
playNavSound('back')
|
||||
firstSidebar.focus()
|
||||
firstSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
playNavSound('back')
|
||||
window.history.back()
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// --- ENTER ---
|
||||
if (e.key === 'Enter') {
|
||||
if (currentIndex >= 0 && focusable[currentIndex]) {
|
||||
playNavSound('select')
|
||||
;(focusable[currentIndex] as HTMLElement).click()
|
||||
const el = focusable[currentIndex] as HTMLElement
|
||||
|
||||
if (el.hasAttribute('data-controller-container')) {
|
||||
const inner = getInnerFocusables(el)
|
||||
const firstInner = inner[0]
|
||||
if (firstInner) {
|
||||
playNavSound('action')
|
||||
firstInner.focus()
|
||||
firstInner.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
playNavSound('action')
|
||||
el.click()
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// --- ARROWS ---
|
||||
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
|
||||
isControllerActive.value = true
|
||||
if (keyNavTimeout) clearTimeout(keyNavTimeout)
|
||||
@@ -108,9 +227,87 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
isControllerActive.value = gamepadCount.value > 0
|
||||
}, 3000)
|
||||
|
||||
const sidebarEls = getElementsInZone('sidebar')
|
||||
const mainEls = getElementsInZone('main')
|
||||
const hasZones = sidebarEls.length > 0 && mainEls.length > 0
|
||||
|
||||
// Right: from sidebar → main
|
||||
const firstMain = mainEls[0]
|
||||
if (e.key === 'ArrowRight' && hasZones && isInZone(activeEl, 'sidebar') && firstMain) {
|
||||
playNavSound('move')
|
||||
firstMain.focus()
|
||||
firstMain.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// Main zone: spatial navigation (game-style grid)
|
||||
if (hasZones && isInZone(activeEl, 'main')) {
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
||||
const next = findNearestInDirection(activeEl, mainEls, dir)
|
||||
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
// No element in that direction: Left from leftmost → sidebar
|
||||
if (e.key === 'ArrowLeft' && dir === 'left') {
|
||||
const lastSidebar = sidebarEls[sidebarEls.length - 1]
|
||||
if (lastSidebar) {
|
||||
playNavSound('move')
|
||||
lastSidebar.focus()
|
||||
lastSidebar.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inside container: spatial nav among inner elements
|
||||
if (isInsideContainer(activeEl)) {
|
||||
const container = activeEl.closest('[data-controller-container]') as HTMLElement
|
||||
if (container) {
|
||||
const inner = getInnerFocusables(container)
|
||||
const dir = e.key === 'ArrowLeft' ? 'left' : e.key === 'ArrowRight' ? 'right' : e.key === 'ArrowUp' ? 'up' : 'down'
|
||||
const next = findNearestInDirection(activeEl, inner, dir)
|
||||
if (next) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar: linear up/down
|
||||
if (isInZone(activeEl, 'sidebar')) {
|
||||
const idx = sidebarEls.indexOf(activeEl)
|
||||
if (idx >= 0) {
|
||||
const isDown = e.key === 'ArrowDown'
|
||||
const nextIdx = isDown ? Math.min(idx + 1, sidebarEls.length - 1) : Math.max(idx - 1, 0)
|
||||
const next = sidebarEls[nextIdx]
|
||||
if (next && next !== activeEl) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
if (next.tagName === 'A') {
|
||||
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
||||
if (href && href.startsWith('/')) router.push(href).catch(() => {})
|
||||
}
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: linear navigation
|
||||
let nextIndex = currentIndex
|
||||
const isForward = e.key === 'ArrowDown' || e.key === 'ArrowRight'
|
||||
|
||||
if (focusable.length === 0) return
|
||||
|
||||
if (currentIndex < 0) {
|
||||
@@ -126,6 +323,12 @@ export function useControllerNav(containerRef?: { value: HTMLElement | null }) {
|
||||
playNavSound('move')
|
||||
next.focus()
|
||||
next.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
if (isInZone(next, 'sidebar') && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
const href = (next as HTMLAnchorElement).getAttribute?.('href')
|
||||
if (href && href.startsWith('/') && next.tagName === 'A') {
|
||||
router.push(href).catch(() => {})
|
||||
}
|
||||
}
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Login screen audio: intro loop (MP3) + transition sounds.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
let introAudio: HTMLAudioElement | null = null
|
||||
let introGain: GainNode | null = null
|
||||
|
||||
function getContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function playTone(
|
||||
ctx: AudioContext,
|
||||
freq: number,
|
||||
duration: number,
|
||||
gain: number,
|
||||
type: OscillatorType = 'sine',
|
||||
startOffset = 0,
|
||||
dest: AudioNode = ctx.destination
|
||||
) {
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.connect(g)
|
||||
g.connect(dest)
|
||||
g.gain.setValueAtTime(0, ctx.currentTime)
|
||||
g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
|
||||
osc.frequency.value = freq
|
||||
osc.type = type
|
||||
osc.start(ctx.currentTime + startOffset)
|
||||
osc.stop(ctx.currentTime + startOffset + duration)
|
||||
}
|
||||
|
||||
const INTRO_AUDIO_URL = '/assets/audio/cosmic-updrift.mp3'
|
||||
const LOOP_START_URL = '/assets/audio/loop-start.mp3'
|
||||
|
||||
/** Play loop-start when transitioning from typing intro to Welcome Noderunner, as the intro music comes in */
|
||||
export function playLoopStart() {
|
||||
try {
|
||||
const audio = new Audio(LOOP_START_URL)
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** Resume audio context (call on first user interaction to unlock autoplay) */
|
||||
export function resumeAudioContext() {
|
||||
const ctx = getContext()
|
||||
if (ctx?.state === 'suspended') {
|
||||
ctx.resume().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/** Start intro loop - Cosmic Updrift (free royalty) */
|
||||
export function startSynthwave() {
|
||||
const ctxOrNull = getContext()
|
||||
if (!ctxOrNull) return
|
||||
|
||||
try {
|
||||
if (ctxOrNull.state === 'suspended') ctxOrNull.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
stopSynthwave()
|
||||
|
||||
const audio = new Audio(INTRO_AUDIO_URL)
|
||||
audio.loop = true
|
||||
|
||||
const ctx = ctxOrNull
|
||||
const source = ctx.createMediaElementSource(audio)
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.gain.value = 0.25
|
||||
source.connect(gainNode)
|
||||
gainNode.connect(ctx.destination)
|
||||
introGain = gainNode
|
||||
introAudio = audio
|
||||
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Stop intro loop (call on login success) */
|
||||
export function stopSynthwave() {
|
||||
if (introAudio) {
|
||||
if (introGain && audioContext) {
|
||||
const t = audioContext.currentTime
|
||||
introGain.gain.setValueAtTime(1, t)
|
||||
introGain.gain.linearRampToValueAtTime(0.001, t + 0.2)
|
||||
}
|
||||
setTimeout(() => {
|
||||
introAudio?.pause()
|
||||
introAudio = null
|
||||
introGain = null
|
||||
}, 220)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whoosh transition on successful login */
|
||||
export function playLoginSuccessWhoosh() {
|
||||
const woosh = new Audio('/assets/audio/woosh.mp3')
|
||||
woosh.volume = 0.5
|
||||
woosh.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Typing sound - plays once when welcome typing starts (typing.mp3) */
|
||||
export function playTypingSound() {
|
||||
const audio = new Audio('/assets/audio/typing.mp3')
|
||||
audio.volume = 0.6
|
||||
audio.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Intro typing - ONE sound per sentence: play when sentence starts, stop when it ends (intro-typing.mp3 from archy assets) */
|
||||
let introTypingAudio: HTMLAudioElement | null = null
|
||||
const INTRO_TYPING_URL = '/assets/audio/intro-typing.mp3'
|
||||
|
||||
export function playIntroTyping() {
|
||||
stopIntroTyping()
|
||||
introTypingAudio = new Audio(INTRO_TYPING_URL)
|
||||
introTypingAudio.volume = 0.5
|
||||
introTypingAudio.loop = true
|
||||
introTypingAudio.play().catch(() => {})
|
||||
}
|
||||
|
||||
export function stopIntroTyping() {
|
||||
if (introTypingAudio) {
|
||||
introTypingAudio.pause()
|
||||
introTypingAudio.currentTime = 0
|
||||
introTypingAudio = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Typing tick - for dashboard welcome typing (typing.mp3) */
|
||||
let typingTickPool: HTMLAudioElement[] = []
|
||||
const TYPING_TICK_POOL_SIZE = 5
|
||||
|
||||
function getTypingTick(): HTMLAudioElement {
|
||||
if (typingTickPool.length === 0) {
|
||||
for (let i = 0; i < TYPING_TICK_POOL_SIZE; i++) {
|
||||
const a = new Audio('/assets/audio/typing.mp3')
|
||||
a.volume = 0.4
|
||||
typingTickPool.push(a)
|
||||
}
|
||||
}
|
||||
const a = typingTickPool.shift()!
|
||||
typingTickPool.push(a)
|
||||
return a
|
||||
}
|
||||
|
||||
export function playTypingTick() {
|
||||
const a = getTypingTick()
|
||||
a.currentTime = 0
|
||||
a.play().catch(() => {})
|
||||
}
|
||||
|
||||
/** Gaming-style boot thud - soft impact when dashboard loads */
|
||||
export function playDashboardLoadOomph() {
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const t = ctx.currentTime
|
||||
|
||||
// Soft layered thud - sine only, smooth attack/decay
|
||||
const layers = [
|
||||
{ freq: 55, dur: 0.35, gain: 0.4, attack: 0.02 },
|
||||
{ freq: 82, dur: 0.28, gain: 0.25, attack: 0.03 },
|
||||
{ freq: 110, dur: 0.22, gain: 0.15, attack: 0.04 },
|
||||
{ freq: 165, dur: 0.18, gain: 0.1, attack: 0.05 },
|
||||
]
|
||||
|
||||
for (let i = 0; i < layers.length; i++) {
|
||||
const L = layers[i]!
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.type = 'sine'
|
||||
osc.frequency.value = L.freq
|
||||
g.gain.setValueAtTime(0, t)
|
||||
g.gain.linearRampToValueAtTime(L.gain, t + L.attack)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, t + L.dur)
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
osc.start(t + i * 0.01)
|
||||
osc.stop(t + L.dur)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ export function useMessageToast() {
|
||||
show: true,
|
||||
text: (newCount === 1 ? latest?.message : null) ?? `${newCount} new messages`,
|
||||
}
|
||||
lastMessageCount.value = msgs.length
|
||||
} else {
|
||||
lastMessageCount.value = msgs.length
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Epic interface sounds for controller/keyboard navigation.
|
||||
* Layered synthesis - cool, impactful, celebratory for actions.
|
||||
*/
|
||||
|
||||
let audioContext: AudioContext | null = null
|
||||
|
||||
function getContext(): AudioContext | null {
|
||||
if (audioContext) return audioContext
|
||||
try {
|
||||
audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
return audioContext
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function playTone(
|
||||
ctx: AudioContext,
|
||||
freq: number,
|
||||
duration: number,
|
||||
gain: number,
|
||||
type: OscillatorType = 'sine',
|
||||
startOffset = 0
|
||||
) {
|
||||
const osc = ctx.createOscillator()
|
||||
const g = ctx.createGain()
|
||||
osc.connect(g)
|
||||
g.connect(ctx.destination)
|
||||
g.gain.setValueAtTime(0, ctx.currentTime)
|
||||
g.gain.linearRampToValueAtTime(gain, ctx.currentTime + 0.01)
|
||||
g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + duration)
|
||||
osc.frequency.value = freq
|
||||
osc.type = type
|
||||
osc.start(ctx.currentTime + startOffset)
|
||||
osc.stop(ctx.currentTime + startOffset + duration)
|
||||
}
|
||||
|
||||
export function playNavSound(type: 'move' | 'select' | 'action' | 'back' = 'move') {
|
||||
if (type === 'move') {
|
||||
const audio = new Audio('/assets/audio/arrows.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
if (type === 'select' || type === 'action') {
|
||||
const audio = new Audio('/assets/audio/enter.mp3')
|
||||
audio.volume = 0.5
|
||||
audio.play().catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
const ctx = getContext()
|
||||
if (!ctx) return
|
||||
|
||||
try {
|
||||
if (ctx.state === 'suspended') ctx.resume()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'back': {
|
||||
playTone(ctx, 440, 0.06, 0.08, 'sine')
|
||||
playTone(ctx, 330, 0.08, 0.05, 'sine', 0.03)
|
||||
playTone(ctx, 220, 0.1, 0.04, 'triangle', 0.05)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user