Add widget item type with editable sound and spatial controls

This commit is contained in:
Jage9
2026-02-21 22:20:15 -05:00
parent 97caaef001
commit bb36a007e2
16 changed files with 309 additions and 27 deletions

View File

@@ -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 R125";
window.CHGRID_WEB_VERSION = "2026.02.21 R126";
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
window.CHGRID_TIME_ZONE = "America/Detroit";

View File

@@ -65,7 +65,9 @@ export class ItemEmitRuntime {
if (!audioCtx) return;
for (const item of items) {
const soundUrl = this.resolveSoundUrl(String(item.emitSound ?? '').trim());
const emitSound = String(item.params.emitSound ?? item.emitSound ?? '').trim();
const enabled = item.params.enabled !== false;
const soundUrl = enabled ? this.resolveSoundUrl(emitSound) : '';
if (!soundUrl || item.carrierId) {
this.cleanup(item.id);
continue;

View File

@@ -45,13 +45,14 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
'UTC',
] as const;
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
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', 'volume', 'effect', 'effectValue', 'facing', 'emitRange'],
dice: ['title', 'sides', 'number'],
wheel: ['title', 'spaces'],
clock: ['title', 'timeZone', 'use24Hour'],
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'useSound', 'emitSound'],
};
const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, string | number | boolean>> = {
@@ -59,6 +60,7 @@ const DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES: Record<ItemType, Record<string, strin
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 },
widget: { useSound: 'none', emitSound: 'none', useCooldownMs: 1000, emitRange: 15, directional: false },
};
export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound';
@@ -92,6 +94,7 @@ let itemTypeLabels: Record<ItemType, string> = {
dice: 'dice',
wheel: 'wheel',
clock: 'clock',
widget: 'widget',
};
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
let itemTypeEditableProperties: Record<ItemType, string[]> = {
@@ -99,12 +102,14 @@ let itemTypeEditableProperties: Record<ItemType, string[]> = {
dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice],
wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel],
clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock],
widget: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.widget],
};
let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number | boolean>> = {
radio_station: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.radio_station },
dice: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.dice },
wheel: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.wheel },
clock: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.clock },
widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget },
};
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
@@ -188,6 +193,8 @@ export function itemTypeLabel(type: ItemType): string {
export function itemPropertyLabel(key: string): string {
if (key === 'use24Hour') return 'use 24 hour format';
if (key === 'emitRange') return 'emit range';
if (key === 'useSound') return 'use sound';
if (key === 'emitSound') return 'emit sound';
return key;
}

View File

@@ -531,7 +531,7 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo
const rawGlobalRange = Number(global.emitRange);
const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange;
const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15;
const directional = global.directional === true;
const directional = typeof item.params.directional === 'boolean' ? item.params.directional : global.directional === true;
const rawFacing = Number(item.params.facing ?? 0);
const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0;
return { range, directional, facingDeg };
@@ -694,6 +694,14 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe
}
function getItemPropertyValue(item: WorldItem, key: string): string {
const toSoundDisplayName = (rawValue: unknown): string => {
const raw = String(rawValue ?? '').trim();
if (!raw) return 'none';
if (raw.toLowerCase() === 'none') return 'none';
const withoutQuery = raw.split('?')[0].split('#')[0];
const segments = withoutQuery.split('/').filter((part) => part.length > 0);
return segments[segments.length - 1] ?? raw;
};
if (key === 'title') return item.title;
if (key === 'type') return item.type;
if (key === 'x') return String(item.x);
@@ -704,9 +712,15 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
if (key === 'createdAt') return formatTimestampMs(item.createdAt);
if (key === 'updatedAt') return formatTimestampMs(item.updatedAt);
if (key === 'capabilities') return item.capabilities.join(', ') || 'none';
if (key === 'useSound') return item.useSound ?? 'none';
if (key === 'emitSound') return item.emitSound ?? 'none';
if (key === 'useSound') return toSoundDisplayName(item.params.useSound ?? item.useSound);
if (key === 'emitSound') return toSoundDisplayName(item.params.emitSound ?? item.emitSound);
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on';
if (key === 'directional') {
if (typeof item.params.directional === 'boolean') {
return item.params.directional ? 'on' : 'off';
}
return getItemTypeGlobalProperties(item.type).directional === true ? 'on' : 'off';
}
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);
@@ -2033,6 +2047,13 @@ function handleItemPropertiesModeInput(code: string, key: string): void {
audio.sfxUiBlip();
return;
}
if (key === 'directional') {
const nextDirectional = item.params.directional !== true;
signaling.send({ type: 'item_update', itemId, params: { directional: nextDirectional } });
updateStatus(`directional: ${nextDirectional ? 'on' : 'off'}`);
audio.sfxUiBlip();
return;
}
if (key === 'use24Hour') {
const nextUse24Hour = item.params.use24Hour !== true;
signaling.send({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } });
@@ -2108,6 +2129,15 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
}
const enabled = ['on', 'true', '1', 'yes'].includes(normalized);
signaling.send({ type: 'item_update', itemId, params: { enabled } });
} else if (propertyKey === 'directional') {
const normalized = value.toLowerCase();
if (!['on', 'off', 'true', 'false', '1', '0', 'yes', 'no'].includes(normalized)) {
updateStatus('directional must be on or off.');
audio.sfxUiCancel();
return;
}
const directional = ['on', 'true', '1', 'yes'].includes(normalized);
signaling.send({ type: 'item_update', itemId, params: { directional } });
} else if (propertyKey === 'volume') {
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true);
if (!parsed.ok) {
@@ -2148,6 +2178,8 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
return;
}
signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed.value } });
} else if (propertyKey === 'useSound' || propertyKey === 'emitSound') {
signaling.send({ type: 'item_update', itemId, params: { [propertyKey]: value } });
} else if (propertyKey === 'spaces') {
const spaces = value
.split(',')

View File

@@ -2,7 +2,7 @@ import { z } from 'zod';
export const itemSchema = z.object({
id: z.string(),
type: z.enum(['radio_station', 'dice', 'wheel', 'clock']),
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']),
title: z.string(),
x: z.number().int(),
y: z.number().int(),
@@ -36,10 +36,10 @@ export const welcomeMessageSchema = z.object({
.optional(),
uiDefinitions: z
.object({
itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock'])),
itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget'])),
itemTypes: z.array(
z.object({
type: z.enum(['radio_station', 'dice', 'wheel', 'clock']),
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']),
label: z.string().optional(),
tooltip: z.string().optional(),
editableProperties: z.array(z.string()),
@@ -166,7 +166,7 @@ export type OutgoingMessage =
| { type: 'update_nickname'; nickname: string }
| { type: 'chat_message'; message: string }
| { type: 'ping'; clientSentAt: number }
| { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' }
| { type: 'item_add'; itemType: 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget' }
| { type: 'item_pickup'; itemId: string }
| { type: 'item_drop'; itemId: string; x: number; y: number }
| { type: 'item_delete'; itemId: string }

View File

@@ -91,13 +91,23 @@ export class CanvasRenderer {
? '#f97316'
: item.type === 'clock'
? '#86efac'
: '#60a5fa';
: item.type === 'widget'
? '#22d3ee'
: '#60a5fa';
this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize);
this.ctx.fillStyle = '#111827';
this.ctx.font = 'bold 12px Courier New';
this.ctx.textAlign = 'center';
this.ctx.fillText(
item.type === 'radio_station' ? 'R' : item.type === 'wheel' ? 'W' : item.type === 'clock' ? 'C' : 'D',
item.type === 'radio_station'
? 'R'
: item.type === 'wheel'
? 'W'
: item.type === 'clock'
? 'C'
: item.type === 'widget'
? 'B'
: 'D',
drawX + this.squarePixelSize / 2,
drawY + 13,
);

View File

@@ -2,7 +2,7 @@ export const GRID_SIZE = 41;
export const HEARING_RADIUS = 15;
export const MOVE_COOLDOWN_MS = 200;
export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock';
export type ItemType = 'radio_station' | 'dice' | 'wheel' | 'clock' | 'widget';
export type WorldItem = {
id: string;