Add widget item type with editable sound and spatial controls
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 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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(',')
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user