Add directional emit model and per-type emit range defaults
This commit is contained in:
@@ -10,6 +10,12 @@ type EmitOutput = {
|
|||||||
panner: StereoPannerNode | null;
|
panner: StereoPannerNode | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EmitSpatialConfig = {
|
||||||
|
range: number;
|
||||||
|
directional: boolean;
|
||||||
|
facingDeg: number;
|
||||||
|
};
|
||||||
|
|
||||||
const ITEM_EMIT_BASE_GAIN = 0.3;
|
const ITEM_EMIT_BASE_GAIN = 0.3;
|
||||||
|
|
||||||
export class ItemEmitRuntime {
|
export class ItemEmitRuntime {
|
||||||
@@ -19,6 +25,7 @@ export class ItemEmitRuntime {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly audio: AudioEngine,
|
private readonly audio: AudioEngine,
|
||||||
private readonly resolveSoundUrl: (soundPath: string) => string,
|
private readonly resolveSoundUrl: (soundPath: string) => string,
|
||||||
|
private readonly getSpatialConfig: (item: WorldItem) => EmitSpatialConfig,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
cleanup(itemId: string): void {
|
cleanup(itemId: string): void {
|
||||||
@@ -108,14 +115,21 @@ export class ItemEmitRuntime {
|
|||||||
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const spatialConfig = this.getSpatialConfig(item);
|
||||||
const mix = resolveSpatialMix({
|
const mix = resolveSpatialMix({
|
||||||
dx: item.x - playerPosition.x,
|
dx: item.x - playerPosition.x,
|
||||||
dy: item.y - playerPosition.y,
|
dy: item.y - playerPosition.y,
|
||||||
range: HEARING_RADIUS,
|
range: Math.max(1, spatialConfig.range || HEARING_RADIUS),
|
||||||
baseGain: ITEM_EMIT_BASE_GAIN,
|
baseGain: ITEM_EMIT_BASE_GAIN,
|
||||||
nearFieldDistance: 1,
|
nearFieldDistance: 1,
|
||||||
nearFieldGain: 1,
|
nearFieldGain: 1,
|
||||||
nearFieldCenterPan: true,
|
nearFieldCenterPan: true,
|
||||||
|
directional: {
|
||||||
|
enabled: spatialConfig.directional,
|
||||||
|
facingDeg: spatialConfig.facingDeg,
|
||||||
|
coneDeg: 120,
|
||||||
|
rearGain: 0.5,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const gainValue = mix?.gain ?? 0;
|
const gainValue = mix?.gain ?? 0;
|
||||||
const panValue = mix?.pan ?? 0;
|
const panValue = mix?.pan ?? 0;
|
||||||
|
|||||||
@@ -113,12 +113,21 @@ function freshStreamUrl(streamUrl: string): string {
|
|||||||
return `${streamUrl}${separator}chgrid_start=${Date.now()}`;
|
return `${streamUrl}${separator}chgrid_start=${Date.now()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RadioSpatialConfig = {
|
||||||
|
range: number;
|
||||||
|
directional: boolean;
|
||||||
|
facingDeg: number;
|
||||||
|
};
|
||||||
|
|
||||||
export class RadioStationRuntime {
|
export class RadioStationRuntime {
|
||||||
private readonly sharedRadioSources = new Map<string, SharedRadioSource>();
|
private readonly sharedRadioSources = new Map<string, SharedRadioSource>();
|
||||||
private readonly itemRadioOutputs = new Map<string, ItemRadioOutput>();
|
private readonly itemRadioOutputs = new Map<string, ItemRadioOutput>();
|
||||||
private layerEnabled = true;
|
private layerEnabled = true;
|
||||||
|
|
||||||
constructor(private readonly audio: AudioEngine) {}
|
constructor(
|
||||||
|
private readonly audio: AudioEngine,
|
||||||
|
private readonly getSpatialConfig: (item: WorldItem) => RadioSpatialConfig,
|
||||||
|
) {}
|
||||||
|
|
||||||
cleanup(itemId: string): void {
|
cleanup(itemId: string): void {
|
||||||
const output = this.itemRadioOutputs.get(itemId);
|
const output = this.itemRadioOutputs.get(itemId);
|
||||||
@@ -203,14 +212,21 @@ export class RadioStationRuntime {
|
|||||||
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const spatialConfig = this.getSpatialConfig(item);
|
||||||
const mix = resolveSpatialMix({
|
const mix = resolveSpatialMix({
|
||||||
dx: item.x - playerPosition.x,
|
dx: item.x - playerPosition.x,
|
||||||
dy: item.y - playerPosition.y,
|
dy: item.y - playerPosition.y,
|
||||||
range: HEARING_RADIUS,
|
range: Math.max(1, spatialConfig.range || HEARING_RADIUS),
|
||||||
baseGain: normalizedVolume,
|
baseGain: normalizedVolume,
|
||||||
nearFieldDistance: 1,
|
nearFieldDistance: 1,
|
||||||
nearFieldGain: 1,
|
nearFieldGain: 1,
|
||||||
nearFieldCenterPan: true,
|
nearFieldCenterPan: true,
|
||||||
|
directional: {
|
||||||
|
enabled: spatialConfig.directional,
|
||||||
|
facingDeg: spatialConfig.facingDeg,
|
||||||
|
coneDeg: 120,
|
||||||
|
rearGain: 0.5,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const gainValue = mix?.gain ?? 0;
|
const gainValue = mix?.gain ?? 0;
|
||||||
const panValue = mix?.pan ?? 0;
|
const panValue = mix?.pan ?? 0;
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ export type SpatialMixOptions = {
|
|||||||
nearFieldDistance?: number;
|
nearFieldDistance?: number;
|
||||||
nearFieldGain?: number;
|
nearFieldGain?: number;
|
||||||
nearFieldCenterPan?: boolean;
|
nearFieldCenterPan?: boolean;
|
||||||
|
directional?: {
|
||||||
|
enabled: boolean;
|
||||||
|
facingDeg: number;
|
||||||
|
coneDeg?: number;
|
||||||
|
rearGain?: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SpatialMixResult = {
|
export type SpatialMixResult = {
|
||||||
@@ -45,5 +51,37 @@ export function resolveSpatialMix(options: SpatialMixOptions): SpatialMixResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (options.directional?.enabled) {
|
||||||
|
const coneDeg = Math.max(1, Math.min(359, options.directional.coneDeg ?? 120));
|
||||||
|
const rearGain = Math.max(0, Math.min(1, options.directional.rearGain ?? 0.5));
|
||||||
|
const facingDeg = normalizeDegrees(options.directional.facingDeg);
|
||||||
|
const bearingDeg = bearingFromSourceToListener(dx, dy);
|
||||||
|
const diff = angularDifferenceDeg(facingDeg, bearingDeg);
|
||||||
|
const halfCone = coneDeg / 2;
|
||||||
|
if (diff > halfCone) {
|
||||||
|
const span = Math.max(1, 180 - halfCone);
|
||||||
|
const t = Math.max(0, Math.min(1, (diff - halfCone) / span));
|
||||||
|
const directionalGain = 1 - t * (1 - rearGain);
|
||||||
|
gain *= directionalGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { distance, gain, pan };
|
return { distance, gain, pan };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeDegrees(value: number): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
const wrapped = value % 360;
|
||||||
|
return wrapped < 0 ? wrapped + 360 : wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearingFromSourceToListener(dx: number, dy: number): number {
|
||||||
|
// 0 degrees is north (+y), 90 is east (+x), matching screen-reader compass wording.
|
||||||
|
const degrees = Math.atan2(dx, dy) * (180 / Math.PI);
|
||||||
|
return normalizeDegrees(degrees);
|
||||||
|
}
|
||||||
|
|
||||||
|
function angularDifferenceDeg(a: number, b: number): number {
|
||||||
|
const raw = Math.abs(normalizeDegrees(a) - normalizeDegrees(b));
|
||||||
|
return raw > 180 ? 360 - raw : raw;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,17 +48,17 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
|
|||||||
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
|
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
|
||||||
|
|
||||||
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
||||||
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue'],
|
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing'],
|
||||||
dice: ['title', 'sides', 'number'],
|
dice: ['title', 'sides', 'number'],
|
||||||
wheel: ['title', 'spaces'],
|
wheel: ['title', 'spaces'],
|
||||||
clock: ['title', 'timeZone', 'use24Hour'],
|
clock: ['title', 'timeZone', 'use24Hour'],
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
|
||||||
radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 },
|
radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 20, directional: true },
|
||||||
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 },
|
dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false },
|
||||||
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 },
|
wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000, emitRange: 15, directional: false },
|
||||||
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 },
|
clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
type UiDefinitionsPayload = {
|
type UiDefinitionsPayload = {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from './audio/effects';
|
} from './audio/effects';
|
||||||
import { RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime';
|
import { RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime';
|
||||||
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
||||||
|
import { normalizeDegrees } from './audio/spatial';
|
||||||
import {
|
import {
|
||||||
applyPastedText,
|
applyPastedText,
|
||||||
applyTextInput,
|
applyTextInput,
|
||||||
@@ -175,8 +176,8 @@ let outputMode = localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono'
|
|||||||
let connecting = false;
|
let connecting = false;
|
||||||
const messageBuffer: string[] = [];
|
const messageBuffer: string[] = [];
|
||||||
let messageCursor = -1;
|
let messageCursor = -1;
|
||||||
const radioRuntime = new RadioStationRuntime(audio);
|
const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig);
|
||||||
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl);
|
const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl, getItemSpatialConfig);
|
||||||
let internalClipboardText = '';
|
let internalClipboardText = '';
|
||||||
let replaceTextOnNextType = false;
|
let replaceTextOnNextType = false;
|
||||||
let pendingEscapeDisconnect = false;
|
let pendingEscapeDisconnect = false;
|
||||||
@@ -522,6 +523,16 @@ function itemLabel(item: WorldItem): string {
|
|||||||
return `${item.title} (${itemTypeLabel(item.type)})`;
|
return `${item.title} (${itemTypeLabel(item.type)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getItemSpatialConfig(item: WorldItem): { range: number; directional: boolean; facingDeg: number } {
|
||||||
|
const global = getItemTypeGlobalProperties(item.type);
|
||||||
|
const rawRange = Number(global.emitRange);
|
||||||
|
const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15;
|
||||||
|
const directional = global.directional === true;
|
||||||
|
const rawFacing = Number(item.params.facing ?? 0);
|
||||||
|
const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0;
|
||||||
|
return { range, directional, facingDeg };
|
||||||
|
}
|
||||||
|
|
||||||
function openHelpViewer(): void {
|
function openHelpViewer(): void {
|
||||||
if (helpViewerLines.length === 0) {
|
if (helpViewerLines.length === 0) {
|
||||||
updateStatus('Help unavailable.');
|
updateStatus('Help unavailable.');
|
||||||
@@ -697,6 +708,11 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
|
|||||||
if (key === 'channel') return normalizeRadioChannel(item.params.channel);
|
if (key === 'channel') return normalizeRadioChannel(item.params.channel);
|
||||||
if (key === 'effect') return normalizeRadioEffect(item.params.effect);
|
if (key === 'effect') return normalizeRadioEffect(item.params.effect);
|
||||||
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
|
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
|
||||||
|
if (key === 'facing') {
|
||||||
|
const parsed = Number(item.params.facing ?? 0);
|
||||||
|
if (!Number.isFinite(parsed)) return '0';
|
||||||
|
return String(Math.round(normalizeDegrees(parsed) * 10) / 10);
|
||||||
|
}
|
||||||
const globalValue = getItemTypeGlobalProperties(item.type)?.[key];
|
const globalValue = getItemTypeGlobalProperties(item.type)?.[key];
|
||||||
if (globalValue !== undefined) return String(globalValue);
|
if (globalValue !== undefined) return String(globalValue);
|
||||||
return String(item.params[key] ?? '');
|
return String(item.params[key] ?? '');
|
||||||
@@ -1984,6 +2000,14 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed) } });
|
signaling.send({ type: 'item_update', itemId, params: { effectValue: clampEffectLevel(parsed) } });
|
||||||
|
} else if (propertyKey === 'facing') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 360) {
|
||||||
|
updateStatus('facing must be a number between 0 and 360.');
|
||||||
|
audio.sfxUiCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
signaling.send({ type: 'item_update', itemId, params: { facing: Math.round(parsed * 10) / 10 } });
|
||||||
} else if (propertyKey === 'spaces') {
|
} else if (propertyKey === 'spaces') {
|
||||||
const spaces = value
|
const spaces = value
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
- `emitSound`: optional continuously-looping spatial sound emitted from the item on the grid; global item field and not user-editable in V1.
|
- `emitSound`: optional continuously-looping spatial sound emitted from the item on the grid; global item field and not user-editable in V1.
|
||||||
- `capabilities`, `useSound`, and `emitSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state).
|
- `capabilities`, `useSound`, and `emitSound` are derived from global item-type definitions at runtime (not stored per-instance in persisted state).
|
||||||
- `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`), not per-instance editable.
|
- `useCooldownMs`: global per item type (`radio_station=1000`, `dice=1000`, `wheel=4000`, `clock=1000`), not per-instance editable.
|
||||||
|
- `emitRange`: global spatial range per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`), not per-instance editable.
|
||||||
|
- `directional`: global directional attenuation flag per item type (`radio_station=true`, others `false`), not per-instance editable.
|
||||||
|
|
||||||
## Persisted Item State (`server/runtime/items.json`)
|
## Persisted Item State (`server/runtime/items.json`)
|
||||||
|
|
||||||
@@ -61,7 +63,8 @@
|
|||||||
"channel": "stereo",
|
"channel": "stereo",
|
||||||
"volume": 50,
|
"volume": 50,
|
||||||
"effect": "off",
|
"effect": "off",
|
||||||
"effectValue": 50
|
"effectValue": 50,
|
||||||
|
"facing": 0
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -72,6 +75,7 @@
|
|||||||
- `channel`: one of `stereo | mono | left | right`, default `stereo`.
|
- `channel`: one of `stereo | mono | left | right`, default `stereo`.
|
||||||
- `effect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
|
- `effect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`.
|
||||||
- `effectValue`: number, range `0-100`, precision `0.1`.
|
- `effectValue`: number, range `0-100`, precision `0.1`.
|
||||||
|
- `facing`: number, range `0-360`, precision `0.1` (used when `directional=true`).
|
||||||
|
|
||||||
### `dice`
|
### `dice`
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `useSound`
|
- `useSound`
|
||||||
- `emitSound`
|
- `emitSound`
|
||||||
- `useCooldownMs` (from item catalog)
|
- `useCooldownMs` (from item catalog)
|
||||||
|
- `emitRange` (spatial range in squares)
|
||||||
|
- `directional` (directional attenuation enabled)
|
||||||
- Instance fields are persisted in `server/runtime/items.json`.
|
- Instance fields are persisted in `server/runtime/items.json`.
|
||||||
|
|
||||||
## `radio_station`
|
## `radio_station`
|
||||||
@@ -23,10 +25,13 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `volume=50`
|
- `volume=50`
|
||||||
- `effect="off"`
|
- `effect="off"`
|
||||||
- `effectValue=50`
|
- `effectValue=50`
|
||||||
|
- `facing=0`
|
||||||
- Global:
|
- Global:
|
||||||
- `useSound=none`
|
- `useSound=none`
|
||||||
- `emitSound=none`
|
- `emitSound=none`
|
||||||
- `useCooldownMs=1000`
|
- `useCooldownMs=1000`
|
||||||
|
- `emitRange=20`
|
||||||
|
- `directional=true`
|
||||||
|
|
||||||
### Use
|
### Use
|
||||||
- `use` toggles `enabled` on/off and broadcasts chat status.
|
- `use` toggles `enabled` on/off and broadcasts chat status.
|
||||||
@@ -36,6 +41,7 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `volume`: integer `0..100`
|
- `volume`: integer `0..100`
|
||||||
- `effect`: `reverb | echo | flanger | high_pass | low_pass | off`
|
- `effect`: `reverb | echo | flanger | high_pass | low_pass | off`
|
||||||
- `effectValue`: number `0..100` with `0.1` precision
|
- `effectValue`: number `0..100` with `0.1` precision
|
||||||
|
- `facing`: number `0..360` with `0.1` precision
|
||||||
|
|
||||||
## `dice`
|
## `dice`
|
||||||
|
|
||||||
@@ -48,6 +54,8 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `useSound=sounds/roll.ogg`
|
- `useSound=sounds/roll.ogg`
|
||||||
- `emitSound=none`
|
- `emitSound=none`
|
||||||
- `useCooldownMs=1000`
|
- `useCooldownMs=1000`
|
||||||
|
- `emitRange=15`
|
||||||
|
- `directional=false`
|
||||||
|
|
||||||
### Use
|
### Use
|
||||||
- Rolls `number` dice with `sides` sides and reports values + total.
|
- Rolls `number` dice with `sides` sides and reports values + total.
|
||||||
@@ -66,6 +74,8 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `useSound=sounds/spin.ogg`
|
- `useSound=sounds/spin.ogg`
|
||||||
- `emitSound=none`
|
- `emitSound=none`
|
||||||
- `useCooldownMs=4000`
|
- `useCooldownMs=4000`
|
||||||
|
- `emitRange=15`
|
||||||
|
- `directional=false`
|
||||||
|
|
||||||
### Use
|
### Use
|
||||||
- Announces spin immediately.
|
- Announces spin immediately.
|
||||||
@@ -88,6 +98,8 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `useSound=none`
|
- `useSound=none`
|
||||||
- `emitSound=sounds/clock.ogg`
|
- `emitSound=sounds/clock.ogg`
|
||||||
- `useCooldownMs=1000`
|
- `useCooldownMs=1000`
|
||||||
|
- `emitRange=10`
|
||||||
|
- `directional=false`
|
||||||
|
|
||||||
### Use
|
### Use
|
||||||
- Reports current time from item timezone and format.
|
- Reports current time from item timezone and format.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ ITEM_TYPE_LABELS: dict[ItemType, str] = {
|
|||||||
RADIO_EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off")
|
RADIO_EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off")
|
||||||
RADIO_CHANNEL_OPTIONS: tuple[str, ...] = ("stereo", "mono", "left", "right")
|
RADIO_CHANNEL_OPTIONS: tuple[str, ...] = ("stereo", "mono", "left", "right")
|
||||||
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
|
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
|
||||||
"radio_station": ("title", "streamUrl", "enabled", "channel", "volume", "effect", "effectValue"),
|
"radio_station": ("title", "streamUrl", "enabled", "channel", "volume", "effect", "effectValue", "facing"),
|
||||||
"dice": ("title", "sides", "number"),
|
"dice": ("title", "sides", "number"),
|
||||||
"wheel": ("title", "spaces"),
|
"wheel": ("title", "spaces"),
|
||||||
"clock": ("title", "timeZone", "use24Hour"),
|
"clock": ("title", "timeZone", "use24Hour"),
|
||||||
@@ -76,6 +76,8 @@ class ItemDefinition:
|
|||||||
emit_sound: str | None
|
emit_sound: str | None
|
||||||
default_params: dict
|
default_params: dict
|
||||||
use_cooldown_ms: int = 1000
|
use_cooldown_ms: int = 1000
|
||||||
|
emit_range: int = 15
|
||||||
|
directional: bool = False
|
||||||
|
|
||||||
|
|
||||||
ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
||||||
@@ -84,7 +86,9 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
|||||||
capabilities=("editable", "carryable", "deletable", "usable"),
|
capabilities=("editable", "carryable", "deletable", "usable"),
|
||||||
use_sound=None,
|
use_sound=None,
|
||||||
emit_sound=None,
|
emit_sound=None,
|
||||||
default_params={"streamUrl": "", "enabled": True, "channel": "stereo", "volume": 50, "effect": "off", "effectValue": 50},
|
default_params={"streamUrl": "", "enabled": True, "channel": "stereo", "volume": 50, "effect": "off", "effectValue": 50, "facing": 0},
|
||||||
|
emit_range=20,
|
||||||
|
directional=True,
|
||||||
),
|
),
|
||||||
"dice": ItemDefinition(
|
"dice": ItemDefinition(
|
||||||
default_title="Dice",
|
default_title="Dice",
|
||||||
@@ -107,6 +111,7 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = {
|
|||||||
use_sound=None,
|
use_sound=None,
|
||||||
emit_sound="sounds/clock.ogg",
|
emit_sound="sounds/clock.ogg",
|
||||||
default_params={"timeZone": CLOCK_DEFAULT_TIME_ZONE, "use24Hour": False},
|
default_params={"timeZone": CLOCK_DEFAULT_TIME_ZONE, "use24Hour": False},
|
||||||
|
emit_range=10,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ def get_item_use_cooldown_ms(item_type: ItemType) -> int:
|
|||||||
return 1000
|
return 1000
|
||||||
|
|
||||||
|
|
||||||
def get_item_global_properties(item_type: ItemType) -> dict[str, str | int]:
|
def get_item_global_properties(item_type: ItemType) -> dict[str, str | int | bool]:
|
||||||
"""Return non-editable global properties exposed in UI metadata."""
|
"""Return non-editable global properties exposed in UI metadata."""
|
||||||
|
|
||||||
definition = get_item_definition(item_type)
|
definition = get_item_definition(item_type)
|
||||||
@@ -141,4 +146,6 @@ def get_item_global_properties(item_type: ItemType) -> dict[str, str | int]:
|
|||||||
"useSound": definition.use_sound or "none",
|
"useSound": definition.use_sound or "none",
|
||||||
"emitSound": definition.emit_sound or "none",
|
"emitSound": definition.emit_sound or "none",
|
||||||
"useCooldownMs": get_item_use_cooldown_ms(item_type),
|
"useCooldownMs": get_item_use_cooldown_ms(item_type),
|
||||||
|
"emitRange": definition.emit_range if isinstance(definition.emit_range, int) and definition.emit_range > 0 else 15,
|
||||||
|
"directional": bool(definition.directional),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,14 @@ def _validate_radio_update(item: WorldItem, next_params: dict) -> dict:
|
|||||||
if not (0 <= effect_value <= 100):
|
if not (0 <= effect_value <= 100):
|
||||||
raise ValueError("effectValue must be between 0 and 100.")
|
raise ValueError("effectValue must be between 0 and 100.")
|
||||||
next_params["effectValue"] = round(effect_value, 1)
|
next_params["effectValue"] = round(effect_value, 1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
facing = float(next_params.get("facing", item.params.get("facing", 0)))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("facing must be a number between 0 and 360.") from exc
|
||||||
|
if not (0 <= facing <= 360):
|
||||||
|
raise ValueError("facing must be between 0 and 360.")
|
||||||
|
next_params["facing"] = round(facing, 1)
|
||||||
return next_params
|
return next_params
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,20 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
assert send_payloads[-1].ok is False
|
assert send_payloads[-1].ok is False
|
||||||
assert "channel must be one of" in send_payloads[-1].message.lower()
|
assert "channel must be one of" in send_payloads[-1].message.lower()
|
||||||
|
|
||||||
|
await server._handle_message(
|
||||||
|
client,
|
||||||
|
json.dumps({"type": "item_update", "itemId": item.id, "params": {"facing": 270}}),
|
||||||
|
)
|
||||||
|
assert send_payloads[-1].ok is True
|
||||||
|
assert item.params.get("facing") == 270
|
||||||
|
|
||||||
|
await server._handle_message(
|
||||||
|
client,
|
||||||
|
json.dumps({"type": "item_update", "itemId": item.id, "params": {"facing": 361}}),
|
||||||
|
)
|
||||||
|
assert send_payloads[-1].ok is False
|
||||||
|
assert "facing must be between 0 and 360" in send_payloads[-1].message.lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_clock_use_reports_time_without_use_sound_packet(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_clock_use_reports_time_without_use_sound_packet(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user