ca5faeea创建于 9 天前历史提交
/**
 * Audio.js
 *
 * Tiny one-shot SFX player for in-game cues. Each clip is loaded once,
 * decoded into an AudioBuffer, and played via short-lived
 * AudioBufferSourceNodes so that rapid-fire triggers overlap cleanly
 * (instead of restarting / cutting off a single shared <audio> element).
 *
 * Audio policies on every modern browser require a user gesture before
 * sound can play, so we lazily resume the AudioContext on the first
 * trigger and silently ignore the call if the context is still suspended.
 */

const DEFAULT_VOLUME = 0.55;

let _audioCtx = null;
let _enabled = true;

// Per-clip state. Each entry is { buffer, loading, lastPlayAt, minIntervalMs }.
const _clips = new Map();

function getCtx() {
    if (_audioCtx) return _audioCtx;
    const Ctx = window.AudioContext || window.webkitAudioContext;
    if (!Ctx) return null;
    try {
        _audioCtx = new Ctx();
    } catch {
        return null;
    }
    return _audioCtx;
}

/**
 * Fetch + decode a clip and register it under `name`. Safe to call
 * multiple times — re-registering with the same name returns the
 * memoised promise. Failures are logged and swallowed: a missing
 * sound file should never break the UI.
 *
 * `minIntervalMs` debounces rapid-fire triggers (default 18ms keeps a
 * keyboard-repeat from machine-gunning the clip).
 */
export async function registerClip(name, url, { minIntervalMs = 18 } = {}) {
    let entry = _clips.get(name);
    if (entry?.buffer || entry?.loading) return entry.loading ?? Promise.resolve();
    if (!entry) {
        entry = { buffer: null, loading: null, lastPlayAt: 0, minIntervalMs };
        _clips.set(name, entry);
    } else {
        entry.minIntervalMs = minIntervalMs;
    }
    entry.loading = (async () => {
        const ctx = getCtx();
        if (!ctx) return;
        try {
            const res = await fetch(url);
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            const data = await res.arrayBuffer();
            entry.buffer = await new Promise((resolve, reject) => {
                ctx.decodeAudioData(data, resolve, reject);
            });
        } catch (err) {
            console.warn(`[audio] failed to load clip "${name}":`, err);
        }
    })();
    return entry.loading;
}

/**
 * Trigger a registered clip. No-op when audio is disabled, the buffer
 * hasn't loaded yet, or the AudioContext is still suspended waiting for
 * a user gesture (the very first interaction primes it).
 */
export function play(name, volume = DEFAULT_VOLUME) {
    if (!_enabled) return;
    const entry = _clips.get(name);
    if (!entry || !entry.buffer) return;
    const ctx = getCtx();
    if (!ctx) return;

    if (ctx.state === 'suspended') {
        ctx.resume().catch(() => {});
    }

    const now = performance.now();
    if (now - entry.lastPlayAt < entry.minIntervalMs) return;
    entry.lastPlayAt = now;

    try {
        const src = ctx.createBufferSource();
        src.buffer = entry.buffer;
        const gain = ctx.createGain();
        gain.gain.value = volume;
        src.connect(gain).connect(ctx.destination);
        src.start(0);
    } catch {
        /* swallow — sound failures are non-fatal */
    }
}

/* ── Convenience wrappers for the clips we ship ─────────────────── */

export async function loadUiAudio() {
    // Seven clips: a soft click for menus / palette / toolbar / shortcuts,
    // a generic "thud" fallback, a wet splash for water tiles, a chunky
    // knock for stone / brick / plaster masonry, a hollow tap for fences
    // / wooden decorations, a soft rustle for small vegetation, and a
    // leafier whoosh for trees / large vegetation. All loaded in parallel.
    await Promise.all([
        registerClip('ui',                'menu_select_lightbulb.ogg',   { minIntervalMs: 18 }),
        // Brushing across cells fires very rapidly; allow modest overlap
        // but throttle a touch more aggressively than the UI click.
        registerClip('placement',         'new-placement.ogg',            { minIntervalMs: 35 }),
        registerClip('placementWater',    'waterPlacement.ogg',           { minIntervalMs: 50 }),
        registerClip('placementStone',    'brick-stone.ogg',              { minIntervalMs: 35 }),
        registerClip('placementWood',     'fence-woodenDecorations.ogg',  { minIntervalMs: 35 }),
        registerClip('placementVeg',      'small-vegetations.ogg',        { minIntervalMs: 30 }),
        registerClip('placementTree',     'large-vegetations.ogg',        { minIntervalMs: 40 }),
    ]);
}

export function playUiClick(volume = DEFAULT_VOLUME)   { play('ui',             volume); }
export function playPlacement(volume = 0.6)            { play('placement',      volume); }
export function playWaterPlacement(volume = 0.6)       { play('placementWater', volume); }
export function playStonePlacement(volume = 0.6)       { play('placementStone', volume); }
export function playWoodPlacement(volume = 0.6)        { play('placementWood',  volume); }
export function playVegPlacement(volume = 0.6)         { play('placementVeg',   volume); }
export function playTreePlacement(volume = 0.6)        { play('placementTree',  volume); }

/**
 * Asset ids whose placement / erase should trigger the brick-stone SFX.
 * Includes the obvious stone terrain + props plus the white-plastered
 * Mykonos buildings (which are masonry under the paint).
 *
 * Kept as flat Sets so membership checks stay O(1) inside the per-click
 * `playPlacementFor` lookup.
 */
const STONE_ASSET_IDS = new Set([
    // Terrain
    'stone', 'path', 'sea_wall', 'stairs',
    // Walls / arches / lanterns / basins
    'low_wall', 'corner_wall', 'archway',
    'stone_lantern', 'stone_basin', 'well',
    // Rock clutter
    'rocks', 'large_rock', 'mossy_stone', 'flat_stone',
    'pebbles', 'stone_pile', 'boulder',
    // Buildings (whitewashed masonry)
    'house', 'two_story', 'cube_house', 'terrace_house', 'pergola_house',
    'villa', 'altar', 'tower_chapel', 'main_chapel', 'windmill',
]);

/**
 * Asset ids whose placement / erase should trigger the wood / fence SFX.
 * Covers wooden fences and railings, wooden furniture and props, and
 * the wooden planter boxes / bridges in the water category.
 */
const WOOD_ASSET_IDS = new Set([
    // Fences / railings / gates
    'blue_railing', 'gate_fence',
    // Wooden furniture / signage
    'bench', 'signpost', 'banner',
    // Lantern posts (wooden mast)
    'lantern_post', 'hanging_lantern',
    // Wooden carryables
    'crate', 'hay_bale', 'storage_box', 'wood_pile', 'water_bucket',
    // Wooden water-category structures
    'small_bridge', 'garden_bed', 'crop_patch', 'veg_garden',
]);

/**
 * Asset ids whose placement / erase should trigger the small-vegetation
 * rustle. Includes the grass terrain plus low-lying plant props
 * (succulents, grass tufts, potted flowers).
 */
const SMALL_VEG_ASSET_IDS = new Set([
    'grass',
    'agave', 'dry_grass', 'flower_pot', 'terracotta_pot',
]);

/**
 * Asset ids whose placement / erase should trigger the large-vegetation /
 * tree whoosh. Reserved for full trees and tall flowering plants.
 */
const LARGE_VEG_ASSET_IDS = new Set([
    'cypress', 'olive', 'bougainvillea',
]);

/**
 * Pick the right placement SFX for a given asset id:
 *   - water tiles       → splash
 *   - stone / masonry   → brick knock
 *   - fence / wood      → hollow wood tap
 *   - small vegetation  → soft rustle
 *   - trees / large veg → leafy whoosh
 *   - everything else   → generic placement thud
 *
 * Centralising the lookup here means callers don't need to know the
 * asset taxonomy.
 */
export function playPlacementFor(assetId) {
    if (assetId === 'water') {
        playWaterPlacement();
        return;
    }
    if (STONE_ASSET_IDS.has(assetId)) {
        playStonePlacement();
        return;
    }
    if (WOOD_ASSET_IDS.has(assetId)) {
        playWoodPlacement();
        return;
    }
    if (SMALL_VEG_ASSET_IDS.has(assetId)) {
        playVegPlacement();
        return;
    }
    if (LARGE_VEG_ASSET_IDS.has(assetId)) {
        playTreePlacement();
        return;
    }
    playPlacement();
}

export function setUiAudioEnabled(on) { _enabled = !!on; }
export function isUiAudioEnabled() { return _enabled; }