Rename radio media params and add widget emit effects
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
// Maintainer-controlled web client version.
|
||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||
window.CHGRID_WEB_VERSION = "2026.02.21 R127";
|
||||
window.CHGRID_WEB_VERSION = "2026.02.21 R128";
|
||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { HEARING_RADIUS, type WorldItem } from '../state/gameState';
|
||||
import { AudioEngine } from './audioEngine';
|
||||
import { connectEffectChain, disconnectEffectRuntime, type EffectId, type EffectRuntime } from './effects';
|
||||
import { normalizeRadioEffect, normalizeRadioEffectValue } from './radioStationRuntime';
|
||||
import { resolveSpatialMix } from './spatial';
|
||||
|
||||
type EmitOutput = {
|
||||
soundUrl: string;
|
||||
element: HTMLAudioElement;
|
||||
source: MediaElementAudioSourceNode;
|
||||
effectInput: GainNode;
|
||||
effectRuntime: EffectRuntime | null;
|
||||
effect: EffectId;
|
||||
effectValue: number;
|
||||
gain: GainNode;
|
||||
panner: StereoPannerNode | null;
|
||||
};
|
||||
@@ -34,6 +40,8 @@ export class ItemEmitRuntime {
|
||||
output.element.pause();
|
||||
output.element.src = '';
|
||||
output.source.disconnect();
|
||||
output.effectInput.disconnect();
|
||||
disconnectEffectRuntime(output.effectRuntime);
|
||||
output.gain.disconnect();
|
||||
output.panner?.disconnect();
|
||||
this.outputs.delete(itemId);
|
||||
@@ -85,17 +93,21 @@ export class ItemEmitRuntime {
|
||||
element.preload = 'none';
|
||||
element.crossOrigin = 'anonymous';
|
||||
const source = audioCtx.createMediaElementSource(element);
|
||||
const effectInput = audioCtx.createGain();
|
||||
const gain = audioCtx.createGain();
|
||||
gain.gain.value = 0;
|
||||
let panner: StereoPannerNode | null = null;
|
||||
source.connect(gain);
|
||||
source.connect(effectInput);
|
||||
const effect = normalizeRadioEffect(item.params.emitEffect);
|
||||
const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue);
|
||||
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
||||
if (this.audio.supportsStereoPanner()) {
|
||||
panner = audioCtx.createStereoPanner();
|
||||
gain.connect(panner).connect(audioCtx.destination);
|
||||
} else {
|
||||
gain.connect(audioCtx.destination);
|
||||
}
|
||||
this.outputs.set(item.id, { soundUrl, element, source, gain, panner });
|
||||
this.outputs.set(item.id, { soundUrl, element, source, effectInput, effectRuntime, effect, effectValue, gain, panner });
|
||||
void element.play().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -117,6 +129,15 @@ export class ItemEmitRuntime {
|
||||
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
||||
continue;
|
||||
}
|
||||
const effect = normalizeRadioEffect(item.params.emitEffect);
|
||||
const effectValue = normalizeRadioEffectValue(item.params.emitEffectValue);
|
||||
if (output.effect !== effect || output.effectValue !== effectValue) {
|
||||
output.effectInput.disconnect();
|
||||
disconnectEffectRuntime(output.effectRuntime);
|
||||
output.effectRuntime = connectEffectChain(audioCtx, output.effectInput, output.gain, effect, effectValue);
|
||||
output.effect = effect;
|
||||
output.effectValue = effectValue;
|
||||
}
|
||||
const spatialConfig = this.getSpatialConfig(item);
|
||||
const mix = resolveSpatialMix({
|
||||
dx: item.x - playerPosition.x,
|
||||
|
||||
@@ -205,8 +205,8 @@ export class RadioStationRuntime {
|
||||
const enabled = item.params.enabled !== false;
|
||||
const mediaVolume = Number(item.params.mediaVolume ?? 50);
|
||||
const normalizedVolume = Number.isFinite(mediaVolume) ? Math.max(0, Math.min(100, mediaVolume)) / 100 : 0.5;
|
||||
const effect = normalizeRadioEffect(item.params.effect);
|
||||
const effectValue = normalizeRadioEffectValue(item.params.effectValue);
|
||||
const effect = normalizeRadioEffect(item.params.mediaEffect);
|
||||
const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue);
|
||||
this.applyEffect(output, audioCtx, effect, effectValue);
|
||||
if (!streamUrl || !enabled) {
|
||||
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
||||
@@ -299,7 +299,7 @@ export class RadioStationRuntime {
|
||||
const audioCtx = this.audio.context;
|
||||
if (!audioCtx) return;
|
||||
|
||||
const channel = normalizeRadioChannel(item.params.channel);
|
||||
const channel = normalizeRadioChannel(item.params.mediaChannel);
|
||||
const existing = this.itemRadioOutputs.get(item.id);
|
||||
if (existing && existing.streamUrl === streamUrl && existing.channel === channel) {
|
||||
return;
|
||||
@@ -315,8 +315,8 @@ export class RadioStationRuntime {
|
||||
gain.gain.value = 0;
|
||||
const effectInput = audioCtx.createGain();
|
||||
const channelSource = connectRadioChannelSource(audioCtx, shared.source, channel, effectInput);
|
||||
const effect = normalizeRadioEffect(item.params.effect);
|
||||
const effectValue = normalizeRadioEffectValue(item.params.effectValue);
|
||||
const effect = normalizeRadioEffect(item.params.mediaEffect);
|
||||
const effectValue = normalizeRadioEffectValue(item.params.mediaEffectValue);
|
||||
const effectRuntime = connectEffectChain(audioCtx, effectInput, gain, effect, effectValue);
|
||||
let panner: StereoPannerNode | null = null;
|
||||
if (this.audio.supportsStereoPanner()) {
|
||||
|
||||
@@ -48,11 +48,11 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
|
||||
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel', 'widget'];
|
||||
|
||||
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
||||
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'mediaVolume', 'effect', 'effectValue', 'facing', 'emitRange'],
|
||||
radio_station: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'],
|
||||
dice: ['title', 'sides', 'number'],
|
||||
wheel: ['title', 'spaces'],
|
||||
clock: ['title', 'timeZone', 'use24Hour'],
|
||||
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'useSound', 'emitSound'],
|
||||
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'],
|
||||
};
|
||||
|
||||
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
||||
@@ -112,8 +112,9 @@ let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number |
|
||||
widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget },
|
||||
};
|
||||
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
||||
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
||||
channel: [...RADIO_CHANNEL_OPTIONS],
|
||||
mediaEffect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
||||
emitEffect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
||||
mediaChannel: [...RADIO_CHANNEL_OPTIONS],
|
||||
timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS],
|
||||
};
|
||||
let itemTypePropertyMetadata: Partial<Record<ItemType, Record<string, ItemPropertyMetadata>>> = {};
|
||||
@@ -195,6 +196,11 @@ export function itemPropertyLabel(key: string): string {
|
||||
if (key === 'emitRange') return 'emit range';
|
||||
if (key === 'mediaVolume') return 'media volume';
|
||||
if (key === 'emitVolume') return 'emit volume';
|
||||
if (key === 'mediaChannel') return 'media channel';
|
||||
if (key === 'mediaEffect') return 'media effect';
|
||||
if (key === 'mediaEffectValue') return 'media effect value';
|
||||
if (key === 'emitEffect') return 'emit effect';
|
||||
if (key === 'emitEffectValue') return 'emit effect value';
|
||||
if (key === 'useSound') return 'use sound';
|
||||
if (key === 'emitSound') return 'emit sound';
|
||||
return key;
|
||||
|
||||
@@ -723,9 +723,11 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
|
||||
}
|
||||
if (key === 'timeZone') return String(item.params.timeZone ?? getDefaultClockTimeZone());
|
||||
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
|
||||
if (key === 'channel') return normalizeRadioChannel(item.params.channel);
|
||||
if (key === 'effect') return normalizeRadioEffect(item.params.effect);
|
||||
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
|
||||
if (key === 'mediaChannel') return normalizeRadioChannel(item.params.mediaChannel);
|
||||
if (key === 'mediaEffect') return normalizeRadioEffect(item.params.mediaEffect);
|
||||
if (key === 'mediaEffectValue') return String(normalizeRadioEffectValue(item.params.mediaEffectValue));
|
||||
if (key === 'emitEffect') return normalizeRadioEffect(item.params.emitEffect);
|
||||
if (key === 'emitEffectValue') return String(normalizeRadioEffectValue(item.params.emitEffectValue));
|
||||
if (key === 'facing') {
|
||||
const parsed = Number(item.params.facing ?? 0);
|
||||
if (!Number.isFinite(parsed)) return '0';
|
||||
@@ -744,14 +746,15 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
|
||||
function inferItemPropertyValueType(item: WorldItem, key: string): string | undefined {
|
||||
if (key === 'useSound' || key === 'emitSound') return 'sound';
|
||||
if (key === 'enabled' || key === 'use24Hour' || key === 'directional') return 'boolean';
|
||||
if (key === 'channel' || key === 'effect' || key === 'timeZone') return 'list';
|
||||
if (key === 'mediaChannel' || key === 'mediaEffect' || key === 'emitEffect' || key === 'timeZone') return 'list';
|
||||
if (
|
||||
key === 'x' ||
|
||||
key === 'y' ||
|
||||
key === 'version' ||
|
||||
key === 'mediaVolume' ||
|
||||
key === 'emitVolume' ||
|
||||
key === 'effectValue' ||
|
||||
key === 'mediaEffectValue' ||
|
||||
key === 'emitEffectValue' ||
|
||||
key === 'facing' ||
|
||||
key === 'emitRange' ||
|
||||
key === 'sides' ||
|
||||
@@ -2155,22 +2158,22 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
|
||||
return;
|
||||
}
|
||||
signaling.send({ type: 'item_update', itemId, params: { emitVolume: parsed.value } });
|
||||
} else if (propertyKey === 'effect') {
|
||||
} else if (propertyKey === 'mediaEffect' || propertyKey === 'emitEffect') {
|
||||
const normalized = value.trim().toLowerCase() as EffectId;
|
||||
if (!EFFECT_IDS.has(normalized)) {
|
||||
updateStatus(`effect must be one of: ${EFFECT_SEQUENCE.map((effect) => effect.id).join(', ')}.`);
|
||||
updateStatus(`${itemPropertyLabel(propertyKey)} must be one of: ${EFFECT_SEQUENCE.map((effect) => effect.id).join(', ')}.`);
|
||||
audio.sfxUiCancel();
|
||||
return;
|
||||
}
|
||||
signaling.send({ type: 'item_update', itemId, params: { effect: normalized } });
|
||||
} else if (propertyKey === 'effectValue') {
|
||||
signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: normalized } });
|
||||
} else if (propertyKey === 'mediaEffectValue' || propertyKey === 'emitEffectValue') {
|
||||
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false);
|
||||
if (!parsed.ok) {
|
||||
updateStatus(parsed.message);
|
||||
audio.sfxUiCancel();
|
||||
return;
|
||||
}
|
||||
signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed.value) } });
|
||||
signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: clampEffectLevel(parsed.value) } });
|
||||
} else if (propertyKey === 'facing') {
|
||||
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, false);
|
||||
if (!parsed.ok) {
|
||||
|
||||
Reference in New Issue
Block a user