From 79523246338c28ab547755cc4704386815a777eb Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 21 Feb 2026 19:37:08 -0500 Subject: [PATCH] Add directional emit model and per-type emit range defaults --- client/src/audio/itemEmitRuntime.ts | 16 ++++++++++- client/src/audio/radioStationRuntime.ts | 20 +++++++++++-- client/src/audio/spatial.ts | 38 +++++++++++++++++++++++++ client/src/items/itemRegistry.ts | 10 +++---- client/src/main.ts | 28 ++++++++++++++++-- docs/item-schema.md | 6 +++- docs/item-types.md | 12 ++++++++ server/app/item_catalog.py | 13 +++++++-- server/app/item_type_handlers.py | 8 ++++++ server/tests/test_item_use_cooldown.py | 14 +++++++++ 10 files changed, 151 insertions(+), 14 deletions(-) diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index 8eb0857..db46776 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -10,6 +10,12 @@ type EmitOutput = { panner: StereoPannerNode | null; }; +type EmitSpatialConfig = { + range: number; + directional: boolean; + facingDeg: number; +}; + const ITEM_EMIT_BASE_GAIN = 0.3; export class ItemEmitRuntime { @@ -19,6 +25,7 @@ export class ItemEmitRuntime { constructor( private readonly audio: AudioEngine, private readonly resolveSoundUrl: (soundPath: string) => string, + private readonly getSpatialConfig: (item: WorldItem) => EmitSpatialConfig, ) {} cleanup(itemId: string): void { @@ -108,14 +115,21 @@ export class ItemEmitRuntime { output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); continue; } + const spatialConfig = this.getSpatialConfig(item); const mix = resolveSpatialMix({ dx: item.x - playerPosition.x, dy: item.y - playerPosition.y, - range: HEARING_RADIUS, + range: Math.max(1, spatialConfig.range || HEARING_RADIUS), baseGain: ITEM_EMIT_BASE_GAIN, nearFieldDistance: 1, nearFieldGain: 1, nearFieldCenterPan: true, + directional: { + enabled: spatialConfig.directional, + facingDeg: spatialConfig.facingDeg, + coneDeg: 120, + rearGain: 0.5, + }, }); const gainValue = mix?.gain ?? 0; const panValue = mix?.pan ?? 0; diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index e50c066..a8b4438 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -113,12 +113,21 @@ function freshStreamUrl(streamUrl: string): string { return `${streamUrl}${separator}chgrid_start=${Date.now()}`; } +type RadioSpatialConfig = { + range: number; + directional: boolean; + facingDeg: number; +}; + export class RadioStationRuntime { private readonly sharedRadioSources = new Map(); private readonly itemRadioOutputs = new Map(); private layerEnabled = true; - constructor(private readonly audio: AudioEngine) {} + constructor( + private readonly audio: AudioEngine, + private readonly getSpatialConfig: (item: WorldItem) => RadioSpatialConfig, + ) {} cleanup(itemId: string): void { const output = this.itemRadioOutputs.get(itemId); @@ -203,14 +212,21 @@ export class RadioStationRuntime { output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05); continue; } + const spatialConfig = this.getSpatialConfig(item); const mix = resolveSpatialMix({ dx: item.x - playerPosition.x, dy: item.y - playerPosition.y, - range: HEARING_RADIUS, + range: Math.max(1, spatialConfig.range || HEARING_RADIUS), baseGain: normalizedVolume, nearFieldDistance: 1, nearFieldGain: 1, nearFieldCenterPan: true, + directional: { + enabled: spatialConfig.directional, + facingDeg: spatialConfig.facingDeg, + coneDeg: 120, + rearGain: 0.5, + }, }); const gainValue = mix?.gain ?? 0; const panValue = mix?.pan ?? 0; diff --git a/client/src/audio/spatial.ts b/client/src/audio/spatial.ts index dbbe5ab..87170dc 100644 --- a/client/src/audio/spatial.ts +++ b/client/src/audio/spatial.ts @@ -6,6 +6,12 @@ export type SpatialMixOptions = { nearFieldDistance?: number; nearFieldGain?: number; nearFieldCenterPan?: boolean; + directional?: { + enabled: boolean; + facingDeg: number; + coneDeg?: number; + rearGain?: number; + }; }; 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 }; } + +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; +} diff --git a/client/src/items/itemRegistry.ts b/client/src/items/itemRegistry.ts index 8a450e9..3de4ab3 100644 --- a/client/src/items/itemRegistry.ts +++ b/client/src/items/itemRegistry.ts @@ -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_EDITABLE_PROPERTIES: Record = { - radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue'], + radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing'], dice: ['title', 'sides', 'number'], wheel: ['title', 'spaces'], clock: ['title', 'timeZone', 'use24Hour'], }; const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record> = { - radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000 }, - dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000 }, - wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000 }, - clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000 }, + radio_station: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 20, directional: true }, + dice: { useSound: 'sounds/roll.ogg', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false }, + wheel: { useSound: 'sounds/spin.ogg', emitSound: 'none', useCooldownMs: 4000, emitRange: 15, directional: false }, + clock: { useSound: 'none', emitSound: 'sounds/clock.ogg', useCooldownMs: 1000, emitRange: 10, directional: false }, }; type UiDefinitionsPayload = { diff --git a/client/src/main.ts b/client/src/main.ts index b81923d..1a0a69b 100644 --- a/client/src/main.ts +++ b/client/src/main.ts @@ -8,6 +8,7 @@ import { } from './audio/effects'; import { RadioStationRuntime, normalizeRadioChannel, normalizeRadioEffect, normalizeRadioEffectValue } from './audio/radioStationRuntime'; import { ItemEmitRuntime } from './audio/itemEmitRuntime'; +import { normalizeDegrees } from './audio/spatial'; import { applyPastedText, applyTextInput, @@ -175,8 +176,8 @@ let outputMode = localStorage.getItem(AUDIO_OUTPUT_MODE_STORAGE_KEY) === 'mono' let connecting = false; const messageBuffer: string[] = []; let messageCursor = -1; -const radioRuntime = new RadioStationRuntime(audio); -const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl); +const radioRuntime = new RadioStationRuntime(audio, getItemSpatialConfig); +const itemEmitRuntime = new ItemEmitRuntime(audio, resolveIncomingSoundUrl, getItemSpatialConfig); let internalClipboardText = ''; let replaceTextOnNextType = false; let pendingEscapeDisconnect = false; @@ -522,6 +523,16 @@ function itemLabel(item: WorldItem): string { 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 { if (helpViewerLines.length === 0) { updateStatus('Help unavailable.'); @@ -697,6 +708,11 @@ function getItemPropertyValue(item: WorldItem, key: string): string { 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 === '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]; if (globalValue !== undefined) return String(globalValue); return String(item.params[key] ?? ''); @@ -1984,6 +2000,14 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo return; } 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') { const spaces = value .split(',') diff --git a/docs/item-schema.md b/docs/item-schema.md index ad7aeb2..d0952b5 100644 --- a/docs/item-schema.md +++ b/docs/item-schema.md @@ -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. - `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. +- `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`) @@ -61,7 +63,8 @@ "channel": "stereo", "volume": 50, "effect": "off", - "effectValue": 50 + "effectValue": 50, + "facing": 0 } ``` @@ -72,6 +75,7 @@ - `channel`: one of `stereo | mono | left | right`, default `stereo`. - `effect`: one of `reverb | echo | flanger | high_pass | low_pass | off`, default `off`. - `effectValue`: number, range `0-100`, precision `0.1`. +- `facing`: number, range `0-360`, precision `0.1` (used when `directional=true`). ### `dice` diff --git a/docs/item-types.md b/docs/item-types.md index 015d207..a920c26 100644 --- a/docs/item-types.md +++ b/docs/item-types.md @@ -10,6 +10,8 @@ This is behavior-focused documentation for item types and their defaults. - `useSound` - `emitSound` - `useCooldownMs` (from item catalog) + - `emitRange` (spatial range in squares) + - `directional` (directional attenuation enabled) - Instance fields are persisted in `server/runtime/items.json`. ## `radio_station` @@ -23,10 +25,13 @@ This is behavior-focused documentation for item types and their defaults. - `volume=50` - `effect="off"` - `effectValue=50` + - `facing=0` - Global: - `useSound=none` - `emitSound=none` - `useCooldownMs=1000` + - `emitRange=20` + - `directional=true` ### Use - `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` - `effect`: `reverb | echo | flanger | high_pass | low_pass | off` - `effectValue`: number `0..100` with `0.1` precision +- `facing`: number `0..360` with `0.1` precision ## `dice` @@ -48,6 +54,8 @@ This is behavior-focused documentation for item types and their defaults. - `useSound=sounds/roll.ogg` - `emitSound=none` - `useCooldownMs=1000` + - `emitRange=15` + - `directional=false` ### Use - 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` - `emitSound=none` - `useCooldownMs=4000` + - `emitRange=15` + - `directional=false` ### Use - Announces spin immediately. @@ -88,6 +98,8 @@ This is behavior-focused documentation for item types and their defaults. - `useSound=none` - `emitSound=sounds/clock.ogg` - `useCooldownMs=1000` + - `emitRange=10` + - `directional=false` ### Use - Reports current time from item timezone and format. diff --git a/server/app/item_catalog.py b/server/app/item_catalog.py index 63dee7b..b933bfb 100644 --- a/server/app/item_catalog.py +++ b/server/app/item_catalog.py @@ -16,7 +16,7 @@ ITEM_TYPE_LABELS: dict[ItemType, str] = { RADIO_EFFECT_OPTIONS: tuple[str, ...] = ("reverb", "echo", "flanger", "high_pass", "low_pass", "off") RADIO_CHANNEL_OPTIONS: tuple[str, ...] = ("stereo", "mono", "left", "right") 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"), "wheel": ("title", "spaces"), "clock": ("title", "timeZone", "use24Hour"), @@ -76,6 +76,8 @@ class ItemDefinition: emit_sound: str | None default_params: dict use_cooldown_ms: int = 1000 + emit_range: int = 15 + directional: bool = False ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { @@ -84,7 +86,9 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { capabilities=("editable", "carryable", "deletable", "usable"), use_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( default_title="Dice", @@ -107,6 +111,7 @@ ITEM_DEFINITIONS: dict[ItemType, ItemDefinition] = { use_sound=None, emit_sound="sounds/clock.ogg", 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 -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.""" 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", "emitSound": definition.emit_sound or "none", "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), } diff --git a/server/app/item_type_handlers.py b/server/app/item_type_handlers.py index 83c6bb5..3e02817 100644 --- a/server/app/item_type_handlers.py +++ b/server/app/item_type_handlers.py @@ -104,6 +104,14 @@ def _validate_radio_update(item: WorldItem, next_params: dict) -> dict: if not (0 <= effect_value <= 100): raise ValueError("effectValue must be between 0 and 100.") 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 diff --git a/server/tests/test_item_use_cooldown.py b/server/tests/test_item_use_cooldown.py index 0130a2f..56dbf9e 100644 --- a/server/tests/test_item_use_cooldown.py +++ b/server/tests/test_item_use_cooldown.py @@ -118,6 +118,20 @@ async def test_radio_channel_update_validates(monkeypatch: pytest.MonkeyPatch) - assert send_payloads[-1].ok is False 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 async def test_clock_use_reports_time_without_use_sound_packet(monkeypatch: pytest.MonkeyPatch) -> None: