Add widget item type with editable sound and spatial controls
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// 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.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ export class ItemEmitRuntime {
|
|||||||
if (!audioCtx) return;
|
if (!audioCtx) return;
|
||||||
|
|
||||||
for (const item of items) {
|
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) {
|
if (!soundUrl || item.carrierId) {
|
||||||
this.cleanup(item.id);
|
this.cleanup(item.id);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
|
|||||||
'UTC',
|
'UTC',
|
||||||
] as const;
|
] 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[]> = {
|
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
||||||
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing', 'emitRange'],
|
radio_station: ['title', 'streamUrl', 'enabled', 'channel', 'volume', 'effect', 'effectValue', 'facing', 'emitRange'],
|
||||||
dice: ['title', 'sides', 'number'],
|
dice: ['title', 'sides', 'number'],
|
||||||
wheel: ['title', 'spaces'],
|
wheel: ['title', 'spaces'],
|
||||||
clock: ['title', 'timeZone', 'use24Hour'],
|
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>> = {
|
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 },
|
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 },
|
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 },
|
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';
|
export type ItemPropertyValueType = 'boolean' | 'text' | 'number' | 'list' | 'sound';
|
||||||
@@ -92,6 +94,7 @@ let itemTypeLabels: Record<ItemType, string> = {
|
|||||||
dice: 'dice',
|
dice: 'dice',
|
||||||
wheel: 'wheel',
|
wheel: 'wheel',
|
||||||
clock: 'clock',
|
clock: 'clock',
|
||||||
|
widget: 'widget',
|
||||||
};
|
};
|
||||||
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
|
let itemTypeTooltips: Partial<Record<ItemType, string>> = {};
|
||||||
let itemTypeEditableProperties: Record<ItemType, string[]> = {
|
let itemTypeEditableProperties: Record<ItemType, string[]> = {
|
||||||
@@ -99,12 +102,14 @@ let itemTypeEditableProperties: Record<ItemType, string[]> = {
|
|||||||
dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice],
|
dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice],
|
||||||
wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel],
|
wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel],
|
||||||
clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock],
|
clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock],
|
||||||
|
widget: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.widget],
|
||||||
};
|
};
|
||||||
let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number | boolean>> = {
|
let itemTypeGlobalProperties: Record<ItemType, Record<string, string | number | boolean>> = {
|
||||||
radio_station: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.radio_station },
|
radio_station: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.radio_station },
|
||||||
dice: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.dice },
|
dice: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.dice },
|
||||||
wheel: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.wheel },
|
wheel: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.wheel },
|
||||||
clock: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.clock },
|
clock: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.clock },
|
||||||
|
widget: { ...DEFAULT_ITEM_TYPE_GLOBAL_PROPERTIES.widget },
|
||||||
};
|
};
|
||||||
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
|
||||||
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
|
||||||
@@ -188,6 +193,8 @@ export function itemTypeLabel(type: ItemType): string {
|
|||||||
export function itemPropertyLabel(key: string): string {
|
export function itemPropertyLabel(key: string): string {
|
||||||
if (key === 'use24Hour') return 'use 24 hour format';
|
if (key === 'use24Hour') return 'use 24 hour format';
|
||||||
if (key === 'emitRange') return 'emit range';
|
if (key === 'emitRange') return 'emit range';
|
||||||
|
if (key === 'useSound') return 'use sound';
|
||||||
|
if (key === 'emitSound') return 'emit sound';
|
||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo
|
|||||||
const rawGlobalRange = Number(global.emitRange);
|
const rawGlobalRange = Number(global.emitRange);
|
||||||
const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange;
|
const rawRange = Number.isFinite(rawParamRange) && rawParamRange > 0 ? rawParamRange : rawGlobalRange;
|
||||||
const range = Number.isFinite(rawRange) && rawRange > 0 ? rawRange : 15;
|
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 rawFacing = Number(item.params.facing ?? 0);
|
||||||
const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0;
|
const facingDeg = Number.isFinite(rawFacing) ? normalizeDegrees(rawFacing) : 0;
|
||||||
return { range, directional, facingDeg };
|
return { range, directional, facingDeg };
|
||||||
@@ -694,6 +694,14 @@ function applyTextInputEdit(code: string, key: string, maxLength: number, ctrlKe
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getItemPropertyValue(item: WorldItem, key: string): string {
|
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 === 'title') return item.title;
|
||||||
if (key === 'type') return item.type;
|
if (key === 'type') return item.type;
|
||||||
if (key === 'x') return String(item.x);
|
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 === 'createdAt') return formatTimestampMs(item.createdAt);
|
||||||
if (key === 'updatedAt') return formatTimestampMs(item.updatedAt);
|
if (key === 'updatedAt') return formatTimestampMs(item.updatedAt);
|
||||||
if (key === 'capabilities') return item.capabilities.join(', ') || 'none';
|
if (key === 'capabilities') return item.capabilities.join(', ') || 'none';
|
||||||
if (key === 'useSound') return item.useSound ?? 'none';
|
if (key === 'useSound') return toSoundDisplayName(item.params.useSound ?? item.useSound);
|
||||||
if (key === 'emitSound') return item.emitSound ?? 'none';
|
if (key === 'emitSound') return toSoundDisplayName(item.params.emitSound ?? item.emitSound);
|
||||||
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on';
|
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 === 'timeZone') return String(item.params.timeZone ?? getDefaultClockTimeZone());
|
||||||
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
|
if (key === 'use24Hour') return item.params.use24Hour === true ? 'on' : 'off';
|
||||||
if (key === 'channel') return normalizeRadioChannel(item.params.channel);
|
if (key === 'channel') return normalizeRadioChannel(item.params.channel);
|
||||||
@@ -2033,6 +2047,13 @@ function handleItemPropertiesModeInput(code: string, key: string): void {
|
|||||||
audio.sfxUiBlip();
|
audio.sfxUiBlip();
|
||||||
return;
|
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') {
|
if (key === 'use24Hour') {
|
||||||
const nextUse24Hour = item.params.use24Hour !== true;
|
const nextUse24Hour = item.params.use24Hour !== true;
|
||||||
signaling.send({ type: 'item_update', itemId, params: { use24Hour: nextUse24Hour } });
|
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);
|
const enabled = ['on', 'true', '1', 'yes'].includes(normalized);
|
||||||
signaling.send({ type: 'item_update', itemId, params: { enabled } });
|
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') {
|
} else if (propertyKey === 'volume') {
|
||||||
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true);
|
const parsed = validateNumericItemPropertyInput(item, propertyKey, value, true);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
@@ -2148,6 +2178,8 @@ function handleItemPropertyEditModeInput(code: string, key: string, ctrlKey: boo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
signaling.send({ type: 'item_update', itemId, params: { emitRange: parsed.value } });
|
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') {
|
} else if (propertyKey === 'spaces') {
|
||||||
const spaces = value
|
const spaces = value
|
||||||
.split(',')
|
.split(',')
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
export const itemSchema = z.object({
|
export const itemSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
type: z.enum(['radio_station', 'dice', 'wheel', 'clock']),
|
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']),
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
x: z.number().int(),
|
x: z.number().int(),
|
||||||
y: z.number().int(),
|
y: z.number().int(),
|
||||||
@@ -36,10 +36,10 @@ export const welcomeMessageSchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
uiDefinitions: z
|
uiDefinitions: z
|
||||||
.object({
|
.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(
|
itemTypes: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.enum(['radio_station', 'dice', 'wheel', 'clock']),
|
type: z.enum(['radio_station', 'dice', 'wheel', 'clock', 'widget']),
|
||||||
label: z.string().optional(),
|
label: z.string().optional(),
|
||||||
tooltip: z.string().optional(),
|
tooltip: z.string().optional(),
|
||||||
editableProperties: z.array(z.string()),
|
editableProperties: z.array(z.string()),
|
||||||
@@ -166,7 +166,7 @@ export type OutgoingMessage =
|
|||||||
| { type: 'update_nickname'; nickname: string }
|
| { type: 'update_nickname'; nickname: string }
|
||||||
| { type: 'chat_message'; message: string }
|
| { type: 'chat_message'; message: string }
|
||||||
| { type: 'ping'; clientSentAt: number }
|
| { 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_pickup'; itemId: string }
|
||||||
| { type: 'item_drop'; itemId: string; x: number; y: number }
|
| { type: 'item_drop'; itemId: string; x: number; y: number }
|
||||||
| { type: 'item_delete'; itemId: string }
|
| { type: 'item_delete'; itemId: string }
|
||||||
|
|||||||
@@ -91,13 +91,23 @@ export class CanvasRenderer {
|
|||||||
? '#f97316'
|
? '#f97316'
|
||||||
: item.type === 'clock'
|
: item.type === 'clock'
|
||||||
? '#86efac'
|
? '#86efac'
|
||||||
: '#60a5fa';
|
: item.type === 'widget'
|
||||||
|
? '#22d3ee'
|
||||||
|
: '#60a5fa';
|
||||||
this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize);
|
this.ctx.fillRect(drawX, drawY, this.squarePixelSize, this.squarePixelSize);
|
||||||
this.ctx.fillStyle = '#111827';
|
this.ctx.fillStyle = '#111827';
|
||||||
this.ctx.font = 'bold 12px Courier New';
|
this.ctx.font = 'bold 12px Courier New';
|
||||||
this.ctx.textAlign = 'center';
|
this.ctx.textAlign = 'center';
|
||||||
this.ctx.fillText(
|
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,
|
drawX + this.squarePixelSize / 2,
|
||||||
drawY + 13,
|
drawY + 13,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ export const GRID_SIZE = 41;
|
|||||||
export const HEARING_RADIUS = 15;
|
export const HEARING_RADIUS = 15;
|
||||||
export const MOVE_COOLDOWN_MS = 200;
|
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 = {
|
export type WorldItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "string",
|
"id": "string",
|
||||||
"type": "radio_station | dice | wheel | clock",
|
"type": "radio_station | dice | wheel | clock | widget",
|
||||||
"title": "string",
|
"title": "string",
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -24,17 +24,17 @@
|
|||||||
- `useSound`: optional client-played one-shot sound when item `use` succeeds; global item field and not user-editable in V1.
|
- `useSound`: optional client-played one-shot sound when item `use` succeeds; 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.
|
- `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`, `widget=1000`), not per-instance editable.
|
||||||
- `emitRange`: global spatial range default per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`).
|
- `emitRange`: global spatial range default per item type (`radio_station=20`, `dice=15`, `wheel=15`, `clock=10`, `widget=15`).
|
||||||
- `radio_station` can override this per instance via `params.emitRange` (`5..20`).
|
- `radio_station` can override this per instance via `params.emitRange` (`5..20`).
|
||||||
- `directional`: global directional attenuation flag per item type (`radio_station=true`, others `false`), not per-instance editable.
|
- `directional`: global directional attenuation flag per item type (`radio_station=true`, others `false`); `widget` can override per instance via `params.directional`.
|
||||||
|
|
||||||
## Persisted Item State (`server/runtime/items.json`)
|
## Persisted Item State (`server/runtime/items.json`)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "string",
|
"id": "string",
|
||||||
"type": "radio_station | dice | wheel | clock",
|
"type": "radio_station | dice | wheel | clock | widget",
|
||||||
"title": "string",
|
"title": "string",
|
||||||
"x": 0,
|
"x": 0,
|
||||||
"y": 0,
|
"y": 0,
|
||||||
@@ -128,6 +128,26 @@
|
|||||||
- `use24Hour`: boolean (or `on/off` in updates), default `false`.
|
- `use24Hour`: boolean (or `on/off` in updates), default `false`.
|
||||||
- Global defaults: `useSound=none`, `emitSound=sounds/clock.ogg`.
|
- Global defaults: `useSound=none`, `emitSound=sounds/clock.ogg`.
|
||||||
|
|
||||||
|
### `widget`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"directional": false,
|
||||||
|
"facing": 0,
|
||||||
|
"emitRange": 15,
|
||||||
|
"useSound": "",
|
||||||
|
"emitSound": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `enabled`: boolean (or `on/off` in updates), default `true`.
|
||||||
|
- `directional`: boolean (or `on/off` in updates), default `false`.
|
||||||
|
- `facing`: number, range `0-360`, precision `0.1`.
|
||||||
|
- `emitRange`: integer, range `1-20`, default `15`.
|
||||||
|
- `useSound`: empty, filename (assumed under `sounds/`), or full URL.
|
||||||
|
- `emitSound`: empty, filename (assumed under `sounds/`), or full URL.
|
||||||
|
|
||||||
## Packet Shapes
|
## Packet Shapes
|
||||||
|
|
||||||
- `item_upsert`:
|
- `item_upsert`:
|
||||||
|
|||||||
@@ -110,6 +110,35 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py`
|
- `timeZone`: one of `CLOCK_TIME_ZONE_OPTIONS` in `server/app/item_catalog.py`
|
||||||
- `use24Hour`: boolean or on/off style input
|
- `use24Hour`: boolean or on/off style input
|
||||||
|
|
||||||
|
## `widget`
|
||||||
|
|
||||||
|
### Defaults
|
||||||
|
- Title: `widget`
|
||||||
|
- Params:
|
||||||
|
- `enabled=true`
|
||||||
|
- `directional=false`
|
||||||
|
- `facing=0`
|
||||||
|
- `emitRange=15`
|
||||||
|
- `useSound=""`
|
||||||
|
- `emitSound=""`
|
||||||
|
- Global:
|
||||||
|
- `useSound=none`
|
||||||
|
- `emitSound=none`
|
||||||
|
- `useCooldownMs=1000`
|
||||||
|
- `emitRange=15`
|
||||||
|
- `directional=false`
|
||||||
|
|
||||||
|
### Use
|
||||||
|
- `use` toggles `enabled` on/off and plays `useSound` when configured.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- `enabled`: boolean or on/off style input
|
||||||
|
- `directional`: boolean or on/off style input
|
||||||
|
- `facing`: number `0..360` with `0.1` precision
|
||||||
|
- `emitRange`: integer `1..20`
|
||||||
|
- `useSound`: empty, filename (assumed under `sounds/`), or full URL
|
||||||
|
- `emitSound`: empty, filename (assumed under `sounds/`), or full URL
|
||||||
|
|
||||||
## Adding A New Item Type (Registry V1)
|
## Adding A New Item Type (Registry V1)
|
||||||
|
|
||||||
Item types are currently code-registered on both server and client. Server item logic is split per item module and wired through one registry.
|
Item types are currently code-registered on both server and client. Server item logic is split per item module and wired through one registry.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from typing import Literal, cast
|
|||||||
from .items import clock, radio
|
from .items import clock, radio
|
||||||
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
|
from .items.registry import ITEM_MODULES, ITEM_TYPE_ORDER
|
||||||
|
|
||||||
ItemType = Literal["radio_station", "dice", "wheel", "clock"]
|
ItemType = Literal["radio_station", "dice", "wheel", "clock", "widget"]
|
||||||
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER)
|
ITEM_TYPE_SEQUENCE: tuple[ItemType, ...] = cast(tuple[ItemType, ...], ITEM_TYPE_ORDER)
|
||||||
ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE}
|
ITEM_TYPE_LABELS: dict[ItemType, str] = {item_type: ITEM_MODULES[item_type].LABEL for item_type in ITEM_TYPE_SEQUENCE}
|
||||||
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
|
ITEM_TYPE_EDITABLE_PROPERTIES: dict[ItemType, tuple[str, ...]] = {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class ItemService:
|
|||||||
|
|
||||||
return int(time.time() * 1000)
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock"]) -> WorldItem:
|
def default_item(self, client: ClientConnection, item_type: Literal["radio_station", "dice", "wheel", "clock", "widget"]) -> WorldItem:
|
||||||
"""Create a new server-authoritative item at the caller's position."""
|
"""Create a new server-authoritative item at the caller's position."""
|
||||||
|
|
||||||
item_def = get_item_definition(item_type)
|
item_def = get_item_definition(item_type)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from typing import Callable, Protocol
|
|||||||
from ..item_types import ItemUseResult
|
from ..item_types import ItemUseResult
|
||||||
from ..models import WorldItem
|
from ..models import WorldItem
|
||||||
|
|
||||||
from . import clock, dice, radio, wheel
|
from . import clock, dice, radio, wheel, widget
|
||||||
|
|
||||||
|
|
||||||
class ItemModule(Protocol):
|
class ItemModule(Protocol):
|
||||||
@@ -29,11 +29,12 @@ class ItemModule(Protocol):
|
|||||||
use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
|
use_item: Callable[[WorldItem, str, Callable[[dict], str]], ItemUseResult]
|
||||||
|
|
||||||
|
|
||||||
ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "radio_station", "wheel")
|
ITEM_TYPE_ORDER: tuple[str, ...] = ("clock", "dice", "radio_station", "wheel", "widget")
|
||||||
|
|
||||||
ITEM_MODULES: dict[str, ItemModule] = {
|
ITEM_MODULES: dict[str, ItemModule] = {
|
||||||
"clock": clock,
|
"clock": clock,
|
||||||
"dice": dice,
|
"dice": dice,
|
||||||
"radio_station": radio,
|
"radio_station": radio,
|
||||||
"wheel": wheel,
|
"wheel": wheel,
|
||||||
|
"widget": widget,
|
||||||
}
|
}
|
||||||
|
|||||||
107
server/app/items/widget.py
Normal file
107
server/app/items/widget.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
"""Widget item schema metadata and behavior."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from ..item_types import ItemUseResult
|
||||||
|
from ..models import WorldItem
|
||||||
|
from .helpers import parse_bool_like, toggle_bool_param
|
||||||
|
|
||||||
|
LABEL = "widget"
|
||||||
|
TOOLTIP = "A basic item. Make it a beacon or whatever you want."
|
||||||
|
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "enabled", "directional", "facing", "emitRange", "useSound", "emitSound")
|
||||||
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
|
USE_SOUND: str | None = None
|
||||||
|
EMIT_SOUND: str | None = None
|
||||||
|
USE_COOLDOWN_MS = 1000
|
||||||
|
EMIT_RANGE = 15
|
||||||
|
DIRECTIONAL = False
|
||||||
|
DEFAULT_TITLE = "widget"
|
||||||
|
DEFAULT_PARAMS: dict = {
|
||||||
|
"enabled": True,
|
||||||
|
"directional": False,
|
||||||
|
"facing": 0,
|
||||||
|
"emitRange": 15,
|
||||||
|
"useSound": "",
|
||||||
|
"emitSound": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
|
"title": {"valueType": "text", "tooltip": "Display name spoken and shown for this item."},
|
||||||
|
"enabled": {"valueType": "boolean", "tooltip": "Turns this widget on or off."},
|
||||||
|
"directional": {"valueType": "boolean", "tooltip": "If on, emitted sound favors the facing direction."},
|
||||||
|
"facing": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Facing direction in degrees used when directional is on.",
|
||||||
|
"range": {"min": 0, "max": 360, "step": 0.1},
|
||||||
|
},
|
||||||
|
"emitRange": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Maximum distance in squares for emitted sound.",
|
||||||
|
"range": {"min": 1, "max": 20, "step": 1},
|
||||||
|
},
|
||||||
|
"useSound": {"valueType": "sound", "tooltip": "Sound played on use. Filename assumes sounds folder, or use full URL."},
|
||||||
|
"emitSound": {"valueType": "sound", "tooltip": "Looping emitted sound. Filename assumes sounds folder, or use full URL."},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_sound_value(raw: object) -> str:
|
||||||
|
"""Normalize sound value to empty/URL/or sounds-relative path."""
|
||||||
|
|
||||||
|
token = str(raw or "").strip()
|
||||||
|
if not token:
|
||||||
|
return ""
|
||||||
|
lowered = token.lower()
|
||||||
|
if lowered in {"none", "off"}:
|
||||||
|
return ""
|
||||||
|
if lowered.startswith(("http://", "https://", "data:", "blob:")):
|
||||||
|
return token
|
||||||
|
if token.startswith("/sounds/"):
|
||||||
|
return token[1:]
|
||||||
|
if token.startswith("sounds/"):
|
||||||
|
return token
|
||||||
|
if "/" not in token:
|
||||||
|
return f"sounds/{token}"
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def validate_update(item: WorldItem, next_params: dict) -> dict:
|
||||||
|
"""Validate and normalize widget params."""
|
||||||
|
|
||||||
|
enabled = parse_bool_like(next_params.get("enabled", item.params.get("enabled", True)), default=True)
|
||||||
|
directional = parse_bool_like(next_params.get("directional", item.params.get("directional", False)), default=False)
|
||||||
|
next_params["enabled"] = enabled
|
||||||
|
next_params["directional"] = directional
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
emit_range = int(next_params.get("emitRange", item.params.get("emitRange", 15)))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("emitRange must be an integer between 1 and 20.") from exc
|
||||||
|
if not (1 <= emit_range <= 20):
|
||||||
|
raise ValueError("emitRange must be between 1 and 20.")
|
||||||
|
next_params["emitRange"] = emit_range
|
||||||
|
|
||||||
|
next_params["useSound"] = _normalize_sound_value(next_params.get("useSound", item.params.get("useSound", "")))
|
||||||
|
next_params["emitSound"] = _normalize_sound_value(next_params.get("emitSound", item.params.get("emitSound", "")))
|
||||||
|
return next_params
|
||||||
|
|
||||||
|
|
||||||
|
def use_item(item: WorldItem, nickname: str, _clock_formatter: Callable[[dict], str]) -> ItemUseResult:
|
||||||
|
"""Toggle enabled state for widget."""
|
||||||
|
|
||||||
|
next_enabled = toggle_bool_param(item.params, "enabled", default=True)
|
||||||
|
state_text = "on" if next_enabled else "off"
|
||||||
|
return ItemUseResult(
|
||||||
|
self_message=f"You turn {state_text} {item.title}.",
|
||||||
|
others_message=f"{nickname} turns {state_text} {item.title}.",
|
||||||
|
updated_params={**item.params, "enabled": next_enabled},
|
||||||
|
)
|
||||||
@@ -42,7 +42,7 @@ class PingPacket(BasePacket):
|
|||||||
|
|
||||||
class ItemAddPacket(BasePacket):
|
class ItemAddPacket(BasePacket):
|
||||||
type: Literal["item_add"]
|
type: Literal["item_add"]
|
||||||
itemType: Literal["radio_station", "dice", "wheel", "clock"]
|
itemType: Literal["radio_station", "dice", "wheel", "clock", "widget"]
|
||||||
|
|
||||||
|
|
||||||
class ItemPickupPacket(BasePacket):
|
class ItemPickupPacket(BasePacket):
|
||||||
@@ -156,7 +156,7 @@ class NicknameResultPacket(BasePacket):
|
|||||||
|
|
||||||
class WorldItem(BaseModel):
|
class WorldItem(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
type: Literal["radio_station", "dice", "wheel", "clock"]
|
type: Literal["radio_station", "dice", "wheel", "clock", "widget"]
|
||||||
title: str
|
title: str
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
@@ -174,7 +174,7 @@ class WorldItem(BaseModel):
|
|||||||
class PersistedWorldItem(BaseModel):
|
class PersistedWorldItem(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore")
|
model_config = ConfigDict(extra="ignore")
|
||||||
id: str
|
id: str
|
||||||
type: Literal["radio_station", "dice", "wheel", "clock"]
|
type: Literal["radio_station", "dice", "wheel", "clock", "widget"]
|
||||||
title: str
|
title: str
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
|
|||||||
@@ -117,6 +117,20 @@ class SignalingServer:
|
|||||||
|
|
||||||
return "radio" if item.type == "radio_station" else item.type
|
return "radio" if item.type == "radio_station" else item.type
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_item_use_sound(item: WorldItem) -> str | None:
|
||||||
|
"""Resolve one-shot use sound, preferring per-item param override."""
|
||||||
|
|
||||||
|
param_sound = item.params.get("useSound")
|
||||||
|
if isinstance(param_sound, str):
|
||||||
|
token = param_sound.strip()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
return None
|
||||||
|
if isinstance(item.useSound, str) and item.useSound.strip():
|
||||||
|
return item.useSound.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
def _is_in_bounds(self, x: int, y: int) -> bool:
|
def _is_in_bounds(self, x: int, y: int) -> bool:
|
||||||
"""Check whether a coordinate is inside server-authoritative world bounds."""
|
"""Check whether a coordinate is inside server-authoritative world bounds."""
|
||||||
|
|
||||||
@@ -590,12 +604,13 @@ class SignalingServer:
|
|||||||
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
|
BroadcastChatMessagePacket(type="chat_message", message=use_result.others_message, system=True),
|
||||||
exclude=client.websocket,
|
exclude=client.websocket,
|
||||||
)
|
)
|
||||||
if item.useSound:
|
use_sound = self._resolve_item_use_sound(item)
|
||||||
|
if use_sound:
|
||||||
await self._broadcast(
|
await self._broadcast(
|
||||||
ItemUseSoundPacket(
|
ItemUseSoundPacket(
|
||||||
type="item_use_sound",
|
type="item_use_sound",
|
||||||
itemId=item.id,
|
itemId=item.id,
|
||||||
sound=item.useSound,
|
sound=use_sound,
|
||||||
x=item.x,
|
x=item.x,
|
||||||
y=item.y,
|
y=item.y,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -242,3 +242,62 @@ async def test_failed_wheel_use_does_not_consume_cooldown(monkeypatch: pytest.Mo
|
|||||||
item.params["spaces"] = "a,b,c"
|
item.params["spaces"] = "a,b,c"
|
||||||
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
|
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
|
||||||
assert send_payloads[-1].ok is True
|
assert send_payloads[-1].ok is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_widget_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
server = SignalingServer("127.0.0.1", 8765, None, None)
|
||||||
|
ws = _fake_ws()
|
||||||
|
client = ClientConnection(websocket=ws, id="u1", nickname="tester", x=5, y=6)
|
||||||
|
server.clients[ws] = client
|
||||||
|
item = server.item_service.default_item(client, "widget")
|
||||||
|
server.item_service.add_item(item)
|
||||||
|
|
||||||
|
send_payloads: list[object] = []
|
||||||
|
broadcast_payloads: list[object] = []
|
||||||
|
now_ms = 50_000
|
||||||
|
|
||||||
|
async def fake_send(websocket: ServerConnection, packet: object) -> None:
|
||||||
|
send_payloads.append(packet)
|
||||||
|
|
||||||
|
async def fake_broadcast(packet: object, exclude: ServerConnection | None = None) -> None:
|
||||||
|
broadcast_payloads.append(packet)
|
||||||
|
|
||||||
|
monkeypatch.setattr(server, "_send", fake_send)
|
||||||
|
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
|
||||||
|
monkeypatch.setattr(server.item_service, "now_ms", lambda: now_ms)
|
||||||
|
|
||||||
|
await server._handle_message(
|
||||||
|
client,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"type": "item_update",
|
||||||
|
"itemId": item.id,
|
||||||
|
"params": {
|
||||||
|
"directional": True,
|
||||||
|
"facing": 123.4,
|
||||||
|
"emitRange": 7,
|
||||||
|
"useSound": "ping.ogg",
|
||||||
|
"emitSound": "https://example.com/ambient.ogg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert send_payloads[-1].ok is True
|
||||||
|
assert item.params.get("directional") is True
|
||||||
|
assert item.params.get("facing") == 123.4
|
||||||
|
assert item.params.get("emitRange") == 7
|
||||||
|
assert item.params.get("useSound") == "sounds/ping.ogg"
|
||||||
|
assert item.params.get("emitSound") == "https://example.com/ambient.ogg"
|
||||||
|
|
||||||
|
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
|
||||||
|
assert send_payloads[-1].ok is True
|
||||||
|
assert item.params.get("enabled") is False
|
||||||
|
assert any(getattr(packet, "type", "") == "item_use_sound" for packet in broadcast_payloads)
|
||||||
|
|
||||||
|
await server._handle_message(
|
||||||
|
client,
|
||||||
|
json.dumps({"type": "item_update", "itemId": item.id, "params": {"emitRange": 21}}),
|
||||||
|
)
|
||||||
|
assert send_payloads[-1].ok is False
|
||||||
|
assert "emitrange must be between 1 and 20" in send_payloads[-1].message.lower()
|
||||||
|
|||||||
Reference in New Issue
Block a user