Send world/item UI metadata in welcome and consume on client

This commit is contained in:
Jage9
2026-02-21 19:12:58 -05:00
parent 4f04e735da
commit 008de60727
9 changed files with 274 additions and 42 deletions

View File

@@ -2,7 +2,7 @@ import { EFFECT_SEQUENCE } from '../audio/effects';
import { RADIO_CHANNEL_OPTIONS } from '../audio/radioStationRuntime';
import { type ItemType, type WorldItem } from '../state/gameState';
export const CLOCK_TIME_ZONE_OPTIONS = [
const DEFAULT_CLOCK_TIME_ZONE_OPTIONS = [
'America/Anchorage',
'America/Argentina/Buenos_Aires',
'America/Chicago',
@@ -45,43 +45,88 @@ export const CLOCK_TIME_ZONE_OPTIONS = [
'UTC',
] as const;
export const ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
const DEFAULT_ITEM_TYPE_SEQUENCE: ItemType[] = ['clock', 'dice', 'radio_station', 'wheel'];
const 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'],
dice: ['title', 'sides', 'number'],
wheel: ['title', 'spaces'],
clock: ['title', 'timeZone', 'use24Hour'],
};
export const 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 },
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 },
};
export const EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
Array.from(
new Set(
Object.values(ITEM_TYPE_EDITABLE_PROPERTIES).flatMap((keys) => keys),
),
),
);
const OPTION_ITEM_PROPERTY_VALUES: Partial<Record<string, string[]>> = {
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
channel: [...RADIO_CHANNEL_OPTIONS],
timeZone: [...CLOCK_TIME_ZONE_OPTIONS],
type UiDefinitionsPayload = {
itemTypeOrder?: ItemType[];
itemTypes?: Array<{
type: ItemType;
label?: string;
editableProperties?: string[];
propertyOptions?: Record<string, string[]>;
globalProperties?: Record<string, unknown>;
}>;
};
let itemTypeSequence: ItemType[] = [...DEFAULT_ITEM_TYPE_SEQUENCE];
let itemTypeLabels: Record<ItemType, string> = {
radio_station: 'radio',
dice: 'dice',
wheel: 'wheel',
clock: 'clock',
};
let itemTypeEditableProperties: Record<ItemType, string[]> = {
radio_station: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.radio_station],
dice: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.dice],
wheel: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.wheel],
clock: [...DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES.clock],
};
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 },
};
let optionItemPropertyValues: Partial<Record<string, string[]>> = {
effect: EFFECT_SEQUENCE.map((effect) => effect.id),
channel: [...RADIO_CHANNEL_OPTIONS],
timeZone: [...DEFAULT_CLOCK_TIME_ZONE_OPTIONS],
};
export let EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(
Object.values(itemTypeEditableProperties).flatMap((keys) => keys),
);
function rebuildEditablePropertyKeySet(): void {
EDITABLE_ITEM_PROPERTY_KEYS = new Set<string>(Object.values(itemTypeEditableProperties).flatMap((keys) => keys));
}
export function getClockTimeZoneOptions(): string[] {
return [...(optionItemPropertyValues.timeZone ?? DEFAULT_CLOCK_TIME_ZONE_OPTIONS)];
}
export function getDefaultClockTimeZone(): string {
return getClockTimeZoneOptions()[0] ?? 'America/Detroit';
}
export function getItemTypeSequence(): ItemType[] {
return [...itemTypeSequence];
}
export function getItemTypeGlobalProperties(itemType: ItemType): Record<string, string | number | boolean> {
return itemTypeGlobalProperties[itemType] ?? {};
}
export function getItemPropertyOptionValues(key: string): string[] | undefined {
return OPTION_ITEM_PROPERTY_VALUES[key];
return optionItemPropertyValues[key];
}
export function itemTypeLabel(type: ItemType): string {
if (type === 'radio_station') return 'radio';
return type;
return itemTypeLabels[type] ?? type;
}
export function itemPropertyLabel(key: string): string {
@@ -90,7 +135,7 @@ export function itemPropertyLabel(key: string): string {
}
export function getEditableItemPropertyKeys(item: WorldItem): string[] {
return [...(ITEM_TYPE_EDITABLE_PROPERTIES[item.type] ?? ['title'])];
return [...(itemTypeEditableProperties[item.type] ?? ['title'])];
}
export function getInspectItemPropertyKeys(item: WorldItem): string[] {
@@ -124,7 +169,7 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] {
allKeys.push(key);
}
const globalKeys = Object.keys(ITEM_TYPE_GLOBAL_PROPERTIES[item.type] ?? {}).sort((a, b) => a.localeCompare(b));
const globalKeys = Object.keys(itemTypeGlobalProperties[item.type] ?? {}).sort((a, b) => a.localeCompare(b));
for (const key of globalKeys) {
if (seen.has(key)) continue;
seen.add(key);
@@ -133,3 +178,56 @@ export function getInspectItemPropertyKeys(item: WorldItem): string[] {
return allKeys;
}
export function applyServerItemUiDefinitions(uiDefinitions: UiDefinitionsPayload | undefined): void {
if (!uiDefinitions) return;
if (Array.isArray(uiDefinitions.itemTypeOrder) && uiDefinitions.itemTypeOrder.length > 0) {
itemTypeSequence = uiDefinitions.itemTypeOrder.filter((entry) => typeof entry === 'string') as ItemType[];
}
if (!Array.isArray(uiDefinitions.itemTypes) || uiDefinitions.itemTypes.length === 0) {
rebuildEditablePropertyKeySet();
return;
}
const nextLabels = { ...itemTypeLabels };
const nextEditable = { ...itemTypeEditableProperties };
const nextGlobals = { ...itemTypeGlobalProperties };
const nextOptions: Partial<Record<string, string[]>> = { ...optionItemPropertyValues };
for (const definition of uiDefinitions.itemTypes) {
if (!definition || typeof definition.type !== 'string') continue;
const itemType = definition.type as ItemType;
if (typeof definition.label === 'string' && definition.label.trim()) {
nextLabels[itemType] = definition.label.trim();
}
if (Array.isArray(definition.editableProperties) && definition.editableProperties.length > 0) {
nextEditable[itemType] = definition.editableProperties.filter((entry) => typeof entry === 'string');
}
if (definition.globalProperties && typeof definition.globalProperties === 'object') {
const normalized: Record<string, string | number | boolean> = {};
for (const [key, raw] of Object.entries(definition.globalProperties)) {
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
normalized[key] = raw;
}
}
nextGlobals[itemType] = normalized;
}
if (definition.propertyOptions && typeof definition.propertyOptions === 'object') {
for (const [propertyKey, values] of Object.entries(definition.propertyOptions)) {
if (!Array.isArray(values) || values.length === 0) continue;
const normalizedValues = values.filter((entry) => typeof entry === 'string');
if (normalizedValues.length > 0) {
nextOptions[propertyKey] = normalizedValues;
}
}
}
}
itemTypeLabels = nextLabels;
itemTypeEditableProperties = nextEditable;
itemTypeGlobalProperties = nextGlobals;
optionItemPropertyValues = nextOptions;
rebuildEditablePropertyKeySet();
}

View File

@@ -32,10 +32,11 @@ import {
type WorldItem,
} from './state/gameState';
import {
CLOCK_TIME_ZONE_OPTIONS,
applyServerItemUiDefinitions,
EDITABLE_ITEM_PROPERTY_KEYS,
ITEM_TYPE_GLOBAL_PROPERTIES,
ITEM_TYPE_SEQUENCE,
getDefaultClockTimeZone,
getItemTypeGlobalProperties,
getItemTypeSequence,
getEditableItemPropertyKeys,
getInspectItemPropertyKeys,
getItemPropertyOptionValues,
@@ -159,6 +160,7 @@ const WALL_SOUND_URL = withBase('sounds/wall.ogg');
const state = createInitialState();
const renderer = new CanvasRenderer(dom.canvas);
const audio = new AudioEngine();
let worldGridSize = GRID_SIZE;
let localStream: MediaStream | null = null;
let outboundStream: MediaStream | null = null;
let statusTimeout: number | null = null;
@@ -690,12 +692,12 @@ function getItemPropertyValue(item: WorldItem, key: string): string {
if (key === 'useSound') return item.useSound ?? 'none';
if (key === 'emitSound') return item.emitSound ?? 'none';
if (key === 'enabled') return item.params.enabled === false ? 'off' : 'on';
if (key === 'timeZone') return String(item.params.timeZone ?? CLOCK_TIME_ZONE_OPTIONS[0]);
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);
if (key === 'effect') return normalizeRadioEffect(item.params.effect);
if (key === 'effectValue') return String(normalizeRadioEffectValue(item.params.effectValue));
const globalValue = ITEM_TYPE_GLOBAL_PROPERTIES[item.type]?.[key];
const globalValue = getItemTypeGlobalProperties(item.type)?.[key];
if (globalValue !== undefined) return String(globalValue);
return String(item.params[key] ?? '');
}
@@ -753,7 +755,7 @@ function handleMovement(): void {
const nextX = state.player.x + dx;
const nextY = state.player.y + dy;
if (nextX < 0 || nextY < 0 || nextX >= GRID_SIZE || nextY >= GRID_SIZE) {
if (nextX < 0 || nextY < 0 || nextX >= worldGridSize || nextY >= worldGridSize) {
state.player.lastMoveTime = now;
void audio.playSample(WALL_SOUND_URL, 1);
return;
@@ -861,8 +863,8 @@ async function connect(): Promise<void> {
return;
}
state.player.x = Math.floor(Math.random() * GRID_SIZE);
state.player.y = Math.floor(Math.random() * GRID_SIZE);
state.player.x = Math.floor(Math.random() * worldGridSize);
state.player.y = Math.floor(Math.random() * worldGridSize);
const storedPosition = localStorage.getItem('spatialChatPosition');
if (storedPosition) {
try {
@@ -870,7 +872,7 @@ async function connect(): Promise<void> {
if (Number.isFinite(parsed.x) && Number.isFinite(parsed.y)) {
const x = Math.floor(parsed.x as number);
const y = Math.floor(parsed.y as number);
if (x >= 0 && x < GRID_SIZE && y >= 0 && y < GRID_SIZE) {
if (x >= 0 && x < worldGridSize && y >= 0 && y < worldGridSize) {
state.player.x = x;
state.player.y = y;
}
@@ -959,9 +961,17 @@ function disconnect(): void {
async function onMessage(message: IncomingMessage): Promise<void> {
switch (message.type) {
case 'welcome':
if (message.worldConfig?.gridSize && Number.isInteger(message.worldConfig.gridSize) && message.worldConfig.gridSize > 0) {
worldGridSize = message.worldConfig.gridSize;
}
renderer.setGridSize(worldGridSize);
applyServerItemUiDefinitions(message.uiDefinitions);
state.addItemTypeIndex = 0;
state.player.id = message.id;
state.running = true;
connecting = false;
state.player.x = Math.max(0, Math.min(worldGridSize - 1, state.player.x));
state.player.y = Math.max(0, Math.min(worldGridSize - 1, state.player.y));
dom.nicknameContainer.classList.add('hidden');
dom.connectButton.classList.add('hidden');
dom.disconnectButton.classList.remove('hidden');
@@ -1258,8 +1268,15 @@ function handleNormalModeInput(code: string, shiftKey: boolean): void {
}
if (code === 'KeyA') {
const itemTypeSequence = getItemTypeSequence();
if (itemTypeSequence.length === 0) {
updateStatus('No item types available.');
audio.sfxUiCancel();
return;
}
state.addItemTypeIndex = Math.max(0, Math.min(state.addItemTypeIndex, itemTypeSequence.length - 1));
state.mode = 'addItem';
updateStatus(`Add item: ${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`);
updateStatus(`Add item: ${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`);
audio.sfxUiBlip();
return;
}
@@ -1702,29 +1719,36 @@ function handleListItemsModeInput(code: string, key: string): void {
}
function handleAddItemModeInput(code: string, key: string): void {
const itemTypeSequence = getItemTypeSequence();
if (itemTypeSequence.length === 0) {
state.mode = 'normal';
updateStatus('No item types available.');
audio.sfxUiCancel();
return;
}
if (code === 'ArrowDown' || code === 'ArrowUp') {
state.addItemTypeIndex =
code === 'ArrowDown'
? (state.addItemTypeIndex + 1) % ITEM_TYPE_SEQUENCE.length
: (state.addItemTypeIndex - 1 + ITEM_TYPE_SEQUENCE.length) % ITEM_TYPE_SEQUENCE.length;
updateStatus(`${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`);
? (state.addItemTypeIndex + 1) % itemTypeSequence.length
: (state.addItemTypeIndex - 1 + itemTypeSequence.length) % itemTypeSequence.length;
updateStatus(`${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`);
audio.sfxUiBlip();
return;
}
const nextByInitial = findNextIndexByInitial(
ITEM_TYPE_SEQUENCE,
itemTypeSequence,
state.addItemTypeIndex,
key,
(itemType) => itemTypeLabel(itemType),
);
if (nextByInitial >= 0) {
state.addItemTypeIndex = nextByInitial;
updateStatus(`${itemTypeLabel(ITEM_TYPE_SEQUENCE[state.addItemTypeIndex])}.`);
updateStatus(`${itemTypeLabel(itemTypeSequence[state.addItemTypeIndex])}.`);
audio.sfxUiBlip();
return;
}
if (code === 'Enter') {
signaling.send({ type: 'item_add', itemType: ITEM_TYPE_SEQUENCE[state.addItemTypeIndex] });
signaling.send({ type: 'item_add', itemType: itemTypeSequence[state.addItemTypeIndex] });
state.mode = 'normal';
return;
}

View File

@@ -29,6 +29,25 @@ export const welcomeMessageSchema = z.object({
}),
),
items: z.array(itemSchema).optional(),
worldConfig: z
.object({
gridSize: z.number().int().positive(),
})
.optional(),
uiDefinitions: z
.object({
itemTypeOrder: z.array(z.enum(['radio_station', 'dice', 'wheel', 'clock'])),
itemTypes: z.array(
z.object({
type: z.enum(['radio_station', 'dice', 'wheel', 'clock']),
label: z.string().optional(),
editableProperties: z.array(z.string()),
propertyOptions: z.record(z.string(), z.array(z.string())).optional(),
globalProperties: z.record(z.string(), z.unknown()).optional(),
}),
),
})
.optional(),
});
export const signalMessageSchema = z.object({

View File

@@ -2,7 +2,8 @@ import { GRID_SIZE, type GameState, type PeerState, type WorldItem } from '../st
export class CanvasRenderer {
private readonly ctx: CanvasRenderingContext2D;
private readonly squarePixelSize: number;
private squarePixelSize: number;
private gridSize: number;
constructor(private readonly canvas: HTMLCanvasElement) {
const ctx = canvas.getContext('2d');
@@ -10,14 +11,21 @@ export class CanvasRenderer {
throw new Error('Unable to create 2D context');
}
this.ctx = ctx;
this.squarePixelSize = canvas.width / GRID_SIZE;
this.gridSize = GRID_SIZE;
this.squarePixelSize = canvas.width / this.gridSize;
}
setGridSize(gridSize: number): void {
if (!Number.isInteger(gridSize) || gridSize <= 0) return;
this.gridSize = gridSize;
this.squarePixelSize = this.canvas.width / this.gridSize;
}
draw(state: GameState): void {
const { ctx } = this;
ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
ctx.strokeStyle = '#374151';
for (let i = 0; i <= GRID_SIZE; i += 1) {
for (let i = 0; i <= this.gridSize; i += 1) {
ctx.beginPath();
ctx.moveTo(i * this.squarePixelSize, 0);
ctx.lineTo(i * this.squarePixelSize, this.canvas.height);